Skip to content

symfonic.kernel.contracts.contributions

contributions

What a capability hands the compiler — W2 phase 2.

Until now CompileRequest.capabilities was a sequence of names, recorded for diagnostics, while the stages themselves were assembled into the request by hand. That works and is what the extension-seam probe does, but it leaves the two halves of a capability — what it declares and what runs it — in different places, joined by nothing a compiler can check.

A :class:CapabilityContribution is both halves in one value. That is what makes the rule enforceable:

**Every stage a contribution declares must be answered by a handler in the
same contribution.**

Scoped to contributions on purpose. The same rule applied to every compiled stage was written, landed and reverted within an hour, because it made the existing hand-assembled path uncompilable — all 17 probe tests failed on legitimate use of a shipped API. A rule that breaks working callers to prevent a defect they do not have is a worse rule than the defect. Applied here it costs nobody anything: a contribution is new, and a capability that declares a stage it cannot run has simply not finished.

The second invariant is the one T2.3.1 wrote as STG-8 and nothing enforced: a contribution's effect_grants must cover the union of its stages' declared effects. A capability that declares an effect it was not granted is refused at compile rather than at the point of the effect.

Neither invariant makes a capability useful. Both make a specific way of being useless impossible to express, which is the only kind of guarantee this codebase has learned to trust.

CapabilityConfig

Bases: Protocol

The seam Agent(capabilities=[...]) accepts.

One method, because a capability's whole job at compile time is to answer "given these grants, what do you contribute?". Anything it needs to do happens in its handlers, at dispatch, where the kernel can bound it.

CapabilityContribution dataclass

CapabilityContribution(capability: str, stages: tuple[StageDescriptor, ...] = (), handlers: Mapping[str, Any] = (lambda: MappingProxyType({}))(), effect_grants: frozenset[str] = frozenset(), tools: tuple[Any, ...] = (), preconditions: tuple[Any, ...] = ())

One capability's declaration and the code that answers it.

validate

validate() -> None

Refuse the shapes that compile and cannot work, or must not.

Raised at fold time so the offending capability is named, rather than surfacing later as a stage nothing answers or an effect nobody granted.

Source code in src/symfonic/kernel/contracts/contributions.py
def validate(self) -> None:
    """Refuse the shapes that compile and cannot work, or must not.

    Raised at fold time so the offending capability is named, rather than
    surfacing later as a stage nothing answers or an effect nobody granted.
    """
    self._require_own_stages()
    declared = [stage.stage_id for stage in self.stages]
    unanswered = sorted(set(declared) - set(self.handlers))
    if unanswered:
        raise ConfigurationError(
            f"capability {self.capability!r} declares stage(s) {unanswered} "
            "and supplies no handler for them. A declared stage nothing "
            "answers orders correctly, appears in diagnostics, and does "
            "nothing — supply a handler, or do not declare the stage."
        )
    stray = sorted(set(self.handlers) - set(declared))
    if stray:
        raise ConfigurationError(
            f"capability {self.capability!r} supplies handler(s) {stray} for "
            "stages it does not declare. A handler for a stage that will "
            "never be compiled is dead code the plan cannot reach."
        )
    needed = frozenset().union(
        *(frozenset(stage.effects) for stage in self.stages)
    ) if self.stages else frozenset()
    require_known_families(
        self.effect_grants, subject=f"capability {self.capability!r}"
    )
    ungranted = sorted(needed - self.effect_grants)
    if ungranted:
        raise ConfigurationError(
            f"capability {self.capability!r} declares stage effect(s) "
            f"{ungranted} that its own contribution does not grant (STG-8). "
            "A stage may exercise only effect families the plan granted; "
            "declaring one it did not ask for makes the grant list a "
            "description rather than a bound."
        )

CapabilityRequest dataclass

CapabilityRequest(effect_grants: frozenset[str] = frozenset(), options: Mapping[str, Any] = (lambda: MappingProxyType({}))())

What a capability is told before it decides what to contribute.

Deliberately small. A capability that needs to inspect the whole compile request to decide its stages is one whose contribution depends on another capability's, and that is an ordering problem the stage ladder already solves — not something to solve again by widening this.

fold_contributions

fold_contributions(configs: Sequence[CapabilityConfig], *, effect_grants: frozenset[str] = frozenset(), options: Mapping[str, Any] | None = None) -> tuple[tuple[StageDescriptor, ...], dict[str, Any], frozenset[str], tuple[tuple[str, Any], ...], tuple[str, ...], tuple[Any, ...]]

