0040. Mixed-era placement expansion: one identity-fallback rule, liberal read / strict maintenance
Context
Proposal 0005 (M3 — Custodians) settles a question M2 left open: a chunk's
placement is recorded at commit (0005:178-207). Each ChunkRef carries a
placement vector — the stable D-server id holding each fragment — replacing
M2's stateless index % n fanout. The vector's length is the chunk's fragment
count: EcScheme::None → 1, ReedSolomon { k, m } → k + m.
The field was added additively to a schema that had not shipped a placement,
so it is #[serde(default)] (crates/core/src/metadata.rs): a record written
before the field — a pre-M3 / mixed-era record — decodes with an empty
vector. The read path intentionally treats an absent entry as identity
placement: fragment index i resolves to D-server i. This is encoded once,
in ChunkRef::placed_dserver(i) (placement.get(i).unwrap_or(i)), with
ChunkRef::fragment_count() defining the index space 0..n. The compatibility
rule is load-bearing precisely at migration time, when committed chunks have not
yet been rewritten with an explicit placement and operators are most likely to
drain or decommission D-servers.
That rule must hold anywhere a committed chunk map is interpreted: the read path,
and the four custodian loops — GC, scrub, reconstruction, and rebalance. The
#287 fix centralized the fallback and wired read, GC, scrub, and reconstruction
through placed_dserver/fragment_count. The #292 audit then found that
rebalance is the lone consumer still iterating the raw placement vector
(plan_evacuations): for a pre-M3 chunk it sees an empty vector, evacuates
nothing, and silently leaves a live fragment on a draining server — total object
loss for EcScheme::None, redundancy erosion for Reed-Solomon (#346). There is
also no single "walk every fragment to its holding D-server" helper; consumers
open-code the 0..fragment_count() walk, which is how the rebalance divergence
went unnoticed.
The audit raised five questions this record answers: the normative expansion rule per scheme; whether short (non-empty, wrong-length) vectors are supported; whether the fallback should be centralized; whether maintenance loops should reject malformed lengths; and which compatibility paths are migration-only and when they can be removed.
Decision
We will adopt a single normative placement-expansion rule, expressed through one helper, with a deliberately asymmetric strictness posture.
-
Normative expansion rule. A chunk's fragment index space is
0..fragment_count()(None→ 1;ReedSolomon { k, m }→k + m). Fragment indexiresolves to D-serverplacement[i]when present, elsei(identity). Every interpretation of a committed chunk map MUST use this rule. -
One expansion helper. A single helper —
ChunkRef::fragments() -> impl Iterator<Item = (u16, DServerId)>over the full index space — is the "walk every fragment to its holding D-server" call;fragment_count()andplaced_dserver()remain its primitives. No consumer may iterate the rawplacementvector — raw iteration is the defect class this record exists to foreclose. This bare helper is liberal: it applies the identity fallback unconditionally and does not validate length, so it is the read path's resolution. Maintenance loops resolve through the same expansion but only behind the validity gate of decision 4 — a malformed vector is rejected before it is expanded, never silently identity-filled. The gate is therefore a separate, fallible step, not a property offragments()itself: expose it as a companion (e.g.checked_fragments() -> Result<…, MalformedPlacement>, or aplacement_is_valid()predicate the loop checks first — #347/#348's call), whilefragments()stays infallible for read. -
Validity of a placement vector. A committed
placementvector is valid iff it is empty (pre-M3 → identity) orlen == fragment_count()(explicit). A non-empty vector of any other length is malformed: no writer produces one (the write path always emits a full-length vector; only#[serde(default)]yields empty), so in practice it can only mean truncation or corruption. Short non-empty vectors are NOT a supported steady state. -
Liberal read, strict maintenance. The read path stays liberal — it keeps the per-index identity fallback so a still-readable record is never made unreadable over a length quirk (availability first). The maintenance loops are strict: they MUST classify the committed placement before expanding — empty or
len == fragment_count()is valid and is walked viafragments(); a non-empty wrong-length vector is malformed and takes the path below WITHOUT expansion, so no identity entry is ever fabricated for it. GC and scrub fail safe — treat the chunk as fully referenced, never reclaim, and emit an audit event (ADR-0011); reconstruction and rebalance skip the chunk and flag it NEEDS-HUMAN. This costs nothing on real data (only empty or full vectors occur) and turns a corrupt vector into a visible signal instead of a silently fabricated placement. -
Repoints write a full-length placement. Any loop that rewrites placement (rebalance evacuation, reconstruction re-placement) MUST materialize a full-length, identity-resolved vector (length
fragment_count()) into the committed record — never a short or raw one. Expanding only an evacuation/repair filter while leaving the carried placement vector raw is incorrect: the downstream version-conditional commit indexes and writes that vector, so a raw empty/short vector panics or persists a malformed record (#346). -
The fallback is migration-only, behind a removal gate. The empty-vector branch exists solely for pre-M3 / mixed-era records and MUST NOT remain load-bearing indefinitely. It becomes removable once (a) a backfill migration has rewritten every pre-M3 committed chunk map with an explicit identity placement, using the same version-conditional commit the custodians use, and (b) a scan confirms zero empty-placement committed records remain. At that point the empty branch SHOULD become a defensive error gated behind a metadata format-version bump. Until both hold, the fallback stays load-bearing (#350).
We reject: supporting short non-empty vectors as a valid steady state; silently fabricating identity placement for malformed vectors inside maintenance loops; and leaving the compatibility fallback in place with no defined removal path.
Consequences
When this record was authored (29.06.2026) it changed no code: the rebalance
data-loss path (#346) was still live on main — plan_evacuations iterated
chunk.placement raw and no fragments() helper existed. Those fixes have since
landed, and this record is ratified with the M3 close alongside them:
ChunkRef::fragments() is now the single expansion helper
(crates/core/src/metadata.rs, #347/#361) and rebalance's plan_evacuations
resolves through it (crates/custodian/src/rebalance.rs, #346), so there is one
definition of placement expansion and the data-loss class is closed — and, because
the helper always walks the full index space, it structurally cannot recur (a
reviewer can grep for .placement.iter() to catch any future regression).
Malformed placement vectors become an observable signal (audit event /
NEEDS-HUMAN) once the strict-maintenance stance is fully implemented (#348, still
open), rather than a silent identity resolution that masks corruption.
The cost is real follow-on work, the #292 follow-ups on M3 — Custodians.
Landed with the M3 close: the fragments() helper and the migration of every
consumer onto it (#347/#361); the rebalance expand-and-materialize fix (#346);
and the mixed-era test matrix across read / scrub / reconstruction / rebalance,
larger RS{6,3}, and a DST scenario seeded with an empty-placement chunk (#349).
Still open, re-homed to Foundations as hardening beyond the M3 definition of
done: the strict malformed-length handling with audit / NEEDS-HUMAN (#348), and
the backfill migration plus the format-version-gated removal of the fallback
(#350, with its defensive-error step #363). The strict-maintenance stance adds
fail-safe and NEEDS-HUMAN paths that did not exist before.
This record refines proposal 0005's placement-record section without editing the frozen proposal (per ADR-0037 / ADR-0001 / ADR-0038: the relationship is recorded here and in the ADR index, never by back-patching 0005). It is load-bearing for ADR-0033's durability argument — the distinct-failure-domain spread it requires is only verifiable if placement resolves correctly for mixed-era chunks — and keys on ADR-0034's first-class failure-domain identity. The atomic, version-conditional placement repoint preserves ADR-0015's consistency contract over the repair path, and the audit-event obligation follows ADR-0011's durability-telemetry stance. Reversal is cheap while this is Proposed; once the backfill runs and the fallback is removed behind a format-version bump (#350), reintroducing pre-M3 compatibility would require restoring the compat branch and accepting empty-placement records again — so the removal step is the point of no easy return, which is why it is gated on an explicit scan rather than a time-based assumption.