# Session changelog 2026-07-27 -- grounding-audit merge + Stage-5 Phase 0

ONE changelog per session (GA-R2/D1). Under blanket approval this is the review
surface: every item states WHAT, WHY (evidence), and HOW TO REVERT.

**No logged window was opened for this session** (`scripts/run-logged.sh` was
offered; the operator approved the steps directly). This file plus
`docs/audit/stage5-phase0-20260727.txt` are therefore the ENTIRE as-executed
record of five live mutations. Reverts below are stated with that in mind.

Status authority for everything here is `docs/CURRENT-STATE.md` (GA-R1). This
file is session-scoped scratch and is NOT citable as status or decision
authority.

---

## 1. Merged `dc-dc-stage5-grounding-audit` to `main`

**What.** Merge commit `607813b`, 2 parents (NOT squashed), 33 commits.
Recorded on `main` by follow-up commit `6495cfb` -- a `--no-ff` merge cannot
carry the doc edit, so this is the Stage-4 shape (`6f5701d` recorded by
`1023596`).

**Why.** Operator direction, exact utterance: "Merge to main, then start Phase
0". The branch carried 14 GA-R5 rulings and the audit record; precondition work
must branch off a `main` that contains them. **No stage opened or closed.**

**Evidence.** `git log -1 --pretty=%P` -> two hashes. `git branch --merged main`
listed the branch before deletion. Post-merge ON `main`: gauntlet ALL GREEN
(81 harnesses), repo-lint 0 fail / 1 standing warn.

**Revert.** `git revert -m 1 607813b` on `main` (then revert `6495cfb`).
Prefer this over a reset -- `main` is pushed and shared.

## 2. Retired the audit branch (local + origin)

**What.** Deleted `dc-dc-stage5-grounding-audit` from origin and locally on
vcloud. Last commit was **`39e8988`**.

**Why.** Contained in `main` after item 1, and leaving it alive would hand the
voffice1 clone a FRESH stale remote-tracking ref during item 3 -- the exact
condition that produced the 105-commit problem being fixed.

**Evidence.** Containment re-checked immediately before deletion; `git branch -d`
(not `-D`) used, which refuses an unmerged branch. `git ls-remote --heads origin`
afterwards -> `main` only.

**Revert.** `git push origin 39e8988:refs/heads/dc-dc-stage5-grounding-audit`.
Nothing is lost meanwhile -- the commits are in `main`.

## 3. Advanced the voffice1 clone to `main` (precondition 0.1)

**What.** On `voffice1:~/openstack-caracal-dc-dc`: `git fetch origin --prune`
then `git switch main`. Was `61c416e` on `dc-dc-g12-dc1-substrate`, a branch
deleted upstream; now `6495cfb`.

**Why.** voffice1 is the D-128 Plane-2 host Stage 5 EXECUTES from, and it was
105 commits behind with both dc1 overlays ABSENT and `bundle.yaml` still the VR0
4-node hyperconverged layout -- readiness blocker 1, "you would deploy the wrong
topology". R10 ruled this fix. A plain `git pull` could not work (tracked branch
gone upstream); the shape is `fetch` + `switch` per finding L5-4.

**Evidence / the safety argument.** Both DCs' inner tfstate -- the substrate's
state-of-record -- lives INSIDE that working tree, so this was not a routine
checkout. PROVEN before the switch: every state artifact is
`git check-ignore`-IGNORED, and `origin/main` tracks an identical file set at
those paths (so no collision, and the one untracked-not-ignored file,
`vr1-dc1-substrate/.terraform.lock.hcl`, is untracked on `main` too). AFTER:
both tfstate sha256s BYTE-IDENTICAL to before (`2e140b74...`, `aa91f103...`);
`HEAD == origin/main` asserted; both dc1 overlays now present; `bundle.yaml` now
the 9-node role-separated layout.

**Revert.** `ssh voffice1 'cd ~/openstack-caracal-dc-dc && git switch -c
dc-dc-g12-dc1-substrate 61c416e'`. The ignored state artifacts are untouched by
either direction, which is the point of the check above.

