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.
dc-dc-stage5-grounding-audit to mainWhat. 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.
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.
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.
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'.
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.
Full detail in docs/audit/stage5-phase0-20260727.txt.
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.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.opentofu/vr1-dc1-substrate/.terraform.lock.hcl is untracked on main while its dc0 twin IS tracked.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.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.
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.
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.
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.
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.
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.
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.
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.
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.
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.