Skip to content

symfonic.platform.extension_policy_stage

extension_policy_stage

The rung a contributed extension policy is actually asked on (#147).

ComposedExtensions.decide_detailed has walked contributed policies for a long time -- honouring a deny, recording an abstention, allowing when nobody objects. What was missing was a caller on a kernel turn. :meth:~symfonic.platform.extensions.ExtensionCapability.contribute returned tools only, while its own docstring noted that CapabilityContribution carries "tools, stages, handlers and grants". So a deployment could contribute a policy that composed cleanly, reported success, and was consulted before nothing.

Why pre-tool, for governance.effect's reason rather than by analogy: it sits after the kernel mints the call ids and before the calls execute, so a refusal there prevents the call instead of describing one that already ran.

Why a refusal raises. It is the mechanism the sibling enforcement point already uses -- governance_verdict raises GovernanceRefused from the same rung. Returning an amended batch minus the denied call was the alternative and is worse: the model asked for a tool and would get silence, with nothing in the transcript saying it was refused or by whom.

Why an abstention denies, which is the one real choice here. ComposedExtensions deliberately turns a policy that raises into an abstention so a single broken guard is not a total outage, and harvest.py records that what an abstention costs belongs to the enforcement point rather than to the composition. The legacy enforcement point (cutover/guardrails.py) denies on one. This one denies too -- not because denying is safer in the abstract, but because two routes disagreeing over whether a guard being down means "allow" would be a worse defect than either answer alone.

ExtensionPolicyRefused

Bases: ExtensionError

A contributed policy denied an action, and the action did not happen.

policy_handler

policy_handler(composed: Any) -> Any

Build the handler that asks composed's policies about each call.

Source code in src/symfonic/platform/extension_policy_stage.py
def policy_handler(composed: Any) -> Any:
    """Build the handler that asks ``composed``'s policies about each call."""

    async def ask(context: Any) -> Any:
        for call in getattr(context, "requests", ()) or ():
            action = getattr(call, "name", "")
            outcome = await composed.decide_detailed(
                PolicyRequest(action=action, context=_context_of(call)),
            )
            verdict = outcome.verdict
            if verdict.denied:
                raise ExtensionPolicyRefused(
                    f"a contributed policy denied {action!r}: {verdict.reason}"
                )
            if outcome.abstentions:
                # Spent as a denial; see the module docstring for whose rule
                # this is and why it matches the other enforcement point.
                names = ", ".join(
                    sorted(str(getattr(a, "reason", "")) for a in outcome.abstentions)
                )
                raise ExtensionPolicyRefused(
                    f"a contributed policy abstained on {action!r} and this "
                    f"enforcement point denies on an abstention: {names}"
                )
        return no_change(POLICY_STAGE_ID)

    return ask

policy_stage

policy_stage() -> StageDescriptor

The pre-tool stage a contributed policy is asked in.

Declares no effect family: asking a policy performs none of the kernel's effects, and declaring one it was not granted would be refused at fold.

Source code in src/symfonic/platform/extension_policy_stage.py
def policy_stage() -> StageDescriptor:
    """The ``pre-tool`` stage a contributed policy is asked in.

    Declares no effect family: asking a policy performs none of the kernel's
    effects, and declaring one it was not granted would be refused at fold.
    """
    return StageDescriptor(
        stage_id=POLICY_STAGE_ID, phase=Phase.PRE_TOOL, capability="extensions",
    )