Files
poimen/tasks/T0.4-upcaster-framework.md
T

3.4 KiB

T0.4 — Upcaster framework

Field Value
Phase P0 — Foundations
Size M — 1 to 3 days
Status Not started
Flags
Spec inlined below
Blocks T2.4

Goal

Read-time migration chain so old log records fold correctly after the event enum changes. Never rewrite stored records.

Facts (inlined — no spec read needed)

  • Migrations are upcasters: fn upcast(vN) -> vN+1, applied on read.
  • Rewriting an append-only log is a contradiction. History stays as written; the reader adapts.
  • Registry keyed by version. Reading a v1 record under a v3 binary runs v1→v2→v3 in sequence.
  • Deprecated variants stay decodable forever — an upcaster maps them forward, it does not delete them.
  • The guarantee is the fixture test: a stored fixture of every historical version still folds to the expected state.

Steps

  1. Keep the per-version wire types as real types — WorkEventV1, WorkEventV2, … — with the current one aliased to WorkEvent.
  2. Define the upcaster shape: fn(vN) -> vN+1, either as function pointers or a trait Upcast { type From; type To; fn upcast(from: Self::From) -> Self::To; }.
  3. Build the registry keyed by SchemaVersion. Registration is explicit; a missing link is an error at registry construction, not at read time.
  4. read_record(bytes): decode schema, decode the matching vN body, then apply each upcaster in order up to CURRENT_SCHEMA.
  5. Return typed errors — UnknownSchemaVersion, MissingUpcaster { from, to }. A corrupt or future-version record must not panic the fold.
  6. Build the synthetic v1→v2 migration as the test vehicle: add a field or split a variant in WorkEventV2, write the upcaster, commit fixtures for both.

Acceptance

  • Synthetic v1 → v2 migration with a fixture per version.
  • Folding a v1 log through the upcaster yields the same state as a natively-v2 log. Assert on the folded state, not on intermediate events.

Verify

Harness: committed v1 and v2 fixtures, plus T0.8's fold.

Integration testtests/it_upcast_equivalence.rs:

  1. Fold the committed v1 log through the upcaster chain → state_a.
  2. Fold the committed native v2 log directly → state_b.
  3. Assert serialize(state_a) == serialize(state_b) byte for byte.
  4. Instrument the registry with a counter; assert at least one upcaster actually ran on the v1 path.
  5. Feed a record stamped with an unknown future version; assert UnknownSchemaVersion is returned and no panic escapes.

Command: cargo test -p log upcast

False pass:

  • CURRENT_SCHEMA still equal to v1, so the chain is empty and both folds trivially agree. Step 4 is the guard.
  • Comparing folded states with a derived PartialEq over HashMap, which ignores ordering. Compare serialized bytes.
  • The v2 change being cosmetic (a renamed local, an added comment) so no upcaster logic is exercised. The v2 fixture must contain a record whose shape actually differs.

Traps

  • Lossy upcasters that default a new field to a value the fold treats as meaningful. If v1 cannot supply the field, the fold handles Option — it does not receive a fabricated value.
  • A registry populated by iteration order rather than an explicit chain. The gap only shows at v3.

Background (not required to do this task): rust-agentic-sys.md §8.7 · rust-agentic-task.md