Skip to content

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

GrantEffects(*families: str)

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
def __init__(self, *families: str) -> None:
    declared = frozenset(families)
    if not declared:
        raise ConfigurationError(
            "GrantEffects() declares no family. An empty authorization is a "
            "no-op that reads at the composition root as though something was "
            "granted; name the families or remove the entry."
        )
    require_known_families(declared, subject="a GrantEffects declaration")
    ungrantable = sorted(declared - ADOPTER_GRANTABLE)
    if ungrantable:
        raise ConfigurationError(
            f"GrantEffects declares {ungrantable}, which an adopter does not "
            f"grant. Grantable at a composition root: {sorted(ADOPTER_GRANTABLE)}. "
            "model_call and tool_call are derived from the invocation itself — a "
            "turn calls the model, and carries tools or does not — so declaring "
            "them here would be a second source of truth for a fact the plan "
            "already knows."
        )
    # Private plus a read-only property: every other invariant in this
    # module is established at construction and the object then treated as a
    # value, and a writable attribute broke that. ``partition_grants``
    # re-reads this without re-validating, so assigning to it granted a
    # family ADOPTER_GRANTABLE deliberately withholds -- and since __eq__
    # and __hash__ are defined over it, a GrantEffects in a set could have
    # its hash change underneath it.
    self._families: frozenset[str] = declared

partition_grants

partition_grants(entries: Sequence[Any]) -> tuple[frozenset[str], tuple[Any, ...]]

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]

(authorized, remaining) — the union of every declaration's families,

tuple[Any, ...]

and the entries with the declarations removed (step 4), so what reaches

tuple[frozenset[str], tuple[Any, ...]]

fold_contributions contains no authority at all.

Source code in src/symfonic/kernel/contracts/effects.py
def partition_grants(
    entries: Sequence[Any],
) -> tuple[frozenset[str], tuple[Any, ...]]:
    """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:
        ``(authorized, remaining)`` — the union of every declaration's families,
        and the entries with the declarations removed (step 4), so what reaches
        ``fold_contributions`` contains no authority at all.
    """
    authorized: set[str] = set()
    remaining: list[Any] = []
    for entry in entries:
        if type(entry) is GrantEffects:
            # Re-validated on read rather than trusted to have stayed sealed.
            # ``__slots__ = ("_families",)`` still leaves ``_families`` writable
            # -- one underscore of extra effort -- and this function is the only
            # consumer, so checking here does not depend on the object being
            # immutable at all. That is the stronger position: an invariant
            # enforced where it is used cannot be undone between construction
            # and use.
            families = entry.families
            require_known_families(families, subject="a GrantEffects declaration")
            ungrantable = sorted(families - ADOPTER_GRANTABLE)
            if ungrantable:
                raise ConfigurationError(
                    f"a GrantEffects declaration carries {ungrantable}, which an "
                    f"adopter does not grant. Grantable: {sorted(ADOPTER_GRANTABLE)}."
                )
            authorized |= families
            continue
        remaining.append(entry)
    return frozenset(authorized), tuple(remaining)

require_known_families

require_known_families(families: Iterable[str], *, subject: str) -> None

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.

Source code in src/symfonic/kernel/contracts/effects.py
def require_known_families(families: Iterable[str], *, subject: str) -> None:
    """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.

    Args:
        families: the declared or granted family strings.
        subject: 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.

    Raises:
        ConfigurationError: naming the offending families and the legal set.
    """
    unknown = sorted(frozenset(families) - EFFECT_FAMILIES)
    if not unknown:
        return
    raise ConfigurationError(
        f"{subject} declares effect family/families {unknown}, which are not in the "
        f"closed vocabulary {sorted(EFFECT_FAMILIES)}. Effect families are "
        "authorization identifiers, not free-form labels: an unrecognised one is a "
        "stage that would never be granted and would silently never run. Add the "
        "family to EffectFamily if it is a real effect somebody has decided how to "
        "authorize, and otherwise fix the spelling."
    )