Skip to content

symfonic.kernel.finalize

finalize

The finalize seam: capability stages that close a turn that finished.

Not :mod:symfonic.kernel.finalizers, and the names are close enough that the distinction has already cost a reader once. That module is the run's finalizer stack -- callables popped during teardown, which the kernel owns. This one dispatches the FINALIZE phase of the stage ladder, which capabilities contribute to. Phase.TEARDOWN is kernel-owned and stays closed; FINALIZE is the last rung a capability may reach.

Only a turn that finished. The caller runs this after the loop completes and before the outcome is assembled, so a paused run and a failed run never reach it. That is deliberate rather than incidental: the phase's first real consumer is memory's flush, and publishing what a turn staged is exactly the thing that must not happen when the turn did not produce it. A pause is resumable and its memories belong to the resumed turn; a failure produced no answer to remember.

It applies nothing. apply=None, the same documented case post-model states: no contract exists for what a finalize contribution mutates, and inventing one here would be the fabrication those modules refused. A finalize stage is here for its effect, and its receipt rides in the trace.

It grants explicitly. grants=None skips the effect check rather than failing it, so a rung that forgets the argument reopens STG-8 in silence -- the PR #92 class. Every other rung passes it by hand for that reason and this one does too.

FinalizeContext dataclass

FinalizeContext(plan: Any, request: Any, stage: Any, resolved: Any, turn: Any, transcript: Any = None)

What a finalize stage is handed.

run_finalize async

run_finalize(plan: Any, request: Any, turn: Any, *, resolved: Any = None, transcript: Any = None, handlers: Mapping[str, StageHandler] | None = None) -> tuple[StageTrace, ...]

Dispatch the finalize stages for one turn that ran to completion.

Source code in src/symfonic/kernel/finalize.py
async def run_finalize(
    plan: Any,
    request: Any,
    turn: Any,
    *,
    resolved: Any = None,
    transcript: Any = None,
    handlers: Mapping[str, StageHandler] | None = None,
) -> tuple[StageTrace, ...]:
    """Dispatch the ``finalize`` stages for one turn that ran to completion."""
    bound = getattr(plan.bindings, "stage_handlers", None) or {}
    table: dict[str, StageHandler] = {**bound, **(handlers or {})}
    traces = await StageDispatcher(table).run_phase(
        plan.stage_program,
        Phase.FINALIZE,
        context_for=lambda stage: FinalizeContext(
            plan=plan, request=request, stage=stage, resolved=resolved,
            turn=turn, transcript=transcript,
        ),
        grants=frozenset(getattr(plan, "effect_grants", frozenset())),
        stop_on_failure=True,
    )
    # EVT-7. A handler that raises becomes a FAILED trace, and a trace nobody
    # reads is a crash the turn reports as success. Worth naming what this
    # phase loses without it: a flush that crashed would leave every memory the
    # turn staged unpublished and invisible, and the turn would answer normally
    # -- the empty-store symptom post-model's own guard was added for.
    require_no_crashed_stage(traces, phase="finalize")
    # A denied grant or missing handler is also a missing commit, even when
    # no exception exists. Never finish successfully after skipping one.
    require_no_failed_stage(traces, phase="finalize")
    return traces