# 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`.
