Skip to content

symfonic.kernel.contracts.results

results

What a stage hands back, and why the envelope is not just a value.

Decision 2 of W2/1b: a stage returns a contribution, it does not mutate the request context. Decision 3: it returns its events in the same result, so one execution is one transactional unit — the dispatcher validates, applies the contribution, then publishes a contiguous block of events. A stage that fails leaves no partial events announcing a change that never landed.

The envelope exists because an empty contribution does not explain itself. This codebase reached ~27,000 unreachable lines by letting "declared" and "does something" be indistinguishable, six separate times. NoChange(reason) is the antidote: a stage that legitimately had nothing to do says so and says why, and is therefore distinguishable from a stage that is broken, refused, or inert.

Three rules that are easy to get wrong and are load-bearing here:

  • Returning is not applying. proposed and applied are different trace states. The dispatcher may validate, merge, reject, or find a proposal changes nothing — so a handler cannot assume its contribution took effect.
  • emits is an upper bound, checked. A stage may emit fewer events than it declared (emission is conditional); it may never emit one it did not declare. That is what stops CompiledStage.emits from being the next field that is declared and never read.
  • No universal contribution type. Each phase returns its own — a PromptContribution is not a tool decision. They share only this envelope. A generic bag would push the "what does this mean?" question to every reader.

StageOutcome

Bases: StrEnum

What happened to one stage execution.

Four states, not two, because "nothing changed" has three distinct causes and conflating them is how an inert stage hides behind a healthy one.

APPLIED class-attribute instance-attribute

APPLIED = 'applied'

The stage proposed a contribution and the dispatcher applied it.

FAILED class-attribute instance-attribute

FAILED = 'failed'

The stage raised. Carries the error for the trace; the dispatcher decides whether that ends the turn (EVT-7) or degrades.

NO_CHANGE class-attribute instance-attribute

NO_CHANGE = 'no-change'

The stage ran, had nothing to contribute, and said why.

REJECTED class-attribute instance-attribute

REJECTED = 'rejected'

The stage proposed something the dispatcher refused. Not an error: a contribution can be well-formed and still inadmissible.

StageResult dataclass

StageResult(outcome: StageOutcome, contribution: ContributionT | None = None, events: tuple[Any, ...] = (), reason: str = '', error: BaseException | None = None, diagnostics: tuple[str, ...] = tuple(), counts: Mapping[str, int] = (lambda: EMPTY_COUNTS)())

Bases: Generic[ContributionT]

One stage execution: what it proposes, what it emits, and why.

contribution is None for every outcome except :attr:StageOutcome .APPLIED — and the constructors below are the supported way to build one, because they make that invariant unstatable rather than merely documented.

applied

applied(contribution: ContributionT, *, events: tuple[Any, ...] = (), diagnostics: tuple[str, ...] = (), counts: Mapping[str, int] | None = None) -> StageResult[ContributionT]

The stage proposes a change.

Source code in src/symfonic/kernel/contracts/results.py
def applied(
    contribution: ContributionT,
    *,
    events: tuple[Any, ...] = (),
    diagnostics: tuple[str, ...] = (),
    counts: Mapping[str, int] | None = None,
) -> StageResult[ContributionT]:
    """The stage proposes a change."""
    return StageResult(
        counts=freeze_counts(counts),
        outcome=StageOutcome.APPLIED,
        contribution=contribution,
        events=events,
        diagnostics=diagnostics,
    )

failed

failed(error: BaseException, *, reason: str = '') -> StageResult[Any]

The stage raised.

Source code in src/symfonic/kernel/contracts/results.py
def failed(error: BaseException, *, reason: str = "") -> StageResult[Any]:
    """The stage raised."""
    return StageResult(
        outcome=StageOutcome.FAILED, error=error, reason=reason or str(error)
    )

freeze_counts

freeze_counts(counts: Mapping[str, int] | None) -> Mapping[str, int]

A read-only copy, so a stage cannot edit what it already reported.

Source code in src/symfonic/kernel/contracts/results.py
def freeze_counts(counts: Mapping[str, int] | None) -> Mapping[str, int]:
    """A read-only copy, so a stage cannot edit what it already reported."""
    if not counts:
        return EMPTY_COUNTS
    return MappingProxyType({str(name): value for name, value in counts.items()})

no_change

no_change(reason: str, *, diagnostics: tuple[str, ...] = (), counts: Mapping[str, int] | None = None) -> StageResult[Any]

The stage ran and had nothing to do. reason is not optional.

Counts are accepted here too, and that is the interesting case: a retrieval that found nothing reports found=0, which is a measurement. Reporting no counts at all would leave a reader unable to tell it from a stage that does not count.

Source code in src/symfonic/kernel/contracts/results.py
def no_change(
    reason: str,
    *,
    diagnostics: tuple[str, ...] = (),
    counts: Mapping[str, int] | None = None,
) -> StageResult[Any]:
    """The stage ran and had nothing to do. ``reason`` is not optional.

    Counts are accepted here too, and that is the interesting case: a
    retrieval that found nothing reports ``found=0``, which is a measurement.
    Reporting no counts at all would leave a reader unable to tell it from a
    stage that does not count.
    """
    return StageResult(
        outcome=StageOutcome.NO_CHANGE,
        reason=reason,
        diagnostics=diagnostics,
        counts=freeze_counts(counts),
    )

rejected

rejected(reason: str, *, diagnostics: tuple[str, ...] = ()) -> StageResult[Any]

The stage declined to act, and says why.

Source code in src/symfonic/kernel/contracts/results.py
def rejected(reason: str, *, diagnostics: tuple[str, ...] = ()) -> StageResult[Any]:
    """The stage declined to act, and says why."""
    return StageResult(
        outcome=StageOutcome.REJECTED, reason=reason, diagnostics=diagnostics
    )