MeridiansMeridians

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 GitHub

Raw Markdown source