87 lines
3.4 KiB
Markdown
87 lines
3.4 KiB
Markdown
# 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)
|