SL1-RR-1 — Event-Sourced Write Path (decide/apply split, log-as-id-authority)¶
Date: 2026-06-01
Status: Approved (brainstorm) — ready for /flow:plan
Plugin: anvil
Tracks: tech-debt-backlog SL1-RR-1; fakoli-style principle P4 open_work
Supersedes the tactical fix described in the original SL1-RR-1 review (Option A / Option C). This spec adopts the deeper root-cause fix.
1. Goal¶
Make a "poison canonical line" — a events.jsonl line that aborts a full
replay — structurally impossible, by restructuring the write path around
event-sourcing's command/event separation. As a consequence, also close the
inverse "post-COMMIT audit gap" on the live path and retire the overloaded
apply_event method and the replay skip-list.
The replay-equivalence guarantee proven by test_replay_equivalence
(fakoli-style P4) must remain green throughout and at the end.
2. Context & root cause¶
apply_event currently behaves two different ways depending on whether
event.id == PENDING_EVENT_ID:
- PENDING (live path): assign id from
SELECT MAX(id)inside theBEGIN IMMEDIATElock, mutate, COMMIT, then append JSONL. A crash between COMMIT and the JSONL write leaves the projection ahead of the log; the event is lost on the next full replay (the documented audit gap atstate/sqlite.py:320-337). - Non-PENDING (replay/legacy path): append JSONL first, then mutate.
If the mutation is rejected, the canonical line is already persisted;
replay_from_emptyre-applies it and re-fails, aborting the entire replay (SL1-RR-1).
Both gaps share one root cause: the event id is sourced from the SQLite
projection (MAX(id)), which forces id assignment to happen inside the DB
transaction, which forces an impossible choice between "log first, id unknown"
and "id known, projection committed first."
One layer deeper: the engine validates at apply time and can reject an event that is (or is about to be) in the log. In an event-sourced system, validation belongs to the command, before anything is logged. Once an event is in the log it is a fact that must replay infallibly. The "poison line" can only exist because today's design violates that separation.
Reachability (why this is latent today)¶
Every live caller constructs events with PENDING_EVENT_ID (verified across
cli/, planning/, mcp_server.py — zero non-PENDING live ids). The
JSONL-first non-PENDING branch has no production callers; its primary
consumer is the test suite, whose helpers (_make_event defaulting
event_id="E000003", _event requiring an explicit event_id) feed hardcoded
sequential ids. So SL1-RR-1 is a latent correctness landmine, armed for the
first future caller that supplies real ids (an importer/migration), and
exercised today only by tests.
3. Architecture¶
Four structural moves:
- Decide/apply split per action. Each of the ~17
_handle_<action>handlers splits into: _check_<action>(conn, payload, event) -> None— reads current state, raisesEventRejectedon any illegal transition / integrity violation. Validation only; performs no writes._write_<action>(conn, payload, event) -> None— executes the mutation, infallible given that the matching_check_*passed.
The dispatch table maps action -> ActionSpec(payload_model, check, write).
- Two entry points, selected by intent (not by a sentinel id):
append(draft) -> Event— the only production write. Validates, assigns id, logs, applies.replay_from_empty(path)— rebuild. Applies via_write_*only: no validation, no logging, no skip-list. Infallible for any complete, ordered log.
The non-PENDING-with-logging branch is deleted — the architecture no longer has that concept.
-
The log is the id authority. An in-memory monotonic counter, seeded from the log's max id on open (not from SQLite
MAX(id)). A file lock (flockonevents.jsonl) serializes the whole append critical section so concurrent CLI + MCP appends cannot collide. This replaces theBEGIN IMMEDIATE+SELECT MAX(id)race fix from PR #41 Critic-3. -
The canonical log holds only real events. Rejections and idempotent no-op warnings move out of
events.jsonlinto a siblingaudit.jsonl, which is never replayed. Consequence:replay_from_emptyno longer skips anything — theerror.transaction_aborted/warn.idempotent_no_opaction-name skip logic (state/sqlite.py:417-435) is deleted. Every line inevents.jsonlis a fact.
This also fixes the skew direction: log-first means a crash can only leave the log ahead of the projection (self-healing via forward catch-up on open), never the projection ahead of the log (today's unrecoverable PENDING gap).
4. Data model & interfaces¶
Types — retire the PENDING_EVENT_ID sentinel¶
class EventDraft(BaseModel): # an intended mutation; id not yet assigned
timestamp: datetime
actor: str
action: str
target_kind: str
target_id: str
payload_json: dict[str, Any]
# NO id field
class Event(EventDraft): # a fact: a draft assigned an id and applied
id: str # "E000001"...
You append(EventDraft) and receive an Event. The type system prevents
handing an unassigned draft to replay, or a real Event to append. The
PENDING_EVENT_ID magic string is removed.
Backend Protocol surface¶
def append(self, draft: EventDraft) -> Event | None: ...
# validate -> assign id from log -> append events.jsonl -> apply (infallible)
# Returns the materialized Event on success.
# Returns None for a legal idempotent no-op (nothing logged; audited).
# Raises EventRejected (illegal transition / bad payload) — nothing written to events.jsonl.
# Raises TransactionAborted (infra failure after log append) — rare, loud.
# Raises StateLocked (flock contention timeout).
def replay_from_empty(self, events_path: str) -> None: ... # signature unchanged
# rebuild via _write_* only; no validation, no logging, no skip-list
# REMOVED from the protocol:
# apply_event(event) -> replaced by append(draft)
# next_event_id() -> id authority is internal to append's locked section
All events, including bootstrap (project.created, state.initialized),
flow through the uniform append path. There is no bootstrap-aware branch;
ordering prerequisites (e.g. "feature needs a project") are enforced by each
action's own _check_*. The only true prerequisite is that initialize()
(schema creation) runs before the first append, which is already a separate
step.
Dispatch table¶
class ActionSpec(NamedTuple):
payload_model: type[BaseModel]
check: Callable[[Connection, BaseModel, Event], None] # raises EventRejected (illegal) or IdempotentNoOp (already satisfied)
write: Callable[[Connection, BaseModel, Event], None] # infallible given check passed
ACTION_DISPATCH: dict[str, ActionSpec]
A _check_* has three outcomes: return normally (proceed to log + write),
raise EventRejected (illegal transition / bad payload — see error types), or
raise IdempotentNoOp (legal but already-satisfied, e.g. releasing an
already-released claim). append catches IdempotentNoOp, writes an
idempotent_no_op line to audit.jsonl, and returns None without logging or
mutating.
Error types¶
EventRejected(new) — illegal transition / bad payload onappend. Normal, expected control path. Nothing written toevents.jsonl.IdempotentNoOp(new) — legal but already-satisfied request (e.g. releasing an already-released claim). Caught insideappend; not propagated to the caller as an error —appendreturnsNone.TransactionAborted(narrowed) — now means only an unexpected infrastructure failure (disk, SQLite operational, a bug in a_write_*). Should be vanishingly rare; never a normal-flow control path.StateLocked(unchanged) —flockcontention beyond the timeout.
audit.jsonl line shape (sibling of events.jsonl, never replayed)¶
{"ts":"...","kind":"rejection","actor":"...","attempted_action":"task.applied","target_id":"T003","reason":"evidence.submitted payload requires non-empty 'commands_run'"}
{"ts":"...","kind":"idempotent_no_op","action":"claim.released","target_id":"T001","reason":"..."}
{"ts":"...","kind":"write_failed_after_log","event_id":"E000042","action":"task.applied","reason":"..."}
No id field — these are not events and never collide with the E###### space.
Lock interface¶
A _append_lock() context manager wrapping fcntl.flock(events_fd, LOCK_EX)
with a timeout mirroring busy_timeout=5000 (contention beyond it raises
StateLocked), with a single in-process threading.Lock nested inside for
same-process MCP + thread safety.
Durability mode¶
durability: relaxed # DEFAULT — laptop: synchronous=NORMAL, buffered log, no per-event fsync
| strict # CI/shared/server: synchronous=FULL + fsync(log) before COMMIT
5. Data flow¶
Write (the only production path)¶
append(draft):
with _append_lock(): # flock(events.jsonl) + in-proc lock
_check_<action>(conn, payload, draft) # raises EventRejected -> audit.jsonl, re-raise
id = _next_seq() # log-owned counter; increments at log-append time
event = Event(id=id, **draft)
append_line(events.jsonl, event) # (1) log write — source of truth (log-first)
BEGIN IMMEDIATE
_write_<action>(conn, payload, event) # (2) infallible projection mutation
_insert_event_row(conn, event)
COMMIT
return event
If validation fails, we raise before (1) — nothing touches events.jsonl.
The in-memory counter increments at log-append time (not commit) so that a
re-run in the same process after a write failure gets the next id and the
failed event remains accounted-for in the log.
Rejection / no-op (never in the canonical log)¶
_check_* raises EventRejected -> append_line(audit.jsonl, {kind:"rejection", ...}); re-raise
_check_* raises IdempotentNoOp -> append_line(audit.jsonl, {kind:"idempotent_no_op", ...}); return None (no event)
Rebuild¶
replay_from_empty(path):
close(); delete state.db (+ -wal/-shm); initialize()
for line in path: # every line is a real Event — no skip-list
event = Event.model_validate(line) # torn trailing line tolerated; interior corruption -> raise
BEGIN IMMEDIATE; _write_<action>(conn, payload, event); _insert_event_row; COMMIT
_next_seq = max(event.id) # counter re-synced post-rebuild
Open + self-heal (skew recovery)¶
open():
initialize() # ensure schema
log_max = scan_tail(events.jsonl) # LOG is the id authority; reads last line only
table_max = SELECT MAX(id) FROM events
if table_max < log_max: # crash left log ahead of projection
forward_catch_up: _write_* the tail (table_max+1 .. log_max) # self-heal
_next_seq = log_max # never reassigns an orphaned id
scan_tail reads only the log's final line (seek-to-EOF, scan back to last
newline — O(line), not O(file)). Forward catch-up reuses _write_*, sharing
the replay code path; there is no third apply implementation.
6. Performance & durability¶
- Not a background process. No daemon, poller, or idle loop — work happens only while a CLI command or MCP call executes, then the process exits. Zero cycles between developer actions.
- The hot path gets cheaper: the per-append
SELECT MAX(id)is removed (in-memory counter);flockis advisory and contention-only (no spin/poll, ~0 cost uncontended); no per-event fsync inrelaxed. - Correctness does not depend on fsync. Deterministic replay and
log-never-behind-projection are guaranteed by ordering (log write precedes
COMMIT) + the log-authority counter + forward catch-up. Worst case on hard
power-loss under
relaxed: the last few un-synced events drop from log and projection together; the user repeats the last action. - Reads stay fully concurrent via WAL — status/list never block on a writer. Writes serialize through one fast lock.
- Scaling boundary (explicit): single-machine, single-project coordination ledger with a low write rate. Serialized writes buy the deterministic total order P4's replay proof depends on. If write throughput ever becomes a real bottleneck on a bigger machine, the lever is batching multiple appends under one lock acquisition — a measure-then-add future optimization, out of scope here. Designing for distributed/high-write now would trade away the determinism that is the whole point.
7. Error handling¶
| Failure mode | Where | Surfaces as | Log effect |
|---|---|---|---|
| Validation failure | _check_* raises EventRejected |
Clear caller error; normal path | No events.jsonl line; rejection in audit.jsonl |
| Idempotent no-op | engine detects | Returns normally | No event; idempotent_no_op in audit.jsonl |
| Write failure after log append | _write_*/SQLite raises despite passing check |
TransactionAborted — loud infra alarm |
Log line stays (append-only); SQLite rolled back; write_failed_after_log audited; healed by forward catch-up on next open |
| Lock contention | flock timeout |
StateLocked — "retry" |
None |
| Replay hits a write failure | replay_from_empty |
Hard raise — integrity alarm | n/a |
| Torn final log line | crash mid-append (relaxed) |
Tolerated silently | Only trailing partial line ignored |
Three deliberate decisions:
- The log is strictly append-only — no truncation, even on write failure. A post-check write failure means a real bug or disk emergency; roll back SQLite, leave the log line, let forward catch-up re-apply on next open. The event is never lost and the failure is loud.
- Replay failures are loud — the skip-list is gone for good. Every log line is a validated fact; a replay/catch-up write failure is a genuine integrity alarm and raises. We never silently skip, because skipping diverges state and would quietly break the P4 replay-equivalence guarantee.
- Only the trailing log line may be torn; an interior malformed line is corruption. A crash can only damage the last append, and that event was never committed nor returned to a caller — so ignoring a partial final line is safe. A malformed interior line is real corruption → hard fail.
Severity inversion: today TransactionAborted is a normal outcome (bad
payload, illegal transition all funnel through it), which is why the poison
line and skip-list exist. Moving rejection to a pre-log validation gate makes
TransactionAborted rare-and-alarming, the canonical log failure-free, and
replay strict.
8. Testing¶
A. Test-suite migration¶
Migrate the ~5 helpers (_make_event in test_claims/test_reconciliation/
test_sqlite; _event in test_snapshot/fixtures/replay/regenerate.py) to
build an EventDraft (no id) and call append, returning the materialized
Event. Tests that hardcoded ids now assert against the returned id.
Direct Event(id=PENDING_EVENT_ID, …) constructions are updated individually.
Honors the project's no-mocking rule — real appends, real ids.
B. New behavioral tests¶
| Test | Asserts |
|---|---|
| Poison-line impossibility (SL1-RR-1 regression) | append of a draft whose _check_* fails → zero new canonical lines in events.jsonl, one rejection in audit.jsonl; subsequent replay_from_empty byte-equal to pre-append state |
| ID-reuse / self-heal | Inject log-ahead skew (log has E00000N, projection lacks it) → on open _next_seq seeds from log_max, forward catch-up applies the tail, a following append gets E00000(N+1) — orphaned id never reassigned |
| Concurrency (PR #41 Critic-3) | Two backends sharing one events.jsonl, interleaved appends → no id collision, no lost event; flock serializes |
| Append-only on write failure | Real failure injection (subclass overriding one _write_* to raise, or an FK violation) → log line remains, SQLite rolled back, write_failed_after_log audited, next open catch-up converges |
| Replay strictness | Corrupt interior line → replay raises (not skips); torn trailing line → tolerated |
| Decide/apply contract | Per action: _check_* rejects illegal state with no side effects; _write_* succeeds whenever its _check_* passed |
| Durability modes | relaxed (default): synchronous=NORMAL, no per-event fsync. strict: synchronous=FULL + fsync(log) before COMMIT (verified via a real fsync spy / pragma read, not a mock of business logic) |
C. P4 golden equivalence — update, don't weaken¶
Regenerate tests/fixtures/replay/sample-project/ to the new shape: canonical
events.jsonl contains only real events (the old error.transaction_aborted
line E000099 moves to audit.jsonl). test_replay_equivalence's byte-equal
serialize_state(normal) == serialize_state(replay) == golden assertion stays
green — it is the P4 proof and the embodiment fakoli-style P4 points at.
D. Performance guardrail (smoke, not a flaky perf gate)¶
A test with a large synthetic log asserting scan_tail reads bounded bytes
(last line only), not the whole file. No wall-clock perf gate in CI.
E. CI¶
Full suite runs on every anvil PR via .github/workflows/anvil.yml
(with the TERM=dumb / unset FORCE_COLOR guard already in place), invoked as
uv run --project bin --extra all-providers --with pytest pytest.
9. Acceptance criteria¶
apply_eventandnext_event_idare removed from the Backend Protocol and all implementations;append(EventDraft) -> Eventis the sole production write entry point.PENDING_EVENT_IDis removed.- Every action has a
_check_<action>and_write_<action>registered inACTION_DISPATCH;_write_*performs no validation. A_check_*signals one of three outcomes: proceed,EventRejected, orIdempotentNoOp(appendreturnsNonefor the last). - A failed
append(validation) writes zero lines toevents.jsonland exactly onerejectionline toaudit.jsonl. events.jsonlcontains only canonical events;replay_from_emptycontains no action-name skip-list and applies every line.- Event ids are assigned from an in-memory counter seeded from
log_maxon open; concurrent appends (CLI + MCP) never collide and never drop an event. - Forward catch-up converges a log-ahead-of-projection skew on open without reassigning the orphaned id.
relaxeddurability is the default;strictis opt-in and the only mode that fsyncs per event.- The poison-line regression test and the ID-reuse/self-heal test pass.
test_replay_equivalence(P4) remains green against the regenerated golden.- The full anvil suite passes in CI.
- fakoli-style P4
open_workis updated to mark SL1-RR-1 resolved and point at the new embodiments;tech-debt-backlog.mdSL1-RR-1 markedDONE. plugins/anvilversion bumped (minor — new write-path architecture) andregistry/regenerated.
10. Out of scope¶
- Batching multiple appends under one lock acquisition (future throughput optimization; measure first).
- An importer / migration tool that supplies externally-authored event ids
(the future caller this hardening anticipates). When built, it routes through
replay_from_empty/ a dedicated import path, notappend. - The "Option C" replay healer (skip canonical lines carrying an abort tombstone). Made unnecessary by this design — poison lines are now unrepresentable — and recorded here only to note it is deliberately dropped.
- Distributed / multi-machine / high-write operation.
11. References¶
tech-debt-backlog.md§ SL1-RR-1 (originating finding)- fakoli-style principle P4 (
data/principles.json) —open_work state/sqlite.py:apply_event(231-373),replay_from_empty(379-438),_apply_event_sqlite_only(2572-2599),_append_abort_event(2612-2649),_apply_mutation+ handlers (967+)- PR #41 Critic-3 (the
next_event_idrace this design's lock supersedes) test_replay_equivalence.py,test_snapshot.py,test_sqlite.py(suites to migrate)