4.1 KiB
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. WorkEventis#[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.
BranchIdis 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
- Declare
SchemaVersion(u16)andLogRecord/BranchKeyas above. - Declare
WorkEventas#[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, andReduced { original: BlobRef, summary: BlobRef }. - Pick a self-describing codec (
serde+ CBOR or similar). Writeencode(&LogRecord) -> Vec<u8>anddecode(&[u8]) -> Result<LogRecord>, with decode dispatching on theschemafield read before the event body. - Write a fixture generator that serializes one record of every variant at v1
into
tests/fixtures/v1/. Commit the bytes. - 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. - 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:
- Walk
tests/fixtures/*/, decode every file. - Assert each decodes to its expected value, loaded from a committed expectation file — not reconstructed in the test body.
- Assert every fixture's
schemafield is present and non-zero. - Assert the variant coverage: every
WorkEventvariant appears in at least one fixture. Amatchover 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
schemaat 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