# 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 test** — `tests/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](../../../rust-agentic-sys.md) §8.7 · [rust-agentic-task.md](../../../rust-agentic-task.md)