## 4. Deleted two stale local branches on voffice1 (precondition 0.3)

**What.** `dc-dc-g12-dc1-substrate` (was **`61c416e`**) and
`dc-dc-stage3-phase2-dc-substrate` (was **`57836b1`**). Three stale
remote-tracking refs were pruned by item 3's `--prune`.

**Why.** Both tracked branches deleted upstream. The record named ONE stale
local branch; there were two.

**Evidence.** `git rev-list --count origin/main..<branch>` -> **0** for both
(fully contained, nothing unique lost). `git branch -d` used, which refuses if
unmerged.

**Revert.** `ssh voffice1 'cd ~/openstack-caracal-dc-dc && git branch
dc-dc-g12-dc1-substrate 61c416e && git branch dc-dc-stage3-phase2-dc-substrate
57836b1'`.

## 5. Installed the `openstack` client on voffice1 (precondition 0.2)

**What.** `sudo apt-get install -y python3-openstackclient` on voffice1.
Landed `python3-openstackclient 6.6.0-0ubuntu2`, `python3-openstacksdk
3.0.0-0ubuntu2`, `python3-novaclient 2:18.5.0-0ubuntu1`. 68 packages newly
installed, 0 upgraded, 0 removed.

**Why.** Readiness blocker 2: the client was absent on BOTH hosts while ten
Stage-5/6/7 scripts invoke it. voffice1 is where D-128 runs them.

**Evidence -- the method was measured, not assumed.** The snap was REFUTED:
`openstackclients` has NO Caracal channel (newest stable `zed`, 2023-03;
`latest/stable` is `xena`, 2021), and `docs/design-decisions.md:638` already
records its home-only confinement trap. 6.6.0 IS the Caracal 2024.1 client,
verified upstream rather than from memory. Verified behaviourally after install:
`openstack --version` -> `openstack 6.6.0`; `--help` exit 0; `server list` fails
CLEANLY on absent auth config rather than crashing.

**Revert.** `ssh voffice1 'sudo apt-get purge -y python3-openstackclient
python3-openstacksdk python3-osc-placement && sudo apt-get autoremove -y'`.
Scope the autoremove by reviewing `/var/log/apt/history.log` for this
transaction first -- 68 packages were pulled and some (e.g. `python3-pil`,
`libopenjp2-7`) may be shared with other consumers on that host.

**Note this is now a PIN.** Recorded in `docs/CURRENT-STATE.md` section 7,
which carried no row for this component before.

---

## Findings LOGGED, NOT ACTIONED (hard rule 1 -- outside Phase 0's ruled scope)

Full detail in `docs/audit/stage5-phase0-20260727.txt`.

- **P0-1 the gauntlet is HOST-DEPENDENT.** 2/81 FAILED on voffice1 on the
  IDENTICAL commit that is ALL GREEN (81) on vcloud. Neither is a tree defect.
  `opentofu-validate` passes every sub-check and still reports FAIL because
  `tofu fmt -check -recursive` walks the FILESYSTEM and trips on a GITIGNORED
  file that exists only on voffice1. `site-headend-install:36` asserts a
  `--dry-run` installed no snap by snapshotting the host without comparing to a
  pre-state -- on the real headend, where lxd/maas are installed by design, a
  false positive. **Every GA-R6 stage close in this project cites a gauntlet
  figure measured on vcloud only.**
- **P0-2 preflight P5 is HOST-BLIND.** 34 findings on voffice1 vs 7 on vcloud,
  same 82-row matrix and commit. The matrix's `jumphost` role has no assertion
  about WHICH host is the jumphost, so those locations re-point to whatever host
  runs the checker. Surfaces here as false RED; the same hole yields false GREEN
  for a credential present on the wrong host under a matching name.
- **P0-3** `opentofu/vr1-dc1-substrate/.terraform.lock.hcl` is untracked on
  `main` while its dc0 twin IS tracked.
