symfonic.capabilities.extensions.composition¶
composition ¶
Composing extensions into one frozen value (T4.2.2).
:func:compose is a pure function over contributions. It holds no module state,
touches no engine, and returns a :class:ComposedExtensions — so "what do my
extensions add up to?" is a question with an answer you can print, diff between
two deployments, and assert equal across two runs.
The admission rules — reserved names, first-declaration-wins, total ordering —
live in :mod:~symfonic.capabilities.extensions.admission. One rule stays here
because it is a property of the composed value rather than of any single
contribution:
Policies may only refuse. :meth:ComposedExtensions.decide combines
verdicts deny-wins and treats ALLOW and ABSTAIN alike as "no objection". An
extension cannot vote to permit something the host would refuse, so adding one
can only narrow what the agent does (AS-INT-3).
ComposedExtensions
dataclass
¶
ComposedExtensions(extensions: tuple[str, ...] = (), tools: tuple[ToolContribution, ...] = (), prompts: tuple[PromptFragment, ...] = (), policies: tuple[PolicyContribution, ...] = (), lifecycle: tuple[LifecycleContribution, ...] = (), diagnostics: tuple[ExtensionDiagnostic, ...] = ())
What a set of extensions adds up to, and what was refused on the way.
refusals
property
¶
Only the error-severity diagnostics: what an operator must see.
decide
async
¶
Combine every applicable policy's verdict, deny-wins.
Answers only the combined verdict. Use :meth:decide_detailed when the
caller needs to know which guards failed to answer as well.
Source code in src/symfonic/capabilities/extensions/composition.py
decide_detailed
async
¶
Ask every applicable policy and report the verdict with the abstentions.
The first DENY short-circuits: once one policy has refused, asking the rest costs latency on a turn that is already refused, and no later verdict could change the answer. The abstentions reported are therefore those gathered before the refusal, which is the whole set whenever nothing denied.
A policy that raises abstains. That is the legacy engine's fail-open behaviour preserved on purpose — flipping it to fail-closed would turn a broken guard into a total outage — but the abstention is now a value the caller can see rather than a DEBUG log nobody reads.
Source code in src/symfonic/capabilities/extensions/composition.py
install
async
¶
Run every install hook. A hook that raises costs a diagnostic.
teardown
async
¶
Run every teardown hook, in reverse declaration order.
Reverse because unwinding is the inverse of building: an extension installed after another may depend on it, and closing in declaration order would tear the dependency out first.
Source code in src/symfonic/capabilities/extensions/composition.py
PolicyOutcome
dataclass
¶
The combined verdict and every policy that failed to answer.
Exists because "nobody objected" and "the only guard that had an opinion
was down" are different facts about a turn, and
:meth:ComposedExtensions.decide can only return one verdict. An
abstention recorded here is the value that keeps a permanently broken guard
distinguishable from an approving one.
compose ¶
compose(contributions: Sequence[ExtensionContribution], *, reserved_tool_names: frozenset[str] = frozenset()) -> ComposedExtensions
Validate, de-duplicate, and order every contribution into one value.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
contributions
|
Sequence[ExtensionContribution]
|
The bundles, in the order the deployment declared them. Order is the tie-break for every collision, so it is the deployment's statement of precedence. |
required |
reserved_tool_names
|
frozenset[str]
|
Names the host already owns. A contributed tool claiming one is refused. |
frozenset()
|
Raises:
| Type | Description |
|---|---|
ExtensionContractError
|
A contribution is malformed. |
PrivilegeWideningError
|
A contribution claims authority it lacks — an authored tier, a foreign attribution, the kernel layer. |