Legacy lifecycle — how compatibility enters, rolls through the fleet, and leaves
Source path: knowledge-base/knowledge/architecture/persistence/legacy-lifecycle.md
# Legacy lifecycle — how compatibility enters, rolls through the fleet, and leaves
> **Scope.** This doc owns the *management* of legacy: the principles under which a historical shape
> may be tolerated, the one place every tolerated shape is listed, the mechanism that upgrades tenants
> so the tolerance can end, and the enforcement that makes it end on a date. The migration boundary
> itself (what a step is, how a body is stamped) is owned by
> [Record and write path](record-and-write-path.md) §versioning; this doc is what governs the steps'
> arrival and departure. Living document: when the code and this disagree, fix whichever is wrong.
## 1. Why legacy decays a system
Legacy code is not old code. It is a **reader that tolerates a shape the writer no longer produces.**
Each such reader is a second dialect of the record: it must be understood before any feature that
touches the shape can be changed, it is exercised only by data nobody can see in a fixture, and it
never announces when it has stopped being needed. Left alone, tolerance accumulates until the current
contract is a minority of the code that reads it. The 2026-09-09 audit found ~309 `legacy` sites in
100+ runtime files against a policy that says there should be none downstream, and a hosted record
(`jasonyu0100.meridians.global`) in which 18 of 22 domains still depended on them.
The cause was never the migrations. It was that **migrations were written for the reader and never
run against the record**, so the condition that would have let them leave never arrived.
## 2. Principles
1. **Tolerance lives at one boundary.** A historical shape is translated in exactly one place — a
step in a plan in `src/lib/core/storage/document-migrations.ts` for canonical bodies; a versioned
rebuild for projections. Features, reducers, prompts, queries, and analytics receive the current
contract only. A `?? legacyValue`, an `Array.isArray(...)` guard on a required field, or a
`LEGACY_*` alias map outside that boundary is a defect, not caution.
2. **Every tolerance is a ledger entry.** A retained transform carries, in code, what it translates,
when it landed, the *observable* condition that licenses deletion, the date by which that must
have happened, and the GitHub issue that tracks it (`LegacyOffRamp` in `ordered-migrations.ts`).
Steps declare this on their plan; everything else is listed in
`src/lib/core/storage/legacy-ledger.ts`. A legacy path that is not in the ledger is a bug.
3. **Upgrade the record, not the reader.** A step exists to be *run*, fleet-wide, through the
forward-stamp operation (§4). "Supported records no longer need it" is a probe reading of zero on
every supported tenant, not an impression.
4. **Projections are rebuilt, never migrated.** IndexedDB and every other derived cache get a version
bump, a clear, and a rehydrate from the record. Projection-migration code is a policy violation with
a date on it.
5. **Aliases die on a date.** Renamed URL tokens, artifact kinds, storage keys: there is no record
dependency, so the ledger date *is* the off-ramp. Old links land on the surface default.
6. **Secrets and credentials get an explicit operation.** Plaintext pass-through, key-store moves, and
hash-cost fallbacks are the one family where silent removal locks people out. They leave only after
an explicit seal / rehash operation has run, and never lazily.
7. **A retire date is enforced, not remembered.** `src/__tests__/legacy-ledger.test.ts` fails the
suite the day an off-ramp is overdue. The only two ways to go green are to delete the code with its
fixtures, or to re-plan the date *on the issue and in the ledger together*. Re-planning is allowed;
quiet slippage is not.
8. **Snapshot before any record write; verify after.** The forward-stamp op snapshots first, writes
through the ordinary CAS path, and re-runs the probe. Nothing in a supported tenant is lost by an
off-ramp if the probe read zero before the reader was deleted.
## 3. Lifecycle of one shape
```text
contract change ──▶ step + fixture + off-ramp ──▶ forward-stamp fleet ──▶ probe = 0 ──▶ delete step,
(PR) (same PR, dated issue) (admin MCP, per tenant) (issue) fixtures, guards
raise baseline
```
- **Enter.** The PR that changes a canonical contract adds the step *and* its `LegacyOffRamp` *and*
a fixture from a real record. It opens (or reuses) the tracking issue and sets `retireBy` two
releases out, never "later".
- **Roll.** The forward-stamp op is run per tenant through the admin MCP
([Fleet ops](../../operations/FLEET_OPS.md) read order first, then the write). The issue records
each tenant's before/after probe.
- **Leave.** When the probe reads zero fleet-wide, one PR: raise the plan baseline, delete the step
and fixture, delete every downstream guard the type-tightening surfaces, remove the ledger entry,
close the issue. `record-and-write-path.md` §versioning is updated in the same PR.
## 4. The forward-stamp operation
A Tier-2 admin operation, one tenant at a time, that: snapshots the record; reads every document
through the migration boundary plus the one-time fills the boundary cannot infer (frame ids on
deltas, timestamps on frames and threads, current vocabulary on arcs and readings); writes back through
the ordinary CAS path so `DocMeta.version` advances and SSE echoes; and reports the legacy-counter
probe before and after. It is tracked as
[#217](https://github.com/Meridians-Global/meridians/issues/217) and is the prerequisite for every
record-dialect off-ramp. It is specified, not yet built.
## 5. The ledger today (2026-09-09)
| Off-ramp | Family | Retire when | By | Issue |
|---|---|---|---|---|
| Domain v7 experience vocabulary, v8 scene arrays | record dialect | forward-stamp fleet-wide; probe `legacyArcKeys = legacyReadingKeys = sparseScenes = 0` | 2026-11-30 | [#218](https://github.com/Meridians-Global/meridians/issues/218) |
| `frame_legacy_*` ids, `frameId`-less deltas | record dialect | probe `frameDeltasNoId = 0` | 2026-11-30 | [#219](https://github.com/Meridians-Global/meridians/issues/219) |
| "undefined = legacy" optional fields and their readers | record dialect | probe `framesNoTime = threadsNoCreated = 0`; fields required | 2026-12-31 | [#220](https://github.com/Meridians-Global/meridians/issues/220) |
| IndexedDB projection migrations, `LegacyBoardLens` | projection | version bump + rehydrate shipped one release earlier | 2026-10-31 | [#221](https://github.com/Meridians-Global/meridians/issues/221) |
| URL / artifact / position-kind aliases, key-store sweep | alias | the date | 2026-10-15 | [#222](https://github.com/Meridians-Global/meridians/issues/222) |
| Plaintext aux secrets, DEK move, PBKDF2 fallback, local-only Director | secret | seal-all / rehash op has run fleet-wide | 2027-01-31 | [#223](https://github.com/Meridians-Global/meridians/issues/223) |
The code is the source of truth for this table (`legacy-ledger.ts` and the Domain plan); regenerate the
table from it rather than editing the two apart.
## 6. What this is not
- Not a licence to keep a shape because a tenant has it. The tenant is *upgraded*; the reader is not
preserved.
- Not a blanket delete. Removing a reader before the probe reads zero on every supported tenant
destroys data; the ledger exists so that order is never inverted.
- Not a second migration framework. There is one boundary and one runner; the ledger is metadata on
what already exists.
Open on GitHubRaw Markdown source