5.9 KiB
M1.6 — JSONL event log writer
| Field | Value |
|---|---|
| Phase | M1 — Gated loop at L1 |
| Size | M — 1–3 days |
| Status | ✅ Done |
| Flags | — |
| Spec | inlined below |
| Blocks | M1.5 |
Goal
Write the authoritative record — the one artifact everything else is derived from, and the one that must survive a crash mid-run.
Files
| Action | Path |
|---|---|
| Create | crates/mem-store/src/event_log.rs — LogWriter, LogReader, RunStatus |
| Replace | crates/mem-store/src/lib.rs — replace pub mod placeholder {} with pub mod event_log; + re-exports |
| Create | tests/it_event_log.rs — integration tests (workspace root, 8 assertions) |
Dependencies
| Crate | Where | Already present? |
|---|---|---|
ulid or rusty_ulid |
crates/mem-store/Cargo.toml |
❌ add — for sortable run IDs |
tokio (fs feature) |
crates/mem-store/Cargo.toml |
✅ yes |
serde, serde_json |
crates/mem-store/Cargo.toml |
✅ yes |
Existing code to reuse
LoopEventfromgated_loop.rs(M1.5) — the events to serializeLevelfromdomain.rs— carried in every record- Pattern reference:
lessons_cmd.rshas a JSONL writer forEventtypes (events.jsonl). Same concept but different event schema and different storage location:lessons_cmd.rswrites~/.mem/events.jsonl(command execution events)- M1.6 writes
log/<project>/<query>/<run-id>.jsonl(gate decision events) - Do not unify them. They serve different purposes.
Output path convention
log/
poimen/
tool-failures/
01HXYZ....jsonl ← ULID, lexicographically sortable by time
architecture-decisions/
01HXYZ....jsonl
Facts (inlined — no spec read needed)
Path: log/<project>/<query-id>/<run-id>.jsonl. Append-only, one object per line.
Every record carries level.
{"type":"run","level":"L1","project":"poimen","query_id":"infra-root-causes","input_level":"chunk","model":"qwen2.5:3b-instruct","chunk_tokens":5000,"memory_budget":1024,"exit_gate":false,"ts":"..."}
{"type":"chunk","level":"L0","t":1,"source":"pi:...","span":[0,42],"sha256":"..."}
{"type":"gate","level":"L1","t":1,"update":false,"exit":false,"think":"...","latency_ms":812}
{"type":"evidence","level":"L0","t":7,"source":"pi:...","text":"...","sha256":"..."}
{"type":"memory","level":"L1","t":7,"text":"...","tokens":142,"parents":["<sha>"],"sha256":"..."}
{"type":"run_end","level":"L1","chunks_seen":412,"chunks_used":17,"final_memory_sha":"..."}
evidence appears only when the update gate opened. That is what makes
update-rate directly measurable from the log — gate records give the
denominator, evidence records the numerator.
This log is authoritative: the vault and pgvector are projections rebuilt from it. Two consequences — it is tracked in git, and it is never rewritten in place.
A run that crashes leaves a file with no run_end. That is a valid, readable
state meaning "incomplete", not corruption. Readers must handle it.
Steps
LogWriter::open(project, query_id, run_id)inmem-store, creating parents.append(event)serializes one line and flushes. An unflushed buffer loses the last turns of exactly the run you want to debug.run_idis a ULID — lexicographically sortable by creation time, so listing runs in order is a directory sort.LogReaderstreams events back, tolerating a truncated final line.replay_memory_at(t)reconstructsM_tfrom the events alone, proving the log is sufficient.stats()computes chunks seen/used and update-rate from a log file.- Never open in truncate mode. Append only.
Acceptance
- Every emitted record has a
level. evidencecount equals the count ofgaterecords withupdate: true.- A file with no
run_endreads cleanly and reportsincomplete. replay_memory_at(t)matches the memory the loop held att.
Verify
Harness: the scripted loop from M1.5 writing to a temp dir, plus a corrupted fixture.
Integration test — tests/it_event_log.rs:
a1_every_record_has_level— parse every line, assertlevelpresent and in{L0,L1,L2}.a2_evidence_matches_update_gates— countgate.update==true, assert equal to theevidencecount.a3_replay_equals_live— for everyt,replay_memory_at(t)equals the memory the loop held. This is the assertion that proves the authority model.a4_truncated_tail_reads— chop the last line mid-object; assert all prior events parse and the run reportsincomplete.a5_no_run_end_is_incomplete— a log ending after amemoryevent reports incomplete, not an error.a6_append_only— write, reopen, write again; assert the first events survive.a7_flush_per_event— kill the process (or drop without close) after 3 appends; assert 3 lines on disk.a8_run_id_sorts_by_time— three runs, assert lexicographic order equals chronological order.
Command: cargo test --test it_event_log
False pass:
- Asserting the file parses. A writer that omits
evidenceevents entirely produces a perfectly parseable log with an update-rate of zero — assertion 2 is what catches it. - Testing replay only at the final
t. A writer that records only the final memory passes that and fails assertion 3 at every intermediate turn. - Omitting assertion 7. Buffered writes pass every test that closes the file properly, and lose data in precisely the crash case the log exists for.
Traps
- Opening with truncate. One accidental re-run erases the authoritative record, and the projections are the only surviving copy — inverted authority.
- Gitignoring
log/. Makes the whole "JSONL is authoritative" claim a fiction.agent-rust/.gitignorehas a baretasksentry that untracks its whole board; do not repeat it here.
Background: DESIGN.md — Storage schemas, authority model