Contracts

decision-log-flush.md

Contract: Decision events reach the decision log on every engine-facing path

Modules: src/runtime/next/runtime_bridge.py, src/specify_cli/events/decision_log.py Durable record: kitty-specs/<mission>/decisions.events.jsonl (coordination partition)

Rules

#RuleBeforeAfterTest
F1On a strict-policy advance (enabled and timing == "before_completion" and failure_policy == "block") whose decision is decision_required, the buffered DecisionInputRequested is appended to the decision log exactly once after the flush.dropped (flushed into plain seam)appended oncetest_strict_policy_decision_required_reaches_decision_log (red→green)
F2On composition dispatch, a DecisionInputRequested raised by advance_run_state_after_composition is appended to the decision log, regardless of policy.dropped (plain seam passed)appended oncetest_composition_dispatch_decision_required_reaches_decision_log (red→green)
F3On a strict-policy terminal advance whose gate refuses, nothing is appended to the decision log and no MissionRunCompleted reaches any sink; run state is rolled back.holdsholdstest_strict_policy_refused_terminal_gate_writes_nothing (green→green)
F4A single gated decision_required advance yields exactly one decision-log entry (no duplicate from flush + direct emit).n/aholdstest_gated_flush_does_not_duplicate
F5Non-decision moments buffered on the gated path pass through DecisionGitLog to inner in original order.holds via plain seamholds via wrapexisting buffer tests + F1 fixture asserts order
F6DecisionGitLog.seed_from_snapshot(snapshot) delegates to inner.seed_from_snapshot when present, otherwise no-op; never raises.absentpresenttests/specify_cli/events/test_decision_log.py (new case)
F7The bridge source contains no engine-facing reference to the plain seam: neither flush(ctx.sync_emitter) nor sync_emitter=ctx.sync_emitter.violated ×2holdstests/architectural/test_runtime_emitter_seam.py

Code changes that satisfy the rules

  • runtime_bridge.py:2187buffer.flush(ctx.sync_emitter)buffer.flush(ctx.emitter_for_engine) (F1, F4, F5).
  • runtime_bridge.py:1976sync_emitter=ctx.sync_emittersync_emitter=ctx.emitter_for_engine (F2).
  • decision_log.py — add seed_from_snapshot pass-through (F6; required by F2 because runtime_bridge_engine.py:344 seeds the emitter it receives).

Disclosure

CHANGELOG [Unreleased] - 3.2.7rc1### Fixed: one entry stating that decision requests raised under the strict retrospective policy and on composition dispatch were never written to the mission's decision log, that they now are, and that the no-op emitter seam was consolidated onto the canonical runtime Protocol (ADR 2026-09-06-2).

emitter-seam.md

Contract: Runtime Emitter Seam (factory, registry, null implementation)

Module: src/runtime/next/_internal_runtime/events.py (re-exported by _internal_runtime/emitter.py) Consumers: runtime_bridge.py (2 construction sites), runtime_bridge_engine.py (types only), future E3 producer, tests.

Public surface

class RuntimeEventEmitter(Protocol):        # unchanged: eight emit_*(payload) -> None
class NullEmitter:                          # existing
    def __init__(self, correlation_id: str = "") -> None                  # unchanged signature
    @classmethod
    def for_mission(cls, *, feature_dir: Path, mission_slug: str, mission_type: str) -> "NullEmitter"
    def seed_from_snapshot(self, snapshot: Any) -> None                    # no-op

def runtime_emitter_for_mission(*, feature_dir: Path, mission_slug: str, mission_type: str) -> RuntimeEventEmitter
def register_runtime_emitter_factory(factory: Callable[..., RuntimeEventEmitter]) -> None
def reset_runtime_emitter_factory() -> None

__all__ gains: "runtime_emitter_for_mission", "register_runtime_emitter_factory", "reset_runtime_emitter_factory" (both in events.py and the emitter.py shim).

Behavioral rules

#RuleTest
S1runtime_emitter_for_mission returns a NullEmitter when no factory is registered.test_internal_runtime_coverage (new)
S2When SPEC_KITTY_SYNC_MINIMAL_IMPORT is truthy (per is_truthy), it returns a NullEmitter even if a factory is registered, and does not call the registered factory. Env is read at call time.same
S3When a factory is registered and the env gate is off, the registered callable is invoked with the same keyword arguments and its return is passed through unmodified.same
S4reset_runtime_emitter_factory() restores S1.same
S5NullEmitter.for_mission resolves mission_id via specify_cli.mission_metadata.resolve_mission_identity(feature_dir).mission_id; any exception degrades to None. Never raises.same
S6Every NullEmitter method, including seed_from_snapshot, is a no-op and never raises.existing NullEmitter tests + new
S7Exactly one class named RuntimeEventEmitter exists under src/runtime/next/; runtime.next.event_emitter is not importable.tests/architectural/test_runtime_emitter_seam.py (new)
S8The bridge obtains the seam only by calling runtime_emitter_for_mission (imported by name), never by constructing a concrete class.same guard (source grep)

Product lifecycle rules (added 2026-09-06 after the pre-PR squad; binding on E3)

#Rule
S9The factory is invoked per bridge entry (once per decide_next_via_runtime, once per answer_decision_via_runtime); the product is per-call and must not hold cross-call state. Correlate on payload run_id / decision_id, never on construction order.
S10seed_from_snapshot may be called 0..n times per product, before or after the first emit, and may be skipped entirely by a tolerant caller; it must be idempotent (latest-wins) and side-effect-free. On the decide path today MissionRunStarted is emitted before the seed runs — a producer must bootstrap from that payload, not from seed order (follow-up #3929 item 2).
S11Engine payloads may arrive without mission_id / mission_slug; a producer that needs identity on the wire resolves it in its factory (as NullEmitter.for_mission does) and stamps it itself.

Registration contract for a future producer (E3, out of scope here)

A producer registers once at import tail, mirroring status/adapters.py:364-365:

if not is_truthy(os.environ.get("SPEC_KITTY_SYNC_MINIMAL_IMPORT")):
    register_runtime_emitter_factory(MyProducer.for_mission)

The registered callable must accept feature_dir, mission_slug, mission_type as keywords and return an object satisfying the Protocol. It should also provide seed_from_snapshot(snapshot); the bridge tolerates its absence. Hook lookup and invocation failures are logged and ignored through seed_runtime_emitter; failed instrumentation must not erase a successfully read mission phase or block composition advancement.

Test substitution contract

Tests replace the seam by patching the name on the bridge module:

monkeypatch.setattr(runtime_bridge, "runtime_emitter_for_mission", lambda **_: fake)

or by wrapping it (oracle spy pattern). Tests that exercise the registry call register_runtime_emitter_factory and must reset_runtime_emitter_factory() in teardown.