Files
poimen/tasks/T0.3-workevent-and-schemaversion.md

4.1 KiB

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)

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<u8> and decode(&[u8]) -> Result<LogRecord>, 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 testtests/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 §8.2, §8.7 · rust-agentic-task.md