dc-node-v6-carve: v6 FAMILY guard + a `replace` action for the D-139 step-3 migration
...
Two defects and one gap, all on the critical path.
FAMILY GUARD. v6_by_vlan[vid] = s was last-writer-wins. Once step 2 put a GUA /64
alongside every ULA /64 on the same vlan, that decided the family of all 54 node statics
by MAAS's JSON array order. Measured live: it picked GUA -- luck, not a contract, and the
FOURTH copy of the defect the 2026-07-29 chain audit found in the apex readers. Now keyed
by (vlan, family): --v6-family selects, ambiguity without it REFUSES naming the flag, and
two subnets of the SAME family on one vlan still refuse.
REPLACE ACTION. The nodes hold pre-D-139 ULA statics and `apply` correctly refuses to add
a second address -- two globals on a NIC break G19's sole-global predicate, which has no
exemption. `replace` unlinks the old-family link then links the new one, per interface,
with read-back. It refuses a same-family change (that is an address edit, not a
migration), refuses when a NIC carries more than one v6, and refuses when the old link
carries no link id -- without an id there is no unlink, and falling through would ADD a
second global. Unlink failure does NOT attempt the link: a half-done interface is worse
than an untouched one. Real MAAS links were measured to carry ids (326, 410).
GAP FOUND BY WRITING THE TEST: `want in have` short-circuited to skipped++, so a NIC
carrying the wanted address AND a stray second global counted as "already correct" --
silently masking exactly the half-done migration G19 forbids. Now an error. F8's
assertion was REPLACED with that new invariant, not deleted, and F9 added to cover the
refusal path F8 was originally written for.
Harness 14 -> 23. Three mutations each kill named cases; restored sha256-identical.
Gauntlet ALL GREEN (96); repo-lint 0 fail / 1 warn.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HvCyrwvYTTcDYnRErfMsNf