# T0.3 — `WorkEvent` and `SchemaVersion` | Field | Value | |---|---| | Phase | P0 — Foundations | | Size | M — 1 to 3 days | | Status | ✅ Done | | Flags | — | | Spec | inlined below | | Blocks | T0.4, T2.4 | ## Goal The event enum plus the wire-version discipline that keeps a two-year-old log decodable. Must land before the second event variant is ever written. ## Facts (inlined — no spec read needed) ```rust pub struct LogRecord { pub key: BranchKey, // (TenantId, RunId, BranchId) pub lsn: Lsn, /// Wire-format version of `event`. Never removed, never reused. pub schema: SchemaVersion, pub at: Timestamp, pub event: WorkEvent, } pub struct BranchKey { pub tenant: TenantId, pub run: RunId, pub branch: BranchId } ``` Rules, from record one: - Every record carries `SchemaVersion`. Written always, even at v1. - `WorkEvent` is `#[non_exhaustive]`; decode is version-dispatched. - **Variants are never removed or repurposed.** Deprecated variants stay decodable forever. Storage is cheap; an undecodable log is not. - Migrations are upcasters applied on read (T0.4), never by rewriting history. - A round-trip test per version — a stored fixture of every historical version still folds to the expected state. **That test is the whole guarantee**; without it the rules above are aspirational. - `BranchId` is in the key, not implied. LSNs are **per branch**, not global: a global counter serializes every run through one atomic, and the ordering contract only promises per-run total order. ## Steps 1. Declare `SchemaVersion(u16)` and `LogRecord` / `BranchKey` as above. 2. Declare `WorkEvent` as `#[non_exhaustive]` with the v1 variant set the walking skeleton needs: attempt transitions, run-lifecycle transitions, prompt/output blob refs, context partition, usage, intent records, and `Reduced { original: BlobRef, summary: BlobRef }`. 3. Pick a self-describing codec (`serde` + CBOR or similar). Write `encode(&LogRecord) -> Vec` and `decode(&[u8]) -> Result`, with decode dispatching on the `schema` field read **before** the event body. 4. Write a fixture generator that serializes one record of every variant at v1 into `tests/fixtures/v1/`. Commit the bytes. 5. Write the round-trip test: walk `tests/fixtures/*/`, decode each, assert the expected value. A directory walk means adding a version folder automatically extends coverage. 6. Add a test that fails if a fixture directory exists for a version with no registered upcaster — wire this once T0.4 lands. ## Acceptance - A serialized v1 fixture checked into the repo, plus a test that decodes it. ## Verify **Harness:** committed fixture bytes under `tests/fixtures/v1/`, loaded from disk. A regeneration path exists only behind an env var. **Integration test** — `tests/it_fixture_roundtrip.rs`: 1. Walk `tests/fixtures/*/`, decode every file. 2. Assert each decodes to its expected value, loaded from a committed expectation file — not reconstructed in the test body. 3. Assert every fixture's `schema` field is present and non-zero. 4. Assert the variant coverage: every `WorkEvent` variant appears in at least one fixture. A `match` over the decoded set with no `_` arm forces this to fail when a variant is added without a fixture. **Command:** `cargo test -p log fixtures` — and in CI, with `FIXTURE_REGEN` **unset**; assert the test fails loudly if it is set. **False pass:** - The fixture being serialized by the code under test at test start, then immediately decoded. That tests nothing across versions. Bytes must come from git. - Coverage drift: a new variant is added, no fixture is written, and the directory walk still passes because it only checks what is there. Step 4 is the guard. ## Traps - Renaming one variant two years in and losing drop-and-re-fold silently. - Non-self-describing formats where a field reorder decodes to garbage rather than erroring. - Omitting `schema` at v1 "because there is only one version". --- Background (not required to do this task): [rust-agentic-sys.md](../../../rust-agentic-sys.md) §8.2, §8.7 · [rust-agentic-task.md](../../../rust-agentic-task.md)