diff --git a/docs/cookbook-index.md b/docs/cookbook-index.md
index d63dabd83..745598a60 100644
--- a/docs/cookbook-index.md
+++ b/docs/cookbook-index.md
@@ -2,7 +2,7 @@
> **Generated by `tools/cookbook_index.py` — do not hand-edit** (R33). Regenerate after adding a cookbook section.
>
-> `docs/matching-cookbook.md` is ~716 KB / 1087 sections. Grepping it blind is how three P30 wave-1 agents each "discovered" an idiom that was already written down. **Start here, then read the section.** A section appears under every symptom it addresses.
+> `docs/matching-cookbook.md` is ~716 KB / 1088 sections. Grepping it blind is how three P30 wave-1 agents each "discovered" an idiom that was already written down. **Start here, then read the section.** A section appears under every symptom it addresses.
**How to use:** name what you SEE in the diff (a stolen delay slot, an extra `la`, a swapped register pair, a `conflicting types` error), find that symptom below, read those sections first. If nothing fits, THEN grind — and add a section when you win.
@@ -812,7 +812,7 @@
- **§362** — TWO TRAPS WHEN A CARVE MOVES A STUB INTO THE `-O0` TU (P31 S68; byte-proven, 6 fns / 2,547 ins across ov_MAIN_012 / ov_SC02_037 / ov_SC03_107) L31682
- **§388** — ★★★ — THE **-O0 COLOURING ORACLE**: simulate `stupid.c` instead of grinding spellings (P31 S69; main/func_80011380 proved a C-level WALL at 6) L32384
-### family propagation & sweeps (120)
+### family propagation & sweeps (121)
- **§8d** — Templating a body INTO a TU must not CHANGE its declaration environment — demote the carried data externs (Phase 26 session 8, byte-proven on `func_8015AE2C` ×133) L502
- **§11** — Cross-binary dedup & code-sharing (Phase 11 — "one match unlocks many") L927
@@ -934,6 +934,7 @@
- **§398** — ★★★ — `family_remap` CARRIES THE **SOURCE** TU's DECL ENVIRONMENT INTO A DESTINATION THAT ALREADY OWNS THOSE NAMES (P31 S69; measured 3 banked of 22) L32835
- **§3-B.** — THE SCHEDULER DIALS (the dominant residual family this wave) L33108
- **§408** — ★★★ — §406 REFUTED AS A SWEEP: THE SHAPE IS THE FAMILY, THE DISAGREEMENT IS THE DEFECT (P31 S71; 0 MATCH / 14 applied, 0 / 210) L33209
+- **§420** — ★★★ — A MULTI-CLUSTER SYMBOL REBASE, AND THE BARE-NAME DEDUP THAT HID THREE QUARTERS OF IT (P31 S71; 4 banked in 57 s) L33692
### integration / TU plumbing (76)
@@ -2762,6 +2763,7 @@
- **§417** — ★★★ — A REGISTER PIN CAN BLOCK `jump.c`'s SELECT COLLAPSE, AND UNPINNING THEN EXPOSES A `cse` SKIP-BLOCKS MERGE (P31 S71; byte-proven `ov_SC03_013/func_8017E6F4`, 182 ins) L33605
- **§418** — ★★★ — TWO LOOP-STRUCTURE LEVERS: MAKE THE SECOND INDEX A GIV, AND KEEP A TABLE ADDRESS UNFOLDED (P31 S71; byte-proven `ov_SC04_016/func_8017DF8C`, 184 ins, 32 → 0 in seven compiles) L33632
- **§419** — ★★★ — WHEN A PIN IS IMPOSSIBLE, WIN THE local-alloc DENSITY CONTEST INSTEAD (P31 S71; byte-proven `ov_SC01_000/func_8017DD04`, 297 ins) L33658
+- **§420** — ★★★ — A MULTI-CLUSTER SYMBOL REBASE, AND THE BARE-NAME DEDUP THAT HID THREE QUARTERS OF IT (P31 S71; 4 banked in 57 s) L33692
---
@@ -3861,3 +3863,4 @@ Notes routinely quote that as a section id. This table resolves it. Grep bait: `
| L33605 | §417 | ★★★ — A REGISTER PIN CAN BLOCK `jump.c`'s SELECT COLLAPSE, AND UNPINNING THEN EXPOSES A `c |
| L33632 | §418 | ★★★ — TWO LOOP-STRUCTURE LEVERS: MAKE THE SECOND INDEX A GIV, AND KEEP A TABLE ADDRESS UNF |
| L33658 | §419 | ★★★ — WHEN A PIN IS IMPOSSIBLE, WIN THE local-alloc DENSITY CONTEST INSTEAD (P31 S71; byte |
+| L33692 | §420 | ★★★ — A MULTI-CLUSTER SYMBOL REBASE, AND THE BARE-NAME DEDUP THAT HID THREE QUARTERS OF IT |
diff --git a/docs/matching-cookbook.md b/docs/matching-cookbook.md
index 6784fba30..5a3e3cbdb 100644
--- a/docs/matching-cookbook.md
+++ b/docs/matching-cookbook.md
@@ -33688,3 +33688,40 @@ density ranking rather than the register.* Reference count is a dial (zero-byte
length is a dial (where you place the barrier); local-alloc's ordering is arithmetic on the two, so
compute it from the `-df`/`-dl` dumps and aim, instead of trying pins that a call-argument register
structurally forbids.
+
+## §420 ★★★ — A MULTI-CLUSTER SYMBOL REBASE, AND THE BARE-NAME DEDUP THAT HID THREE QUARTERS OF IT (P31 S71; 4 banked in 57 s)
+
+**The shape.** One shared body (`func_8016AB6C`) lives in four overlays — `ov_SC03_107`,
+`ov_SC07_007`, `ov_SC07_010`, `ov_SC07_011`. Every copy scored `match_one` closeness 0 and every copy
+failed the gate with `undefined reference to D_8018D538`: the classic §171 stale-seed-symbol class, the
+draft carrying the symbols of the overlay it was written for.
+
+**Defect 1 — `aprop_symfix` deduped the slate by BARE FUNCTION NAME (R48/§238).** A four-row slate
+reported *"1 drafts audited"*. Three rows were silently dropped, and they were not redundant: **each
+overlay needs a DIFFERENT rebase**, because each has its own target symbols. Keyed by `(binary, fn)`,
+all four audit. This is the same defect class as `reloc_filter`'s `binof` (fixed earlier the same
+session) and `gate_lane`'s homonym staging — three tools, one root.
+
+**Defect 2 — the delta rule allowed exactly ONE uniform cluster.** `STALE-DELTA` requires the sorted
+zip to have a single `target − draft` delta, so it refused all four as AMBIGUOUS. The real structure
+is **two clusters of two**, and it is identical in shape across all four overlays:
+
+| overlay | run 1 | run 2 |
+|---|---|---|
+| ov_SC03_107 | −0x7F64 ×2 | −0x5A650 ×2 |
+| ov_SC07_007 | −0x74DC ×2 | −0x2E898 ×2 |
+| ov_SC07_010 | −0x844C ×2 | −0x4E3A0 ×2 |
+| ov_SC07_011 | −0xBD8C ×2 | −0x65D08 ×2 |
+
+Two data clusters, each moving as a block — exactly what one seed body copied into four overlays
+produces. The single-delta rule is the special case of one cluster.
+
+**The safe generalisation.** Sort both symbol lists, zip positionally, split into runs of constant
+delta, and **require every run to have ≥ 2 members** — a single-member run is a free guess, not a
+cluster, and that is where a wrong symbol would come from. Filter to `D_8xxxxxxx` first: the draft's
+`Ent_`/`Obj_`/`SVec_` typedef names and the target's `rand`/`jtbl_` carry the same vram-looking suffix
+and are not data symbols.
+
+**Result: 4 banked in 57 seconds of gate time, zero drafting** — and all four had been sitting at
+closeness 0 behind a symbol rename. **Verify after rewriting** (`match_one` again, all four still
+MATCH) before gating; a rebase that changes the body is a rebase you got wrong.
diff --git a/tools/aprop_symfix.py b/tools/aprop_symfix.py
index d48ee9625..219f231a9 100644
--- a/tools/aprop_symfix.py
+++ b/tools/aprop_symfix.py
@@ -188,12 +188,19 @@ def main():
recs = [r for r in json.load(open(a.slate)) if isinstance(r, dict) and (r.get('fn') or r.get('name'))]
skip = set(json.load(open(a.skip))) if a.skip else set()
+ # KEY BY (binary, fn), NEVER BY NAME (R48/§238). A slate legitimately carries the SAME function
+ # name for several binaries — one shared body copied into four overlays is the common case — and
+ # a name-keyed `seen` silently audited ONE of them. Measured S71: a 4-row slate for
+ # func_8016AB6C across ov_SC03_107/ov_SC07_007/010/011 reported "1 drafts audited", and the
+ # three unexamined rows each needed a DIFFERENT rebase because each overlay's target symbols
+ # differ.
seen, rows = set(), []
for r in recs:
fn = r.get('fn') or r['name']
- if fn in skip or fn in seen:
+ key = (r['binary'], fn)
+ if fn in skip or key in seen:
continue
- seen.add(fn)
+ seen.add(key)
draft = r.get('draft') or f".run/aprop1/{fn}/{fn}.c"
st, stale, asm_only = audit_one(fn, r['binary'], draft)
rows.append(dict(fn=fn, binary=r['binary'], draft=draft, status=st,