Collect every contribution into the pieces a CompileRequest needs.

Returns (stages, handlers, grants, tools, capability_names, preconditions), where each tool is paired with the name of the capability that offered it.

Preconditions accumulate in contribution order and none replaces another: they are checks on one call, the first objection wins, and a capability that could drop a peer's check would be granting itself a bypass.

The pairing is attribution the fold can produce and the caller cannot: two capabilities offering the same tool name has to be refused by name of capability, and a composition root holding a flat tuple of tool objects can only report the collision as "two tools called run_agent" — true, and useless to whoever has to fix it. The kernel reads no attribute of the tool to do this, so it still interprets nothing.

Two capabilities claiming the same stage_id is refused here rather than resolved by ordering: whichever won would depend on registration order, and a capability whose stage silently never runs is the failure this whole phase exists to make impossible.

Source code in src/symfonic/kernel/contracts/contributions.py
def fold_contributions(
    configs: Sequence[CapabilityConfig],
    *,
    effect_grants: frozenset[str] = frozenset(),
    options: Mapping[str, Any] | None = None,
) -> tuple[
    tuple[StageDescriptor, ...],
    dict[str, Any],
    frozenset[str],
    tuple[tuple[str, Any], ...],
    tuple[str, ...],
    tuple[Any, ...],
]:
    """Collect every contribution into the pieces a ``CompileRequest`` needs.

    Returns ``(stages, handlers, grants, tools, capability_names,
    preconditions)``, where each tool is paired with the name of the capability
    that offered it.

    Preconditions accumulate in contribution order and none replaces another:
    they are checks on one call, the first objection wins, and a capability
    that could drop a peer's check would be granting itself a bypass.

    The pairing is attribution the fold can produce and the caller cannot: two
    capabilities offering the same tool name has to be refused *by name of
    capability*, and a composition root holding a flat tuple of tool objects
    can only report the collision as "two tools called ``run_agent``" — true,
    and useless to whoever has to fix it. The kernel reads no attribute of the
    tool to do this, so it still interprets nothing.

    Two capabilities claiming the same ``stage_id`` is refused here rather than
    resolved by ordering: whichever won would depend on registration order, and
    a capability whose stage silently never runs is the failure this whole phase
    exists to make impossible.
    """
    request = CapabilityRequest(
        effect_grants=effect_grants,
        options=MappingProxyType(dict(options or {})),
    )

    stages: list[StageDescriptor] = []
    handlers: dict[str, Any] = {}
    grants: set[str] = set()
    tools: list[tuple[str, Any]] = []
    names: list[str] = []
    preconditions: list[Any] = []
    owner: dict[str, str] = {}

    for config in configs:
        contribution = config.contribute(request)
        contribution.validate()

        for stage in contribution.stages:
            if stage.stage_id in owner:
                raise ConfigurationError(
                    f"stage id {stage.stage_id!r} is claimed by both "
                    f"{owner[stage.stage_id]!r} and {contribution.capability!r}. "
                    "Stage ids are the routing key for handlers, so a collision "
                    "would make one capability's stage unreachable depending on "
                    "registration order."
                )
            owner[stage.stage_id] = contribution.capability
            stages.append(stage)

        handlers.update(contribution.handlers)
        grants |= contribution.effect_grants
        tools.extend((contribution.capability, tool) for tool in contribution.tools)
        preconditions.extend(contribution.preconditions)
        names.append(contribution.capability)

    # No escape when ``effect_grants`` is empty. The first version read
    # ``... if effect_grants else []``, meant as a courtesy to callers with
    # nothing to check against, and it was an authorization hole: the facade
    # called this without grants, so every capability-declared grant was
    # accepted unchecked. A capability could put "network" in its own
    # contribution, declare a stage with that effect, and the dispatcher's
    # STG-8 check passed -- because the grant set it validates against had just
    # been widened by the thing it was validating. An empty grant set means the
    # invocation holds nothing, which is a reason to refuse everything, not to
    # check nothing.
    ungranted = sorted(grants - effect_grants)
    if ungranted:
        raise ConfigurationError(
            f"capabilities requested effect grant(s) {ungranted} the invocation "
            f"does not hold (it grants {sorted(effect_grants) or 'nothing'}). A "
            "capability may narrow what it uses; it may not widen what the plan "
            "was given, and it may not grant itself. The grant has to come from "
            "whoever composed the invocation."
        )

    return (
        tuple(stages),
        handlers,
        frozenset(grants),
        tuple(tools),
        tuple(names),
        tuple(preconditions),
    )