symfonic.kernel.contracts.effects¶
effects ¶
The closed vocabulary of effect families a stage may declare (STG-8).
Before this module, StageDescriptor.effects and effect_grants were
frozenset[str] with no membership check anywhere. Any string passed. That is
tolerable while grants are internal wiring, and stops being tolerable the moment
an adopter writes GrantEffects("memory-read") at a composition root: these
strings are then authorization identifiers with a compatibility commitment, and
a typo in one is a stage that silently never runs rather than a refusal.
Deliberately not :class:symfonic.services.shadow.EffectFamily. That enum
answers a different question — which ports a shadow run must classify — and
its members (provider, tool, persistence…) are port categories, not
things an invocation grants. It also omits model_call and tool_call, the
two granted on every single invocation, while calling itself exhaustive. Two
vocabularies with two purposes; merging them would make both wrong.
Only production, authorizable effects live here. A family earns membership
by being declared by a stage that ships, not by appearing in a test. The tree
had memory.write and network in effect_grants in tests only; both are
absent below on purpose. Promoting a test fixture's arbitrary string to public
API is how a vocabulary stops meaning anything, and tests that need an
unauthorized family should use a real one they were not granted — which is the
scenario they are actually testing.
No renaming here. The separator drift is real (model_call and
tool_call with underscores, memory-read with a hyphen) and is left
exactly as it is. "tool_call" is simultaneously an emitted event kind
(agent/backend/plan.py:62) and an effect family (plan.py:153), so
renaming it is an event-pipeline migration with its own end-to-end tests and its
own public-compatibility decision. Closing the set is what buys safety; the
separator is cosmetics, and mixing the two would put a silent-failure risk
inside an authorization boundary that has to stay easy to review.
EffectFamily ¶
Bases: StrEnum
Every effect family an invocation can grant. Closed by contract.
Membership is the authorization vocabulary, not a catalogue of everything the framework does. A capability that needs a family absent here is a capability whose effect nobody has decided how to authorize yet, and that decision is the point — not the string.
GrantEffects ¶
An adopter's explicit authorization, written at the composition root.
Usage, and the whole point of the shape::
Agent(provider, capabilities=[
GrantEffects("memory-read"),
MemoryCapability(bridge),
])
It is not a capability. It contributes no stage, no handler and no tool; the facade recognises it by exact type, takes its families, and removes it from the sequence before folding. That ordering is the security property: a contribution can only ever narrow a set that was established without its participation, and it cannot manufacture another top-level entry.
Why a separate type rather than an Agent parameter: FAC-4 freezes the
facade at four constructor arguments, and extension happens through
capabilities=. Why not "installing the capability implies the grant":
that conflates installation with authorization and hides the authority from
the place a reviewer looks for it.
The honest limit, stated rather than papered over: nothing stops third-party
code from instantiating this type. The boundary is that the composition
root chose the top-level sequence. If untrusted code controls that sequence,
nominal framework provenance cannot recover authority — that needs policy
outside this process. And a granted family is granted for the whole
invocation, not to one capability: once memory-read is granted, any
capability in the same list may use it. Per-capability authority would need
grants keyed by capability rather than today's frozenset[str].
Source code in src/symfonic/kernel/contracts/effects.py
partition_grants ¶
Split a capabilities= sequence into authorized families and the rest.
Steps 1 and 2 of the five the review specified: scan only top-level
entries, and recognise by exact type — not a protocol, not a name, not
an attribute, not a subclass. A subclass check would let third-party code
inherit its way into granting; a duck-typed check would let any object with
a families attribute do it.
Returns:
| Type | Description |
|---|---|
frozenset[str]
|
|
tuple[Any, ...]
|
and the entries with the declarations removed (step 4), so what reaches |
tuple[frozenset[str], tuple[Any, ...]]
|
|
Source code in src/symfonic/kernel/contracts/effects.py
require_known_families ¶
Refuse any family outside the closed set. Fail-closed (SEC-FCP-1).
Called at construction — of a stage descriptor, of a grant, and of the plan — rather than at dispatch, because an unknown family reaching dispatch has already cost the caller a compile and, worse, is indistinguishable there from a family that was simply not granted. A typo should read as a typo.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
families
|
Iterable[str]
|
the declared or granted family strings. |
required |
subject
|
str
|
what is being validated, for the message — a stage id, or the capability whose grant is being checked. A refusal that does not name its subject sends the reader hunting through a plan. |
required |
Raises:
| Type | Description |
|---|---|
ConfigurationError
|
naming the offending families and the legal set. |