- **P0-4** no logged window opened for this session (see the header).
- **P0-5** `lib-net.sh:37` declares a metal-admin gateway `10.12.8.1` that NO
  device holds -- measured 100% loss + `INCOMPLETE` ARP against a control ping
  to `10.12.4.1` at 0% loss. MAAS is right; the checker is wrong. **This capture
  initially MIS-FILED it as covered by readiness item 3.7; it is not** (3.7 is
  the VID-103/`br-internal` assertions). The dc1 arm carries the same shape at
  `10.12.68.1`.

## Question QUEUED for the operator (one GA-R5 exchange; no D-number assigned)

**Which host is authoritative for the gates?** R10 ruled preflight belongs on
voffice1; R15(3) rules preflight must stop failing open. Executed together as
ruled, and given P0-2, preflight on voffice1 becomes PERMANENTLY UNPASSABLE --
its 34 findings are host artifacts. Similarly R15(2) pins a harness MANIFEST,
which catches renames but does nothing about P0-1, so R15(2) as ruled still
leaves voffice1 red. GA-R3 resolves doubt DOWN to OPS; whether "which host is
authoritative for a gate" clears the A1 Roosevelt test is the operator's call.

**Standing consequence either way:** Stage 5 executes from voffice1 and the
gauntlet cannot be green there until P0-1 is resolved, so any GA-R6 close citing
a gauntlet figure measured there inherits a known red -- the citation must name
its host.

---

## 6. R15(1) -- repo-lint can no longer PASS over zero files

**What.** `scripts/repo_lint.py`: reject unrecognised options instead of treating them
as the repo root; reject >1 positional root; require the root to be a directory AND to
carry repo markers (`docs/`, `scripts/`); add a files-scanned floor; and PRINT the
files-scanned count in the summary line.

**Why.** R15(1), ruled 2026-07-27. Reproduced before fixing, both cases:
`python3 scripts/repo_lint.py --recordd` and `... /nonexistent-path` each printed
`PASS: repo lint (0 fail, 0 warn)` with **exit 0**. Only the two known flags were
stripped, so any other `--flag` became `argv[0]` i.e. the ROOT, which resolved nowhere,
`rglob` yielded nothing, and zero findings read as clean. **This is the gate whose
"0-fail" every GA-R6 stage close in this project's history cites.** The printed count
also closes a gap found during Phase 0: repo-lint emitted no files-scanned figure at
all, so a reader could not distinguish a thorough pass from a vacuous one.

**Design note -- the floor SCALES, and a flat one was wrong.** First implementation used
a flat floor of 100 and **failed 20 of the 47 existing harness cases**, because
`tests/repo-lint` legitimately builds ~7-file fixture repos to plant one violation each.
The strong floor now applies only to a full checkout of THIS repo (detected via
`scripts/preflight.sh` + `docs/security-ledger.md`, files no fixture creates); any other
tree must still scan at least one file, which is what catches "the walk filters emptied
the scan". Caught by running the harness, not by review.

**Verification.** Harness `tests/repo-lint` **54/54** (was 47) -- seven new cases
T48-T54, each locking a defect reproduced before the fix, including T53 (full-repo
markers + near-empty tree trips the floor) and T54 (the floor does NOT punish a
legitimate minimal tree). Gauntlet ALL GREEN (82). Real repo: 0 fail, 1 standing warn,
**596 files scanned**.

**No consumer breaks:** `preflight.sh` P1 consumes the EXIT CODE (`note $?`), not the
summary text; the harness regexes match on `PASS: repo lint` which is unchanged as a
prefix. Both verified.

**Revert.** `git revert <this commit>`. The lint reverts to accepting any unknown flag
as its root -- i.e. back to a gate that cannot distinguish a clean repo from an
unexamined one.

## 7. R15(3) -- preflight no longer FAILS OPEN

**What.** `scripts/preflight.sh`: `note()` now takes a gate label and a per-gate
policy for rc=2, treats ANY unexpected exit code as FAIL, and names the gate in the
message. All 7 call sites updated.

