An event-native core with one canonical event writer behind a domain port
Status: Accepted (2026-10-14). Decided by the operator on 2026-10-14; this record writes the decision down. Amended 2026-10-22 by ADR 2026-10-22-1 (D-1, D-2, D-5 and the Confirmation; see Amendments at the end).
Date: 2026-10-14
Deciders: Stijn Dejongh (operator).
Technical Story:
- Operator ruling, 2026-10-14 (quoted under Context).
- It builds on ADR 2026-10-13-2 (pub, ledger, sub; a versioned row contract in
spec-kitty-events). It amends that record's D-1 row list: the lifecycle row kind is retired, not given its own schema (see the Amendments note added there). It also settles how D-1's "versioned row schema" and the operator's "canonical events only" fit: they are one contract, not two (D-3 below). - It builds on the produce side of ADR 2026-10-01-2, D-6: the read API stays a pull over the committed ledger, and this record names the one thing that writes that ledger.
- It keeps ADR 2026-02-09-3 (event-log merge semantics) unchanged. The writer changes, but the log keeps its merge and reduction rules.
- It keeps ADR 2026-10-06-1 unchanged. The hosted surfaces that record fenced were removed from open-core by #6164 (PR #6206); their code is preserved in
spec-kitty/event-router.
Reader: a maintainer changing how the CLI or the runtime records what happened to a Mission or a work package, or adding a side effect that reacts to it.
Context and Problem Statement
The operator's decision, verbatim:
"The idea is to have a SINGLE canonical event writer module inside of the CLI. This writes `spec-kitty-events' published contract compatible events only. The event schema MUST contain a 'schemaVersion' field, so consumers know which events they are receiving, and async backwards compatibility is facilitated. Consumers can still choose whether to support multiple versions, or a single version. The producer ( usually ) produces only the latest version. Cfr. https://martinfowler.com/eaaDev/EventSourcing.html. Our runtime ( FSM ) core module sends these events as missions transition through their lifecycle. The internal 'lifecycle events' are to be removed and replaced by canonical events ONLY. We will use a ports-and-adaptors approach for the event creations: the core mission run loop needn't know about the structure of the event writer or pub-ledger-sub approach. It only needs to know to call the eventbridge ssot entrypoints when transitioning WPs or advancing mission state. Allowing it to be pure domain code, and having the git / filesystem operations handled by internal consumers of the events. This effectively makes our core system event-native by design."
The decisions of 2026-10-13 stand: runtime transitions create events; events flow pub, ledger, sub; producers and consumers (the consumers live in spec-kitty/event-router) share only spec-kitty-events contracts; ledger rows get a versioned row schema in spec-kitty-events.
Current state (origin/main, 566b20b9)
This survey predates #6164 (PR #6206), which removed the hosted surfaces from open-core: status/zeitgeist_bridge.py, the hidden hosted CLI entries and the drain gates in status/adapters.py are gone. The citations below describe the tree at 566b20b9.
Many writers, several row shapes. Counting each module that builds an event-like record and appends it to a log, there are twelve producers writing seven files:
| # | Producer | File | Row shape and version marker |
|---|---|---|---|
| 1 | Lane transitions: status/transition_pipeline.py::prepare_transition (:245) builds a StatusEvent, appended by status/emit.py::_persist_prepared (:831) or BookkeepingTransaction.append_events (coordination/transaction.py:985) |
status.events.jsonl |
StatusEvent.to_dict (status/models.py:470): flat row, no version |
| 2 | Inner-state annotations: status/emit.py::emit_inner_state_changed (:1212) |
status.events.jsonl |
InnerStateChanged.to_dict (status/models.py:793), no version |
| 3 | Internal lifecycle events: status/lifecycle_events.py::persist_lifecycle_event_local (:536), envelope from _build_envelope (:309) |
.kittify/canonical-events.jsonl and status.events.jsonl (:10-18, :125-126) |
local envelope, schema_version "5.0.0" (:317), marked canonical-producer-exempt: #1198 (:308, :319) |
| 4 | Decision points: decisions/emit.py::_append_raw_event (:117) |
status.events.jsonl (:65) |
DECISIONPOINT_SCHEMA_VERSION (:185) |
| 5 | Retrospective lifecycle: retrospective/lifecycle_events.py (:320-331) |
status.events.jsonl (:55) |
integer schema_version = 1 (:146) |
| 6 | Retrospective events, superseded entry: retrospective/events.py (:208-226) |
status.events.jsonl |
own envelope |
| 7 | Backfill migration: migration/backfill_runtime_state.py (:1200, :1440) |
status.events.jsonl |
synthesized StatusEvent rows |
| 8 | Run journal: runtime/next/_internal_runtime/engine.py::_append_event (:107-123) |
.kittify/runtime/runs/<run>/run.events.jsonl |
{event_type, timestamp, payload}, no event_id, no version, canonical-producer-exempt: #1248 (:109) |
| 9 | Decision input log: events/decision_log.py::DecisionGitLog (:78, append at :252) |
decisions.events.jsonl |
sanitized mission_next payloads |
| 10 | Mission v1 events: mission_v1/events.py::_write_event (:60-66) |
mission-events.jsonl (:23) |
own dict |
| 11 | Glossary: glossary/events.py::_local_append_event (:162-173) |
.kittify/events/glossary/<mission_id>.events.jsonl |
own dict (#1200 markers) |
| 12 | Op records: invocation/writer.py (:194-205), invocation/lifecycle.py::append_lifecycle_record (:57) |
kitty-ops/<op>.jsonl, kitty-ops/lifecycle.jsonl |
Op record schema v2 |
Rows 1 to 7 share status.events.jsonl and two low-level append primitives in status/store.py (append_raw_rows_atomic :369, append_event_stream_atomic_verified :496). The file stays readable only because a hand-kept registry tells the lane reader which event_type rows to skip and keep (AUTHORITATIVE_NON_LANE_EVENT_TYPES, status/lifecycle_events.py:166). A thirteenth shape exists only on the way out: build_saas_lifecycle_queue_event (:421) rewrites a lifecycle row into a 3.0.0 envelope (:462) for the hosted relay.
There are two internal lifecycle-event writers, with many importers. git grep -l lifecycle_events origin/main -- src lists 23 files (status/lifecycle_events.py itself and 22 others), and they cover two modules:
status/lifecycle_events.py(producer 3) has 14 direct module importers, among themmigration/mission_state.py,status/doctor.py,status/views.py,status/zeitgeist_bridge.pyand the upgrade migrationm_3_2_9_migrate_lifecycle_envelope.py. More callers reach it through thespecify_cli.statusre-exports, for examplecore/mission_creation_events.py:76.MissionReopenedandFollowUpRecordedstay local-only because the hosted strict path would reject them (:85-106).retrospective/lifecycle_events.py(producer 5) is imported byruntime/next/runtime_bridge_retrospective.py:227,cli/commands/agent_retrospect.py:36,post_merge/retrospective_terminus.py:128andretrospective/events.py:222.
Where transitions are triggered, and where side effects run inline.
- The runtime run loop (
runtime/next/) reads lane state (decision.py,discovery.py) but does not write it. Lane writes inside composed steps go throughemit_status_transition(runtime_bridge_composition.py:93-95), and production callers use the transactional dooremit_status_transition_transactional(coordination/status_transition.py:1598): CLI commands (agent/tasks_move_task_executor.py,agent/tasks.py),orchestrator_api/wp_lifecycle.py:846, consolidation (done_bookkeeping.py, both attestations) and theMissionStatusfacade (status/aggregate.py:168). - The runtime already has the shape of a port:
RuntimeEventEmitteris aProtocolover canonicalmission_nextpayloads (runtime/next/_internal_runtime/events.py:76), with a reject-on-conflict factory registry (:240) andruntime_emitter_for_mission(:288). But the engine also writes its own journal and snapshot directly (engine.py:107,:132), and the bridge wraps the emitter inDecisionGitLog(runtime_bridge.py:497-512), which callssafe_commitinline on every answered decision and swallows a failed commit as a warning (events/decision_log.py:85-87,:269-276). - The validation step is already pure:
prepare_transitionhas "zero writes, zero locks, zero git, zero fan-out" (status/transition_pipeline.py:27-35). Its git-derivedlane_headstamp is injected as a probe (status/emit.py:996-1006). - The shells around it do the git and filesystem work in line.
BookkeepingTransactionappends to the worktree log, commits throughsafe_commit(coordination/transaction.py:1225-1266), cuts the appended rows on failure (_cut_event_log, :1350) and runs deferred outbound effects only after the commit (:952-962, :1431). The coordination fallback commits status artifacts itself (coordination/status_transition.py:362-395,:487). Mission creation commits its creation events to the coordination branch (core/mission_creation_events.py:163). - Outbound notification is a third concern mixed into the same shells: the flat shell fans out after releasing its lock (
status/emit.py:1026-1035), the lifecycle writer fans out after its append (status/lifecycle_events.py:646), and the seamstatus/adapters.py(fire_saas_fanout:265,fire_lifecycle_saas_fanout:382) still gates on hosted drain (:295, :393) until ADR 2026-10-13-2 D-2 lands.
What "schemaVersion" means against the published contract. spec-kitty-events (origin/main 5599380, package 10.4.6; the CLI pins >=10.4.4,<11, pyproject.toml:80) already has:
- An envelope version:
Event.schema_version, snake_case on the wire, default and only accepted value"3.0.0"(models.py:141;strict.py:144,:629;contracts/versioning-and-compatibility.md). The contract says producers keep emitting3.0.0whatever the package major. - Per-family payload versions for five families:
CONNECTOR_SCHEMA_VERSION2.8.0,DECISIONPOINT_SCHEMA_VERSION3.0.0,AUDIT_SCHEMA_VERSION2.5.0,PROFILE_INVOCATION_SCHEMA_VERSION3.1.0,RETROSPECTIVE_SCHEMA_VERSION4.1.0.
So the requirement is met at the envelope level for events that leave the machine. It is not met where the operator needs it:
- The envelope version is pinned to one value, so it tells a consumer nothing about the payload shape. The families a Mission run produces most (
statuslane transitions,project_lifecycle,mission_next) have no payload version at all. - The ledger rows carry no version (rows 1, 2, 8), a local one that contradicts the wire (row 3's
"5.0.0"), or a different type (row 5's integer). The row schema of ADR 2026-10-13-2 D-1 does not exist yet.
Decision Drivers
- Single canonical authority (charter): one writer, one contract, one place to change.
- Pure domain core (charter: DDD and tiered rigour): the run loop and the lane state machine decide; they do not touch git, files or transports.
- Consumers choose their versions: every persisted or published event says which shape it is.
- No lost history: the committed logs stay readable and are never rewritten (ADR 2026-10-13-2 D-3; the archive freeze of ADR 2026-10-11-1).
- The consolidation gates keep their evidence (status events with
policy_metadata.lane_head,review_resultandforce).
Considered Options
- One canonical event writer, the EventBridge, behind a domain port; side effects move into internal consumers (chosen, operator ruling 2026-10-14).
- Keep the twelve producers and enforce the contract with a producer gate on persisted lines only (ADR 2026-10-13-2's Confirmation).
- Keep writing internal rows and canonicalize only at the outbound edge, as
build_saas_lifecycle_queue_eventdoes today.
Decision Outcome
Chosen option: "One canonical event writer behind a domain port", because it is the only option that gives the ledger one authority and lets the core stay domain code. Option 2 checks shapes but leaves twelve writers and inline git. Option 3 keeps the ledger non-canonical, so every consumer still needs a CLI-side translation.
D-1: one canonical event writer, the EventBridge
The CLI has exactly one module that creates and persists events: the EventBridge. It is the single source of truth for event creation. Every event-like record the CLI persists, today's producers 1 to 12 included, is created through it. No other module appends to an event log. The low-level append primitives become private to it.
D-2: the domain depends on a port, not on the writer
The run loop and the state machine see only an EventBridge port: a small interface owned by the domain layer, in the direction the layer rules allow (kernel <- charter <- {runtime, mission_runtime} <- specify_cli). The port has SSOT entry points for the facts the domain decides:
- a work package transition (the event
prepare_transitionalready builds); - a Mission state advance (run started, step issued, step completed, decision requested and answered, run completed).
The domain calls an entry point and knows nothing else: not the row format, not the ledger file, not the pub, ledger, sub flow, not git. The writer is an adapter in specify_cli, bound at the composition root. RuntimeEventEmitter and its factory registry are the existing seed of this port and are grown into it, not duplicated. Facts produced outside the run loop (decision points, retrospective, glossary, Op records) use family entry points on the same bridge.
D-3: contract events only, latest version, every event versioned
- The bridge writes only events that conform to a contract published by
spec-kitty-events: a typed payload from the package, carried in the ledger row schema of ADR 2026-10-13-2 D-1. The full 3.0.0 envelope remains the wire contract for events that leave the machine. - It produces only the latest version of each event type. It does not write older shapes and does not dual-write.
- Every event carries its schema version, so a consumer knows what it is reading. On the wire the field is
schema_version, the published contract's name, and it stays that name unlessspec-kitty-eventschanges it. The operator's "schemaVersion" names this requirement, not a new field name. Two versions are needed and both must be present:- the envelope or row version (D-1 of ADR 2026-10-13-2);
- a payload version per event type, for every family the bridge writes.
status,project_lifecycleandmission_nexthave none today.spec-kitty-eventsadds them; their field placement and name are that package's call.
- Consumers decide whether to support one version or several. The producer makes no promise to emit an old version for a consumer that has not upgraded. Compatibility is carried by the version marker and the package's versioning rules (
contracts/versioning-and-compatibility.md).
D-3a: the ledger row is the canonical event in its persisted form, so there is no second row schema
ADR 2026-10-13-2 D-1 asks for a versioned schema for each ledger row kind, published in spec-kitty-events. The operator now says the writer writes canonical spec-kitty-events events only. Both texts hold under one reading, and this record adopts it:
- The row schema D-1 asks for is the persisted form of a canonical event, and
spec-kitty-eventspublishes it. A ledger row is one canonical event: the package's row envelope (event_id,event_type,schema_version, timestamp and ordering fields, aggregate identity) around the package's typed payload for thatevent_type. "One schema per row kind" therefore means one published schema per event type, owned byspec-kitty-events, never a CLI-owned row shape beside it. - The row's
schema_versionis D-1's row-level version marker. No separaterow_versionfield is added. The payload version of D-3 travels with the payload. - The persisted row is not the full 3.0.0 wire envelope. ADR 2026-10-13-2 rejected writing 3.0.0 envelopes into the ledger (its "canonical 3.0.0 envelopes in the ledger" option), and that rejection stands. The wire envelope adds required transport and causality fields (
build_id,node_id,lamport_clock,correlation_id,project_uuid;models.py:110-140) that the local rows do not carry today. Turning a row into a wire envelope is a mechanical mapping thatspec-kitty-eventsdefines and ships, so the event is the same canonical event on disk and on the wire. The CLI-side canonicalizer goes away (D-4).
Open question, with a default. Should spec-kitty-events later make the row envelope and the wire envelope one schema? Default: no. Keep two envelopes over one set of typed payloads, both published by spec-kitty-events, until a consumer needs the transport fields on disk.
D-4: the internal lifecycle events are retired
Both internal lifecycle-event modules go. status/lifecycle_events.py is removed with its "5.0.0" local envelope, its LIFECYCLE_EVENT_TYPES and LOCAL_ONLY_LIFECYCLE_EVENT_TYPES sets and the CLI-side envelope rewrite for the hosted relay. retrospective/lifecycle_events.py is removed with its integer-versioned rows; the retrospective facts are written as the canonical spec-kitty-events retrospective events (RETROSPECTIVE_SCHEMA_VERSION). Each lifecycle fact (project initialized, Mission created, specify, plan and tasks started and completed, work package created, reviewer self-approval, Mission reopened, follow-up recorded) is written as the canonical spec-kitty-events event for it. A fact with no canonical event yet gets one in spec-kitty-events first (a minor release under its rules) before the CLI writes it. No new internal event type is added in the meantime.
D-5: git and filesystem effects are internal consumers
Status commits, artifact writes, coordination commits, worktree work, the lane-state mirror, the derived projection refresh and outbound publication stop being inline steps of the transition shells. Each becomes an internal consumer (a subscriber in the CLI) of the events the bridge writes. Two kinds:
- Synchronous consumers run inside the bridge's unit of work, after the append and before it returns. The status commit is one: today's guarantee holds that an event is announced only after its commit succeeded (
coordination/status_transition.py:26-29). - Asynchronous consumers run after the unit of work. They include the derived projection, the outbound publication adapter and the in-process seam of
status/adapters.py. Their failure never undoes or blocks the event.
External publication is not one of these. The bridge's external publication adapter is spec-kitty/event-router's concern; it reads the ledger as ADR 2026-10-13-2 D-2 describes. The Zeitgeist handlers are no longer in this repository (#6164).
This follows Martin Fowler's Event Sourcing: every change of state is captured as an event, and the event log, not the side effects, is the record. Lane state already works this way: status.events.jsonl is its sole authority. This decision makes the rest of the core work the same way.
Consequences
Positive
- One writer and one contract. A consumer, in this repository or in event-router, needs only
spec-kitty-events. - The run loop and the state machine become testable without git or a filesystem; a test port records the calls.
- The
canonical-producer-exemptmarkers (#1198, #1248, #1200), the lifecycle envelope, the hosted canonicalizer and the non-lane registry's lifecycle half go away. - Adding a side effect means adding a consumer, not editing a transition shell.
Negative
- A large migration across the 22 files that use the two lifecycle modules, three transition shells and the consolidation code. It cannot ship as one change.
spec-kitty-eventstakes on payload versions forstatus,project_lifecycleandmission_next, and the lifecycle events that have no canonical form today. Each is a cross-repository release before the CLI can switch.- Moving side effects out of the shells changes their failure handling and needs new tests for every consumer.
Neutral
- The Mission Status Read API (ADR 2026-10-01-2) is unchanged; it reads the committed ledger.
- The hosted surfaces (
hosted.drain, the hidden hosted CLI entries) were removed by #6164 and are not reintroduced.
What must stay true during the migration
- One contract, two envelopes. Event-router and other consumers read ledger rows against the
spec-kitty-eventsrow-envelope schema plus the payload schema for each event type (D-3a). They do not read against a CLI schema or the 3.0.0 wire envelope. A consumer that also takes events from the wire uses the package's row-to-wire mapping. - Existing logs. Committed
status.events.jsonl,decisions.events.jsonland.kittify/canonical-events.jsonlrows are never rewritten. Readers keep accepting every historical shape: unversioned rows read as version 1 (ADR 2026-10-13-2 D-1), and"5.0.0"lifecycle rows stay readable as legacy. Only new rows use the canonical shape, so a Mission's log can hold both. The merge driver installed for these files keeps working on mixed logs. The archive freeze compares reducer outcomes, not bytes, so it still holds. - Reducer duality. The bridge changes who writes, not how logs are reduced. Two reducers still ship: the Lamport wrapper
materialize_snapshot(status/reducer.py:373-377, ADR 2026-02-09-3) and the wall-clockreduce_parsed(status/reducer.py:17,:176-181). The consolidation gates keep reading through the Lamport wrapper (consolidation/reconciliation.py::build_approved_wp_set, :1442). #4941 stays open, and nothing here resolves it. - Consolidation gates. The approval-stamp bound, per-WP attribution, the reconciliation claim, the coordination status-surface guard and the origin-freshness gate read status events. They read
policy_metadata.lane_head,review_result,force,evidenceand actor provenance. The canonical status event carries each of these, so the gates' verdicts do not change. If a field changes form, the gate migrates in the same change. Thelane_headprobe stays a port the adapter fills; the domain never runs git to stamp it. - Ordering and atomicity. The event is appended before any consumer runs. A synchronous consumer that fails rolls the append back and the call raises, as
BookkeepingTransactiondoes today; no consumer runs for an event that was cut. An asynchronous consumer that fails leaves the event recorded and its own work pending. Because consumers are idempotent byevent_id, it can be rerun.DecisionGitLog's swallowed commit failure becomes a recorded, rerunnable consumer failure. - Hosted stays out. Nothing in this decision reintroduces, extends or points at Zeitgeist, auth or the hosted backend, which #6164 removed from open-core.
Confirmation
- An architectural gate finds exactly one module that appends to any event log (
*.events.jsonl,canonical-events.jsonl,run.events.jsonl,mission-events.jsonl,kitty-ops/*.jsonl), with an empty allowlist. - The layer rules show
runtimeand the transition pipeline import the EventBridge port and nothing that runs git or writes event files. - Every row the bridge writes validates against its
spec-kitty-eventsrow schema and carries both version markers; nocanonical-producer-exemptmarker remains. status/lifecycle_events.pyandretrospective/lifecycle_events.pyare gone, andgit grep lifecycle_events -- srcfinds no importer.- The consolidation suites (
tests/consolidation/) pass unchanged over logs that mix legacy and canonical rows. - A failing synchronous consumer leaves the log byte-identical; a failing asynchronous consumer leaves the event recorded, and a rerun completes it.
Open questions (each with a default)
- Where the port and the bridge live. Default: the port in
src/runtime/next toRuntimeEventEmitter; the bridge adapter insrc/specify_cli/events/. - Payload version placement and name. Default: a version field on each typed payload, named and placed by
spec-kitty-events, snake_case likeschema_version. The row's ownschema_versionis the row-envelope version (D-3a). - Do the file layouts change? Default: no. The bridge writes the same files (
status.events.jsonlper Mission,.kittify/canonical-events.jsonlfor project facts, the run journal,decisions.events.jsonl); it does not merge them into one ledger file. - Is the Mission state a projection of events? Default: the run snapshot (
engine.py:132) andstatus.jsonbecome projections rebuilt by consumers from the ledger;meta.jsonstays authored configuration. - Consumer failure reporting. Default: a pending asynchronous consumer is reported by
spec-kitty doctorand rerun by an explicit command, never silently retried in a loop. - Order of migration. Default: the work package transition first (one bridge entry point behind the two existing doors), then the run loop, then the lifecycle facts, then the remaining families.
Pros and Cons of the Options
A producer gate over twelve writers
Pros: smallest change; ADR 2026-10-13-2 already plans the gate. Cons: compliance without one authority. The side effects stay inline, so the domain still runs git, and each new writer has to be caught by the gate after the fact.
Canonicalize only at the outbound edge
Pros: the ledger is untouched. Cons: the ledger stays CLI-internal, which contradicts "producers and consumers share only the contracts". It keeps today's translation step, the one that made MissionReopened and FollowUpRecorded local-only.
More Information
- Martin Fowler, Event Sourcing.
- ADR 2026-10-13-2: row contract, publication seam, retention.
- ADR 2026-10-01-2, D-6: the read side.
- ADR 2026-02-09-3: event-log merge semantics.
- ADR 2026-10-06-1: hosted surfaces frozen.
spec-kitty-events:contracts/versioning-and-compatibility.md,models.py(Event.schema_version),strict.py.
Amendments
- 2026-10-22, factual (#6614, #6607; Mission
event-write-hygiene-01M5HS55): the write doors and seam names this ADR cites have changed. D-1 to D-5 are unchanged.- Doors deleted. The flat
store.append_event,append_event_verified,append_primary_checkout_event_verified,coordination.status_service.append_event_log,kernel.atomic.notify_file_writtenandinvocations_cmd.append_to_indexhad no production caller and are gone. The "two low-level append primitives" paragraph below the writer table still describes the two primitives that remain in use (its line numbers have shifted). This is a step toward ADR 2026-10-22-1's Confirmation ("a Python writes gate with no appender left"); no append path was added. - Hosted builder deleted.
build_saas_lifecycle_queue_event, which the context section calls a thirteenth row shape and Option 3 cites as today's outbound-edge canonicalization, was dead code and is deleted, with_canonical_lifecycle_payload_for_saas,_validate_lifecycle_payloadand the publicrepo_root_for_lifecycle_log. - Seam renamed.
fire_saas_fanoutandfire_lifecycle_saas_fanoutare nowfire_status_fanoutandfire_lifecycle_fanout, with no alias. The claim that the seam "gates on hosted drain" is stale: the drain posture was removed in #6164, and the seam registers no default handler, so producing an event never depends on a drain. - The gap table in Event ledger writers and readers records G-11, G-16 and G-18 as closed.
- Doors deleted. The flat
- 2026-10-22, by ADR 2026-10-22-1 (operator ruling 2026-10-22): the canonical writer is the Java write service.
- D-1. The one module that persists events is the Java write service's
event-logmodule: the command side of the Mission Write API (#6117), one resident service per repository, keyed on the git common dir (derived; ADR 2026-10-22-1 D-2). It is no longer a module inside the CLI. "No other module appends to an event log" still holds, now across both languages. - D-2. The domain still depends only on the EventBridge port, which stays in Python. The adapter in
specify_cliis a client of the write service, not the writer. The CLI decides when and what to write; the service decides how and where. - D-5. The synchronous consumers move inside the service's unit of work: the append, the status commit and the cut on failure. The asynchronous consumers, including the produce seam
status/adapters.py, stay in the CLI or read the committed ledger by cursor. - Confirmation. The single-writer proof becomes two gates: a Python writes gate with no appender left, and an ArchUnit rule that only
event-logopens a ledger for writing. Rows are checked byte for byte against theledger_appendandstatus_appendfixtures. - "What must stay true", the consolidation gates bullet. "The
lane_headprobe stays a port the adapter fills" no longer holds. The probe moves into the write service'sGitPort, and the CLI adapter no longer fills it. The gates' evidence fields are unchanged. - Unchanged. D-3, D-3a and D-4.
- D-1. The one module that persists events is the Java write service's
- 2026-10-22, factual (#4941, #6604): the "Reducer duality" bullet under "What must stay true during the migration" is wrong about the code.
- Both
status.reducer.reduceandmaterialize_snapshotreach the wall-clockspec_kitty_events.diary.reduce_parsed. That is one reducer with two parsers; neither path is a Lamport wrapper. - #4941's library fix (the approved-departure lift,
spec-kitty-events10.5.1) has shipped. What remains is narrower: a non-approval transition minted on a clock behind the event it follows still sorts before it. - The consolidation gates read through that same reducer.
- The Technical Story's "keeps ADR 2026-02-09-3 unchanged" now holds only for its merge rules. Its ordering is amended to wall clock for 4.0.0 (ADR 2026-02-09-3, Amendment 2026-10-22), and Lamport-primary ordering is a post-GA direction (#6609).
- Both