**Why.** R15(3), ruled 2026-07-27. Reproduced before fixing, in isolation, using the
exact `note()` from `:27`: sub-gate exits **127, 126, 130 and 3 ALL left
`PREFLIGHT: PASS -- clear to add-model / deploy`**. This is the gate that authorises
the deploy.

**Measured departure from R15's stated rationale, and why.** R15 says rc=2 "is how
these checkers signal 'I could not evaluate anything'" and should be remapped. Measured
per checker, that is only PARTLY true:
`repo-lint.sh` documents rc2 as "warnings only"; `pre-flight-checks.sh` documents rc2 as
"warning"; but `provider-bundle-check.py` returns 2 only on could-not-evaluate (PyYAML
missing `:32`, cannot parse `:108`, cannot merge overlay `:113`) and `creds-matrix.py`
likewise (`:116`, `:1037`). `channel_assert.py` uses 2 for BOTH (`sys.exit(2)` missing
PyYAML at `:19`; `return 2 if warns` at `:55`).
**A blanket 2 -> FAIL would have turned the standing L1 legacy-ASCII warn into a deploy
blocker.** So rc=2 is per-gate: FAIL for P2/P5, WARN for P1/P4. P3 is ambiguous IN THE
CHECKER and kept WARN -- its missing-PyYAML root cause is caught anyway by P2, which
shares it and is strict. Making P3's rc2 unambiguous belongs in `channel_assert.py`;
flagged, not guessed.

**Verification.** Harness `tests/preflight` **16/16** (was 10). T11-T13 lock the
127/126/3 fail-open cases; T14/T15 lock could-not-evaluate -> FAIL; **T16 locks the
NON-over-correction** (P1 rc=2 stays WARN). Gauntlet ALL GREEN (82); repo-lint 0 fail.
Real repo: preflight still exits 1 for the same known reasons, with no new
UNEXPECTED/COULD-NOT-EVALUATE lines -- i.e. the fix did not newly break it.

**Revert.** `git revert <this commit>`. Preflight returns to ignoring every sub-gate
exit code that is not 1 or 2 -- a crashed gate again reads as clear-to-deploy.

## 8. R15(2) -- the gauntlet pins a harness MANIFEST, not a count

**What.** `tests/HARNESS-MANIFEST` (82 names, sorted) + a drift gate in
`scripts/run-tests-all.sh`, with `--record-manifest` to re-record deliberately.

**Why.** R15(2), ruled 2026-07-27. `run-tests-all.sh:35` already had a ZERO-floor
(`RAN -eq 0` -> exit 2); what was absent was any pin on WHICH harnesses ran. The count
`81` existed only as prose in CURRENT-STATE, so a renamed or deleted harness was neither
run nor failed and the gauntlet still printed ALL GREEN.

**Proven able to fail, on both cases -- including the one a bare count cannot catch.**
(a) renamed `render-baseline` -> drift reported, exit 1. (b) renamed it AND added a decoy
so the **count held at exactly 82** -> still caught, naming both the missing and the
unpinned entry. That second case is precisely why the ruling chose a manifest over a
count.

**Scope note.** The drift gate runs only on a FULL gauntlet; a filtered run
(`run-tests-all.sh <filter>`) legitimately executes a subset, so comparing it would be a
false red. A MISSING manifest is itself a FAIL -- without it, ALL GREEN is unfalsifiable.

**Seeding.** Seeded from a tree verified in the same session (gauntlet ALL GREEN,
repo-lint 0 fail), per the ruling's explicit warning not to seed from whatever happens to
be on disk. This is why R15(2) was sequenced LAST of the three.

**Revert.** `git revert <this commit>` and delete `tests/HARNESS-MANIFEST`. The gauntlet
returns to reporting ALL GREEN over whatever harnesses happen to exist.

## 9. LIVE MUTATION -- dc0 v6 plane carve (6 MAAS subnets created)

**What.** `bash scripts/dc-plane-ipam.sh carve-v6 vr1-dc0 --commit` on voffice1.
Created six IPv6 plane subnets in MAAS: `2602:f3e2:f02:10::/64` (provider-public) and
`fd50:840e:74e2:{220,221,230,240,250}::/64` (metal-admin, metal-internal, data-tenant,
storage, replication). Each on the SAME vlan as its v4 twin.

**Why.** R2 RULED dual-stack for both DCs this deployment; measured, MAAS carried ZERO
v6 on any of the 12 DC plane fabrics. The v6 carve is also FORCED SEQUENCING for R4 --
a reserved range cannot be created on a subnet that does not exist.

**Authorisation.** Operator, exact utterance: "Run carve-v6 vr1-dc0 --commit". dc0 only;
dc1 was NOT authorised and was not touched.

**Evidence.** Pre-apply re-verify in the SAME session (G8 precedent) showed the plan
unchanged: planned=6 skipped=1 errors=0. Apply: **6 applied, 1 skipped, 0 errors, every
create READ BACK on its intended vlan**, exit 0. Post-state: subnets 18 -> 24, v6 1 -> 7,
**zero duplicate CIDRs** (proven despite `--commit` running twice). Machines 18 Ready + 2
Deployed, UNCHANGED. dc1 still measures 6 v6 planes absent.

**Blast radius.** Additive MAAS records only. No node, no tfstate, no running service.
The provider GUA VIP `/64` was deliberately not created.

**Revert.**
```
ssh voffice1 'for c in 2602:f3e2:f02:10::/64 fd50:840e:74e2:220::/64 \
    fd50:840e:74e2:221::/64 fd50:840e:74e2:230::/64 fd50:840e:74e2:240::/64 \
    fd50:840e:74e2:250::/64; do
  id=$(maas admin subnets read | python3 -c "import json,sys;print(next((x[\"id\"] for x in json.load(sys.stdin) if x[\"cidr\"]==\"$c\"),\"\"))")
  [ -n "$id" ] && maas admin subnet delete "$id"; done'
```
Read the subnet's occupancy BEFORE deleting -- the 2026-07-21 pod-delete incident
cascaded to 9 machine records because an association check ran too late. These subnets
have no allocations today, but check rather than assume.

**Capture.** `docs/audit/dc0-v6-carve-20260727.txt`.

## 10. LIVE MUTATION -- dc1 v6 carve + D-134 band reservations, both DCs

**What.** Operator-gated ("Process 1,2,3. All approved"), in dependency order:
`carve-v6 vr1-dc1 --commit` (6 subnets), `reserve vr1-dc0 --commit` (13 ranges),
`reserve vr1-dc1 --commit` (12 ranges). Each pre-apply re-verified in the same
session; every write read back.

**Result.** MAAS subnets **18 -> 30**, ipranges **3 -> 28** (25 reserved created;
the 3 pre-existing dynamic ranges untouched). `dc-plane-ipam check` = **pass=24
fail=0 on BOTH DCs**, from a 6/18 baseline. Machines **18 Ready + 2 Deployed,
unchanged throughout**.

**Why it matters.** D-134's band table had existed only as decision prose since
2026-07-23 while MAAS held ZERO reserved ranges. The bands are now artifacts, closing
that "ruled is not built" finding.

**FINDING -- MAAS already reserves the whole low IPv6 block.** Every explicit v6 band
create failed: *"Requested reserved range conflicts with an existing range."* Measured
via `maas admin subnet reserved-ip-ranges`: MAAS auto-reserves `::1`-`::ffff:ffff`
(purpose `reserved`) on every IPv6 subnet, plus `::` per RFC 4291 s2.6.1; allocatable
space starts at `<prefix>:0:1::`. The ruled bands sit entirely inside that, so the write
is impossible AND unnecessary. R4's "v6 bands follow as a second pass" is not executable
in MAAS and needs not be. The tool now VERIFIES coverage instead of writing. Confirmed
identically on ULA and GUA subnets.

**Harness.** T19 asserted a literal string the fix removed, so it was **re-pointed at the
surviving invariant** (bounds are the textual `4-99`, never the hex `4-63`) rather than
deleted -- the standing rule against going green by removing an assertion. 25/25;
gauntlet ALL GREEN (83).

**Still refused, correctly:** dc1's FIP pool. Unset in `lib-net.sh` by design; R4 cannot
close for dc1 until it is ruled. Not inferred from dc0's shape.

**Revert.** Reserved ranges are additive and safe to delete:
`maas admin ipranges read` -> for each id with `type=reserved` and a `10.12.*` start_ip
created today, `maas admin iprange delete <id>`. For the dc1 v6 subnets, use item 9's
shape with the `:3xx::/64` prefixes. **Read occupancy before deleting a subnet** (the
2026-07-21 pod-delete cascade).

**Capture.** `docs/audit/dc-plane-ipam-executed-20260727.txt`.

## 11. dc1 FIP pool RULED and applied -- R4 now complete for both DCs

**What.** Operator ruling (GA-R5), exact utterance: "Rule dc1 FIP pool
10.12.65.0-10.12.67.254". Recorded on the D-134 R4 amendment; `lib-net.sh`'s dc1 arm
now sets `FIP_POOL_START`/`END`; the range was reserved in MAAS (1 applied, read back).

**Validated before recording, not assumed.** Inside `10.12.64.0/22`; **767 addresses,
identical in size to dc0's** `10.12.5.0-10.12.7.254`; no overlap with the D-134 bands.

**Scope discipline.** The ruling narrows dc1's deliberate unset list by exactly ONE pair.
`VIP_PREFIX_*`, `VIP_COUNT_EXPECT`, `KEYSTONE_VIP_DEFAULT` stay unset (R9 rules the VIP
group generated from the overlay); `METAL_INTERNAL_VID`/`IFACE` stay unset (D-133). New
harness guards assert exactly that, so a future edit cannot widen it silently.

**Two stale assertions RE-POINTED, not deleted.** `tests/dc-selector` asserted dc1
*unsets* `FIP_POOL_START`; `tests/dc-plane-ipam` T20 asserted dc1 *refuses* the pool.
Both encoded the pre-ruling state. Each was replaced with a stronger check: the ruled
value, the still-unset guards, and that dc1 plans its OWN pool and never dc0's. That
last one is what proves the tool reads a per-DC value rather than a shared literal.

**Final state, both DCs:** `check` pass=24 fail=0 AND `reserve` planned=0 skipped=19
errors=0 -- converged and idempotent. ipranges 3 -> 29. Machines unchanged.
dc-selector 48 -> 51; gauntlet ALL GREEN (83).

**Revert.** Delete the iprange whose start_ip is `10.12.65.0`, and restore the
`unset FIP_POOL_START FIP_POOL_END` line in `lib-net.sh`'s dc1 arm.

## 12. LIVE -- NetBox apex populated with the D-134 bands and the ruled VIP set

**What.** New `netbox/dc-plane-apex-import.py` (dry by default, write-guarded, read-back
verified), applied to both DCs. Apex: ip-ranges **3 -> 27**, ip-addresses **4 -> 160**
(IPv6 **0 -> 78**), **156 VIP objects**. Read-back 12/12 ranges and 78/78 addresses per
DC, errors=0; idempotent re-run plans zero.

**Why.** D-136 option (D) rules the apex be populated FIRST and the renderer generated
from it. Measured before: the apex held the whole prefix layer but ZERO bands and ZERO
VIPs -- the sub-prefix gap the coupling exists to close.

**Derived, not hardcoded**, except the ruled app->octet map: planes from `lib-net.sh`;
v6 prefixes read from the apex itself; VIP legs = the three plane bases + octet (NOT
`VIP_PREFIX_*`, which lib-net unsets for dc1 per R9); v6 host part = textual octet mirror
per the 2026-07-27 ruling, with the provider leg in the DEDICATED GUA VIP /64.

**vault .61 / designate .62 included** though ruled-but-not-built (R11) -- reserving a
planned address is what an IPAM apex is for, and the renderer will now emit them.

**Correction to D-136.** Its prose says `sandbox-fidelity-check.py` "gates every apex
reconciliation". Measured: that script compares two JSON dumps and touches no NetBox --
it validates the SEEDER against the upstream draft. It does not gate apex writes; read-back
does. The extension is still owed for the sandbox loop, but it was not a blocker here.

**Bug caught by the tool's own refusal.** The live API returns `scope.name` as the display
name ("VR1 DC0") and `scope.slug` as the site key; the repo's dumps normalise `name` to the
slug. Built against the dump, the tool matched zero prefixes live -- and REFUSED rather than
writing nothing silently. Now matches slug-then-name.

**Revert.** Delete the created objects by description prefix: `ip-ranges` whose description
starts `D-134 `, and `ip-addresses` whose description starts `VIP `. Both are additive
records; no live address is affected.

## 13. Node IPv6 acquisition RULED -- MAAS static assignment; step 3 closes

**Ruling.** Operator, exact utterance: "MAAS static assignment is correct". Recorded on
the D-134 R4 amendment. Question as presented offered (a) MAAS static assignment,
(b) DHCPv6, (c) SLAAC/RA.

**It closes work rather than opening it.** The v6 carve already applied is sufficient;
**the rack-bridge v6 legs are NOT needed and were not applied** (`dc-rack-net.sh`'s LEGS
table stays v4-only); `dhcpd6` staying off is correct, not a gap; no plane needs a router
advertising RAs. Mirrors how v4 node addressing already works (D-134 statics).

**Resolves U17.** Its "the rack bridges need v6 too" was a correct observation on the data
path, but under static assignment nothing consumes a rack v6 leg, so the widening does not
follow. The observation held; the conclusion did not.

**Verification owed at Stage-5 first boot.** Nodes are powered off, so that MAAS actually
assigns the v6 statics cannot be observed yet -- the one-time window G17 exists for.
PROPOSED, not adopted: a fourth G17 assertion that a booted node carries a global v6
address from its plane's /64 in the ruled ::100-::200 band.

**STEP 3 COMPLETE.** MAAS, lib-net and the apex all carry the ruled values. No revert entry
here -- this item ruled that a mutation is NOT required; items 9-12 carry the reverts for
what was applied.

## 14. LIVE -- node IPv6 carve, both DCs (108 links)

**What.** `scripts/dc-node-v6-carve.py apply <site> --commit`, dc0 then dc1. 54 links each,
mirroring every node's existing v4 octet into the v6 subnet sharing that link's vlan.

**Result.** IPv6 links **0 -> 108**; IPv4 links **108 unchanged**; **18 Ready unchanged**.
Read-back 54/54 per DC, 0 errors. `dc-node-v6-carve check` PASSES both DCs;
`dc-plane-ipam check` still pass=24 fail=0 on both (no regression).

**Why it matters.** D-101's governing rationale sets *IPv6 unless IPv4 is necessary*, and
the node layer is where the IPv4 sizing constraint actually bites. Until now the apex held
the plan and MAAS held the subnets while nodes had nothing.

**Derived, not tabled.** Site by MAAS tag; interfaces by "already carries v4"; v6 subnet by
shared vlan; host part from the node's own v4 octet, mirrored textually. That rule gets the
D-100 raw provider NIC right without naming it -- `enp2s0` has no v4 link so it received
nothing, while `br-ex` did.

**Revert.** `maas admin interface unlink-subnet <system_id> <iface_id> <link_id>` per link;
or re-run `check` to list what exists. Per-link and reversible; no node powered on, no MAC
touched, nothing re-commissioned (the 2026-07-20 MAC-regeneration incident is why).

**Capture.** `docs/audit/node-v6-carve-executed-20260727.txt`.
