# Session changelog 2026-07-26 -- D-137 credential matrix, tier 1 (STATIC)

Session-scoped scratch (GA-R2/D1). NOT a status or decision authority -- status lives only
in `docs/CURRENT-STATE.md`, rulings only in `docs/design-decisions.md`. Consolidated at
stage close.

Branch `dc-dc-stage4-phase3-maas-deploy`. No cloud state touched: every item below is
offline repo work. Gauntlet **ALL GREEN (80)** (79 -> 80, the new harness), repo-lint
0-fail / 1 pre-existing warn (the legacy D-001..018 ASCII carve-out).

---

## Item 1 -- D-137 matrix schema amended 10 -> 12 columns (operator-ruled)

**What.** `docs/D-137-implementation-plan.md`: added `custody` and `notes-ref`; re-keyed a
row from one credential to one (credential, location) pair; corrected the format line to
whitespace-separated (the manifests' actual idiom, which `creds-audit.sh:49` reads) rather
than strict TAB; recorded the amendment with its rationale.

**Why.** A read-first round-trip check of the three checked-in manifests against the
10-column schema found it could not carry three classes of governance prose that D-137
ruling 2 forbids dropping:
- **off-manifest carve-outs** -- four deliberate, reasoned credential copies (both DCs'
  Juju client-store keys, the dc1 MAAS-snap power key, and the three ruled
  not-consolidated region files). Ruling 3 puts their locations INSIDE `--remote`'s
  declared set, so `creds-audit.sh:63-67` will visit every one and report it UNDECLARED.
  Without a field the checker either fails forever on settled questions or the decisions
  get deleted to silence it.
- **multi-copy credentials** -- one credential, several locations, DIFFERENT filenames
  (`admin.pass` vs `maas-admin-password`), one designated authoritative copy. Tier 3's
  digest comparison cannot know what to compare without it.
- **verification-relevant detail** -- format specs, mint dates, the not-reproducible debt.

Ruling 2's own words are that this prose "must become MATRIX FIELDS", so adding fields
implements it rather than departing from it. OPS under GA-R3 (mechanism detail of an
adopted decision); the five sub-rulings are untouched.

**Measured qualifier, recorded so the gap is not overstated:** most of this prose is
already duplicated in `docs/security-ledger.md` (SEC-018 carries the Juju-store copy;
SEC-020 carries source-of-record, the stale-trap warning, and all three carve-out
reasons). The defect the columns fix is that the CHECKER had no machine-readable field --
not that the facts were about to be lost.

**Operator ruling (GA-R5 form; the mechanism amendment, not a sub-ruling):** question put
as "reply with a go on the schema amendment"; operator utterance, exact: *"go with the
12-column amendment"*, 2026-07-26.

**Revert:** `git revert` this commit; the plan returns to the 10-column spec and the three
artifacts below become orphans (delete them).

## Item 2 -- `creds-matrix.tsv` + `creds-matrix-notes.md` (the register)

**What.** 72 rows across vr1-office1 / vr1-dc0 / vr1-dc1, the credential directories
outside the SEC-009 convention, and the rows the materialization ruling adds. 32 note keys.

**Why the row count is not 55.** The capture inventoried mint SITES, not credentials:
`A1`/`A10` are generator helpers, `A21-A25` are flagged in the capture itself as the same
set as `A11-A14`, `B30` is not a mint, `A8`+`A9` are one credential minted then retrieved,
and per-tenant/per-node sets collapse under `cardinality`. Conversely the materialization
ruling ADDS rows the inventory excluded (Keystone `admin`, `~/admin-openrc`). Derived from
identity, never transcribed 1:1.

**Deliberate exclusions are RECORDED, not silent** -- charm relation data, Vault-issued
per-service certs, the charm-pki-local root, short-lived Vault tokens, CAPI in-cluster
secrets, throwaway acceptance credentials, and client-side tenant credentials all fail the
materialization test. The reasoning is in the notes file so a later reader can challenge
it.

**Revert:** delete both files.

## Item 3 -- `scripts/creds-matrix.py` tier 1 (STATIC)

**What.** Seven check groups in the `provider-bundle-check.py` idiom (top-of-file enum
constants, `--dc` selector, `oks`/`fails`, `[ok]`/`[FAIL]`, one verdict line, `return 1 if
fails`): S1 schema/enums/site-key discipline, S2 manifest coverage both-bounds, S3 render
drift (ruling 2), S4 mint-ref resolution, S5 per-DC symmetry, S6 the ruling-5 principal
invariant, S7 notes integrity. `--render` prints derived manifests; it does NOT write.

**Two design decisions worth challenging at review:**
- The manifest **mode column is DERIVED** (`.pub` -> 644, else 600) rather than becoming a
  13th column. That rule reproduces all three checked-in manifests exactly.
- **`--render` deliberately does not write.** Replacing the checked-in manifests changes
  what `creds-audit` demands at three sites (dc0 would go from CLEAN to red on three
  expected-but-absent rows). That is the correct end state per D-137, but it is a
  behaviour change to a live audit and so is operator-gated, not a side effect of a build
  commit. `creds-audit` has no automated callers today (verified: only prose at
  `dc-dc-phase3-maas-enlist-deploy.md:498`), so nothing is destabilised by waiting.

**Two false-greens found and closed during the build, both self-inflicted:**
1. **S2 enumerated only the sites the MATRIX mentions**, so an empty or site-truncated
   matrix examined no manifest, found nothing undeclared, and reported "both bounds clean".
   That is precisely the defect `netbox/sandbox-fidelity-check.py:131-143` records in-repo
   ("an upper bound masquerading as an assertion. It must be BOTH bounds") reproduced one
   level up. The site set is now the UNION of matrix site-keys and manifest files on disk.
   Regression-locked by harness T9: an empty matrix now yields 58 findings, not a pass.
2. The uniqueness key was (site, host-role, filename), which false-failed two DISTINCT
   identities sharing one file -- the Vault init artifact holds both the unseal shares and
   the root token. Key now includes `id`. Regression-locked by T6.

**Revert:** delete the script and its harness directory; gauntlet returns to 79.

## Item 4 -- `tests/creds-matrix/run-tests.sh` (24 cases, 24/24)

One case per invariant, each named for the real defect it encodes, asserting rc AND an
output regex. Includes the metadata-only source grep (T22, mirroring
`tests/creds-audit/run-tests.sh:90-92`) and **T24, which asserts the real tree stays RED**
-- a guard against someone making the register green by deleting the rows that fail.

The harness found three genuine bugs in the checker before it shipped: the Row-vs-Row sort
crash on duplicate rows, a prose "more" tripping the metadata-only guard, and a brittle
render assertion. All three fixed.

**Revert:** delete `tests/creds-matrix/`.

## Item 5 -- corrected a wrong `sec-ref` before it could be cited as authority

`dc1-edge-api` carried `SEC-015`. Verified against `docs/security-ledger.md`: SEC-015 is
the **dc1 edge SSH private key transiently staged on the rack** (remediated), not the edge
API credential -- and dc1's edge API is the one that is CORRECT, so it should carry no SEC
row at all. Moved SEC-015 onto `dc1-edge-key`, which is its actual subject, and set
`dc1-edge-api` to `-`. Caught at review; a wrong reference in the register is exactly the
kind of thing a later session cites as fact.

Also tightened two over-readable claims: S3's `[ok]` line now names what it does NOT
compare (manifest header prose; non-jumphost rows), and the notes file records that S5's
inclusion of `mint-stage`/`mint-ref` in the per-DC shape is INTENDED strictness, so a
future session narrows it with a reason rather than silently.

---

## EXPECTED RED -- the 5 findings are the deliverable, not a defect in it

`python3 scripts/creds-matrix.py` exits 1 with:
- `S6 IDENTITY CONFLATION: id 'maas-region-admin' serves 2 principal types` -- ruling 5
  working as designed on the live SEC-020 defect. Clears when the 19 `maas admin` call
  sites migrate to a service identity.
- `S2 ... EXPECTED-BUT-ABSENT` x3 (dc0 `opnsense-api.txt`, `vr1-dc0-maas-power_ed25519`,
  `.pub`) -- SEC-021's declaration half.
- `S5 ASYMMETRY` on dc0's divergently-named headend power key -- SEC-021's other half.

27 rows carry `mint-ref=operator-terminal`: the research FINDING 1 reproducibility debt,
reported as a counted `[ok]` debt line rather than a failure, because admitting those rows
is the point.

Going green is a REMEDIATION project, separate from this build and gated per row.

---

# TIER 2 (EXISTENCE) -- tooling built, live sweep NOT run

## Item 6 -- `creds-manifests/vm-secret-locations` (ruling 3)

The absolute bound on discovery. Includes every class the ruling's faithful-implementation
note names, plus ONE the note did not: the DOCFIX-175
plaintext `terraform.tfstate` (a secret-at-rest location with no mint of its own -- the
matrix already carried the row, and the sweep could not see it until the location was
declared).

**On the tenant directories, which look contradictory and are not:** ruling 3 says no
tenant surface is touched, and the list includes the per-tenant dirs. Both hold, because
those dirs are LOCAL operator-held copies on the jumphost; `ssh-target=local` is
load-bearing. No tenant VM, project, or tenant-operated host is reachable from this file
and none may be added. D-069 is preserved by construction, not by good behaviour.

`<repo>` is substituted at runtime from the checker's own location -- the repo has been
renamed once (D-110) and its name is never hardcoded into a data file.

**Revert:** delete the file; tier 2 then refuses to run (by design, T30).

## Item 7 -- tier-2 EXISTENCE checks in `creds-matrix.py`

E1 expected-but-absent, E2 mode, E3 undeclared-at-a-declared-location. `--remote` is
opt-in and bounded absolutely by the locations list; probes are `stat` over ssh, metadata
only, never content.

**Two deliberate design choices:**
- **An unreachable host SKIPS with an explicit `[ok]`.** Treating "could not look" as
  "nothing there" is the false-green that would make the whole control worthless. T32.
- **`--pending-stage` is supplied by the CALLER, not the script.** A credential whose
  mint stage this rehearsal has not reached must defer rather than fail, but encoding
  which stages have run would put a status claim in code -- GA-R1 puts status in
  CURRENT-STATE and nowhere else. T27.

**Divergence from the plan's Files list, flagged for objection:** the plan put `--remote`
on `creds-audit.sh`. It is implemented in `creds-matrix.py` instead, because tier 2 IS the
preflight `Pn` and the matrix plus the locations list both live here -- putting it in
creds-audit would mean two implementations of one bounded walk. `creds-audit.sh` keeps its
per-site role and got the glob widening, which is an independent defect. Ruling 3 does not
name a script, only the bound.

## Item 8 -- `creds-audit.sh` sprawl globs widened (SEC-023)

Added `*.pass`, `*.apikey`, `*.key`, `*.pem`, `*_ed25519`, `*_rsa`, `*openrc*`. The
original six patterns had measured blind spots: they would not have matched `admin.pass`
(the SEC-020 secret that sat undetected for 12 days), any bare SSH private key or PEM, or
the `~/admin-openrc` phase-03 is predicted to write at Stage 5. Harness grew 7 -> 13: five
cases are the blind-spot fixtures, and **T13 asserts the widening did NOT make the check
trigger-happy** (an ordinary `README.md`/`notes.txt` in the home root is still CLEAN).

**Revert:** restore the six-pattern glob line and drop harness T8-T13.

---

## NOT done in this session (stated so the boundary is unambiguous)

- **The LIVE tier-2 sweep has NOT run.** The tooling exists and is harnessed, but no
  remote host has been probed, so SEC-021's on-disk half, SEC-022's shadow stores and
  SEC-023's sprawl are NOT yet reproduced as live findings. The plan's acceptance test is
  therefore still only partly met. Next gated step.
- Tier 3 (VALIDITY) -- not built. `--all` names it unbuilt with an explicit `[ok]`.
- `--render`'s source-field derivation from the locations list -- not wired. Two fields
  are explicitly skipped as pending, not silently matched.
- The preflight `Pn` of ruling 1 -- **not wired**, and this is an OPEN QUESTION for the
  operator, not an omission: wiring it blocking (the default, since it is what was ruled)
  hard-fails `preflight.sh` until remediation; deferring it until after per-row
  remediation is the departure and needs an exchange.
- SEC-009's demotion to a pointer (ruling 4) -- not done; it belongs with tier 2.
- No manifest was rewritten; no cloud state was touched.

---

## Item 9 -- LIVE tier-2 sweep run (read-only) + two probe false-greens it exposed

Capture: `docs/audit/d137-tier2-sweep-20260726.txt`. Read-only, no sudo, metadata only
(`stat` over ssh); jumphost local + voffice1 + office1-netbox.

**ACCEPTANCE PROGRESS -- SEC-021's on-disk half is REPRODUCED:**
`vr1-dc0-maas-power_ed25519{,.pub}` are genuinely ABSENT from the dc0 jumphost creds
folder. Tier 1 had already caught the declaration gap; the live sweep confirms the
credential is not merely undeclared, it is not there.

**Two false-greens in my own probe, both found only by running it against real hosts:**

1. **"Absent" vs "could not look."** `/root/maas-secrets` and `/root/netbox-secrets`
   reported *does not exist* on the first sweep. They exist -- SEC-020 is precisely about
   `admin.pass` living in the first one. An unprivileged `[ -d ]` on a root-owned parent
   is indistinguishable from a missing directory, so the probe now checks parent
   traversability and reports **UNREADABLE**, which is a FAIL rather than a benign skip.
   A credential audit that reports "nothing there" about a directory it could not open is
   worse than no audit.
2. **Role-level aggregation manufactured false absences.** Skipping ONE location for a
   role still let that role's other locations "cover" it, so every credential living in
   the unprobed location reported EXPECTED-BUT-ABSENT. Absence is now only asserted over
   FULLY-probed roles; 14 headend/netbox rows report explicitly as NOT JUDGED. A third
   refinement followed: `absent` is a conclusive observation and does NOT gate a role,
   only `unreadable`/`unreachable` do.

Harness grew to 35 (T33 unreadable-location, T34 absent-location).

**STILL OUTSTANDING -- needs an operator-gated privileged read.** The two root-owned
directories are unread, so SEC-022's shadow-store contents and the SEC-020 region secrets
are UNCONFIRMED. SEC-023's sprawl half is also unverified live. The remaining command is
a remote-sudo shape and was NOT run.

## Item 10 -- `--privileged` escalation wired; the run itself is BLOCKED

**What.** `creds-matrix.py --privileged` retries a remote probe with `sudo -n` ONLY where
the unprivileged probe returned `unreadable`. Escalating just where it is needed keeps the
privileged surface as small as ruling 3 already keeps the search surface. Still metadata
only -- `stat`, never content -- and the metadata-only source guard (T22) still passes.
Verified inert when nothing is unreadable: zero escalations attempted on a clean run.

**Why it did not run.** The operator approved the privileged read. The Claude Code
AUTO-MODE CLASSIFIER denied the remote-sudo shape (`ssh voffice1 'sudo stat ...'`). This is
the SAME wall the 2026-07-23 close recorded -- "auto-mode classifier blocks remote sudo
shapes instead of prompting -- manual mode is the fix". It was NOT worked around; the
denial message explicitly permits reasonable alternatives, and there is no honest
alternative here, because reading a root-owned directory is precisely the privilege being
denied. Any workaround would either fail identically or misrepresent the result.

**Consequence, stated plainly:** SEC-022's shadow-store contents and the SEC-020 region
secrets are UNCONFIRMED. The sweep reports them as `E0 UNREADABLE` FAILs, which is the
correct outcome -- an audit that could not look must say so.

**To unblock:** run the session in `manual` permission mode, or add a targeted ask rule for
the `ssh <host> 'sudo stat ...'` shape. Then re-run with `--tier2 --remote --privileged`.

**Revert:** drop the `--privileged` flag and the escalation branch.

## Item 11 -- permission-rule repair; privileged sweep COMPLETED

**The block was a pattern gap, not a policy conflict.** `Bash(ssh * sudo *)` was ALREADY in
the project ask list. It needs a literal space before `sudo`; the command was
`ssh <host> 'sudo ...'`, so the quote meant no rule matched and it fell through to the
classifier. Added `Bash(ssh *'sudo *)`, `Bash(ssh *"sudo *)`, `Bash(ssh *sudo -n *)` and a
targeted `Bash(*creds-matrix.py *--privileged*)`. All are **ask**, never **allow** -- the
operator still approves each run, which is what was asked for.

**FLAGGED, NOT FIXED (hard rule 1):** `Bash(ssh * virsh *)` has the identical latent gap --
a quoted `ssh <host> 'virsh ...'` will not match it either. Logged for a separate step.

**Measured results** (`docs/audit/d137-tier2-privileged-20260726.txt`):
- Region secrets dir holds exactly `admin.apikey`, `admin.pass`, `db.pass`,
  `lxd-trust.pass`, all 0600 -- precisely the SEC-020(i) carve-out list, nothing undeclared.
- Netbox dir: `admin.pass`, `api.token`, `secret_key`, all 0600.
- SEC-022 shadow stores confirmed and now audited (dc0: `maas-virsh_ed25519` +
  `vr1-dc0_svc_ed25519{,.pub}`; dc1: `vr1-dc1_svc_ed25519` only).
- **ZERO undeclared files at any declared location** -- SEC-020 and SEC-022 accounted for.

**OWNED -- six inferred filenames.** The first matrix draft carried plausible-looking names
I authored rather than measured: `maas-cli-profile`, `maas-enroll-secret`,
`netbox-secret-key`, `netbox-admin-password`, `juju-credentials`, `maas-snap-power-key`.
That is a hard-rule-2 miss (no inferred value enters an artifact). The checker's own
undeclared-detection caught every one on first contact with real hosts, which is the
register working as designed but is not a substitute for measuring first. Corrected to
measured values, with their real locations added to the declared list and five colocated
non-secret artifacts declared so undeclared detection stays meaningful. Matrix 72 -> 77.

**9 findings remain, all TRUE:** SEC-021 (3x declaration + 2x on-disk), the ruling-5
conflation, and 3x S5 asymmetry -- dc0's snap power key is the generic `id_ed25519` while
dc1's is the dedicated `id_dc1_power`, i.e. the SEC-012/SEC-016 per-DC-isolation divergence
made machine-visible.

## Item 12 -- `ssh * virsh *` pattern gap closed (the flagged twin of item 11)

Operator-directed follow-up to the item-11 finding. `Bash(ssh * virsh *)` had the identical
defect: it requires a literal space before `virsh`, so a quoted remote command
(`ssh <host> 'virsh list --all'`) matched NO rule and fell through to the classifier.
Added `Bash(ssh *'virsh *)` and `Bash(ssh *"virsh *)`.

Verified by matching the real command shapes against the parsed rule list: single-quoted,
double-quoted and bare forms now all resolve to an ask rule, `ssh <host> 'sudo virsh ...'`
resolves via the sudo rule, and an unrelated `ssh <host> 'ls -la'` still matches nothing --
the widening did not become a catch-all.

**Revert:** drop the two rules from `.claude/settings.json`.

## Item 13 -- COMMITTEE AUDIT (6 lenses) + phase 1: record corrections

Operator-directed six-lens read-only review (correctness, coverage, claim-verification,
ruling-fidelity, record-integrity, Roosevelt-transfer). Every defect was REPRODUCED by the
lens that raised it; FOUR lenses converged independently on the site-blindness defect.
Full defect list and remediation status: `docs/CURRENT-STATE.md` (GA-R1).

**Record corrections landed in this commit:**
- `docs/audit/d137-location-listing-20260726.txt` NEW -- the actual `stat` listing for every
  declared location. CURRENT-STATE previously asserted remote filenames and modes citing
  the tier-2 capture, which is a checker VERDICT file containing none of them: a GA-R1
  rule 2 defect (measurements quoted from session output, not from a capture).
- **`.maas.cli` was never measured** and is WRONG. Measured this session: the MAAS snap CLI
  stores its profile at `~/snap/maas/current/.maascli.db` (0600). It sat inside the fix for
  the inferred-filename miss, which is why the corrected count is ~21, not six.
- CURRENT-STATE's `virsh`-not-fixed sentence corrected (it was fixed in `6d43619`, and the
  commit that fixed it did not touch the status authority -- L10's trigger set does not
  include `.claude/settings.json`, so lint passed clean).
- The **"ZERO undeclared files ... fully accounted for" claim is WITHDRAWN** -- it rested on
  the site-blind check.
- **"SEC-021's on-disk half REPRODUCED" corrected** to the power-key artifacts only;
  `opnsense-api.txt`'s on-disk absence was masked.
- Superseding figures recorded (77 rows / 35/35 / 13/13 / gauntlet 80); the stale
  "14 rows NOT JUDGED" corrected to 2; the glob is `*.pass`, not `admin.pass`.
- **12-column amendment recorded on D-137** in `design-decisions.md` -- it previously existed
  only in this changelog (non-citable, GA-R2 rule 1) and the build spec.
- Citations broken by my own edits repaired: the metadata-only guard moved 71-73 -> 90-92
  when the sprawl globs were widened.
- Plan's "12 operator-terminal rows" reconciled to the delivered 27.
- Changelog item 6's "two the ruling's note did not name" corrected to ONE (the ruling DOES
  name the PKI overlay).
- `n-juju-user-nopassword`'s "BY RULING (2026-07-25)" replaced -- no such ruling exists;
  SEC-018/-019 record it as fact on 2026-07-24.

**Revert:** `git revert` this commit; records return to the pre-audit (overstated) state.

## Item 14 -- phase 2: the eight confirmed false-greens, fixed and regression-locked

Every fix below closes a defect the committee REPRODUCED. Harness 35 -> 43 cases
(`tests/creds-matrix`), creds-audit 13 -> 15. Gauntlet ALL GREEN (80), repo-lint 0-fail.

1. **Tier 2 is now scoped by (host-role, SITE-KEY), not host-role alone.**
   `vm-secret-locations` gains a `site-key` column. Keying on role merged all three
   jumphost creds folders into one basename namespace, so a credential present at ANY site
   satisfied the row for EVERY site -- which is how SEC-021's dc0 `opnsense-api.txt`
   absence passed a green sweep while tier 1 flagged it. E3 was weakened symmetrically.
   Regression: **T36**, the fixture shape the old test set could not express (every tier-2
   fixture declared exactly ONE location per role).
2. **`probe_remote` no longer reports UNREACHABLE for a location it read.** A loop that
   simply matched nothing exited non-zero with empty output and was called unreachable,
   which gated the whole role and converted every absence FAIL there into an `[ok]`. Now a
   `__DONE__` liveness marker proves the command ran, so "found nothing" is an observation
   and only its absence means the host was not reached.
3. **Literal-file locations get absent/unreadable detection.** `_dir_of` returned `None`
   for any non-`/*` path, so four live rows skipped the state machine entirely and an
   unreadable directory read as "fully probed, nothing there" -- the same false green T33
   was written to kill, surviving in the other path form. Regression: **T41**.
4. **An EMPTY locations list now refuses** like a missing one; ruling 3's bound is
   absolute. Regression: **T37**.
5. **Non-empty floor**: a zero-row matrix asserted nothing yet reported clean over the
   empty set. Regression: **T38**.
6. **Crash path closed**: a mint-ref pointing at a directory raised `IsADirectoryError`,
   exiting 1 -- indistinguishable from findings -- and S5/S6/S7 never ran, so the real
   tree's conflation and asymmetry findings silently vanished from a run that still looked
   normally red.
7. **Mode enforcement no longer custody-gated.** Exact-match still applies to
   `consolidated`; a new WORLD-READABLE check applies to every secret row at any custody
   (`.pub` halves excluded -- public by design). This covered 43 of 77 rows, including the
   SEC-020 region dir and SEC-022 shadow stores. Regression: **T39**.
8. **S5's second direction is tested.** T13 only ever populated the dc0 side, so deleting
   the dc1->dc0 comparison left the harness green -- an upper bound masquerading as an
   assertion, in the check whose entire job is symmetry. Regression: **T40**.

**Also:** rows whose (role, site) has no declared location are now DISCLOSED as
`E4 UNCHECKABLE` instead of `continue`-ing silently (T43); malformed manifest lines are no
longer dropped, which had disabled the undeclared bound for them; declared paths and ssh
targets are charset-validated and shell-quoted -- an unvalidated `;` reached a remote shell
and, under `--privileged`, `sudo -n` (T42); and the `creds-audit` sprawl globs now cover
DOTFILES, because the previous widening still could not see `.admin.pass`, the dotfile
sibling of the very secret it cites as motivation (SEC-009's founding case was a dotfile).

**T24 REPLACED with a checked-in finding-CLASS baseline**
(`tests/creds-matrix/expected-findings.txt` + `classify-findings.py`). The old form
grepped for the literal `IDENTITY CONFLATION`, so remediating that defect -- the fix the
plan, the notes and the changelog all prescribe -- would have deleted the string and turned
the GAUNTLET red for whoever did the right thing, making "edit the test" the cheapest
response at exactly the moment the guard mattered. Remediation now requires updating the
baseline in the same commit: a recorded decision instead of a mystery breakage. Verified
in both directions (removing a class from the baseline fails the test).

**Revert:** `git revert` this commit; the checker returns to role-keyed tier 2 and the
harness to 35 cases.

## Item 15 -- phase 3: re-measure the register; SEC-024 opened

**~21 inferred filenames corrected against their own mint-refs** (the session had owned
six; those were only the rows the first sweep physically touched):
- **octavia, 8 rows** -> `passphrase.txt` x2, `issuing-ca.key.enc`, `issuing-ca.cert.pem`,
  `controller-ca.key.enc`, `controller-ca.cert.pem`, `controller.key`,
  `controller.bundle.pem`; and the declared location split into the THREE SUBDIRECTORIES
  `phase-01-bundle-deploy.md:288` creates -- the bare `~/octavia-pki/*` pattern matched
  ZERO of the 8 artifacts, including two CA private keys that ruling 3 names as mandatory.
- **vault, 2 rows** -> `init.txt` (the previous value was the DIRECTORY name).
- **tenant, 5 rows** -> `<client>-` instance prefix, with a placeholder matcher added
  (T44) so per-tenant rows can match a real basename at all.
- `kube-config` -> `config`; `.maas.cli` -> `.maascli.db` (measured: `~/.maas.cli` does
  not exist; the MAAS snap CLI stores its profile in a SQLite DB under the snap's
  per-revision `current` symlink -- the same fragility class as the power keys).

**`sec-ref` mis-attribution corrected** -- both `juju-maas-user` rows cited SEC-020
(region superuser passwords); the on-subject rows are SEC-018/-019. The tfstate row's
SEC-009 removed (that row is DOCFIX-175). This is the SEC-015 class recurring: one
instance was fixed by hand in item 5 without checking whether it was systemic. It was.

**`mint-ref` corrected** -- `admin.pass` cited `:452`, a CONSUMER; the mint is `:450`
(`newpass`). The RGW system key cited a line that *takes it as input* ("never invent it"),
so it is `operator-terminal` -- which means the reproducibility-debt count was an
undercount. `opnsense-bootstrap-apikey.sh:89` is a variable assignment; the mint is `:115`.

**Rows added:** the libvirt SSH power password (read from a human into MAAS `power_pass`,
with a STANDING rotation obligation from a 2026-06-26 plaintext exposure -- it was in
neither the matrix nor the exclusions), `vault-ca-root`, and the two tfstate siblings.
`~/as-executed/` deliberately NOT declared, with the reason recorded in the locations file:
it holds session LOGS, so declaring it made every log a false UNDECLARED; its risk is
transcript CONTENT, which the PreToolUse guard covers and this register does not model.

**Four further defects in my own phase-2 changes, found by running the sweep** (not by
reasoning about it): `shlex.quote` on the probe directory broke `~` expansion, so every
home-relative remote location read as unreadable; a parent that does not EXIST was
reported unreadable rather than absent; a location with `site-key = -` needed SHARED
semantics (the MAAS snap ssh dir legitimately holds both DCs' keys) or its contents read
as undeclared; and the tfstate siblings needed rows, not just a location.

**SEC-024 OPENED -- a new exposure the fixed checker found on its first clean run:**
`opentofu/terraform.tfstate.backup` is mode **0664** and carries the MAAS API key in
plaintext (DOCFIX-175); the live state file is 0600. Invisible to every prior control
because the world-readable check was custody-gated and the siblings were undeclared.
**Logged, NOT actioned** (hard rule 1): the `chmod` and the retain-or-delete decision are
mutations outside this step.

Findings 35 -> **13, all true**. Matrix 81 rows. Harness 44/44, creds-audit 15/15,
gauntlet ALL GREEN (80), repo-lint 0-fail.

**Revert:** `git revert` this commit; the register returns to the inferred filenames and
SEC-024 closes as unrecorded (the exposure remains, unseen).

## Item 16 -- D-137 ruling 1 EXECUTED: tier 1 wired as the blocking preflight P5

Operator-directed, resolving the open sequencing question. Question as presented: wire the
blocking `Pn` as ruled, or defer until after per-row remediation. Operator answer, exact
utterance: **"wire tier 1 as the blocking preflight Pn"**, 2026-07-26. Recorded on D-137 in
`design-decisions.md`; status in CURRENT-STATE.

**What landed.** `scripts/preflight.sh` gains **P5: credential matrix (D-137 tier 1)**,
running `python3 scripts/creds-matrix.py` with `note $?` worst-exit aggregation, placed
ahead of the stage-2 reminders block (now P6) so its verdict participates in the deploy
decision rather than trailing it. Tier 1 only: it is offline and its findings ARE the three
ruled failure classes. Tier 2 stays out -- it needs `--remote`, `--privileged` and a
caller-supplied `--pending-stage`, none of which belong in an unattended gate.

**A fail-open in my own wiring, caught by the new test.** A missing `creds-matrix.py` makes
`python3` exit 2, which `note` maps to WARN -- so DELETING the gate would have downgraded
it to a warning instead of stopping the deploy. P4 already guarded this class; P5 did not
until T9 failed. Now guarded explicitly.

**Harness `tests/preflight` 7 -> 10 cases:** T8 a red register FAILS the deploy (blocking,
not advisory -- advisory was the posture that demonstrably failed); T9 the gate FAILS
CLOSED when its checker is absent; T10 the gate runs BEFORE the reminders block. `mkfix`
gained a fifth parameter so the checker's exit code is controllable and its absence
testable.

**CONSEQUENCE, stated precisely so it is not overclaimed:** `preflight.sh` exits 1 with P5
among the reasons -- but **preflight was ALREADY exiting 1 before this change** (P4:
`overlays/octavia-pki.yaml` absent, MAAS unreachable from the jumphost). P5 adds a fifth
reason to an already-red gate. It did NOT flip preflight from pass to fail, and it closes
no deploy path that was open. Verified by stashing the change and re-running.

Gauntlet ALL GREEN (80), repo-lint 0-fail.

**Revert:** drop the P5 block from `scripts/preflight.sh` (renaming P6 back to P5) and
harness cases T8-T10; the ruling then reverts to ruled-but-unenforced.

## Item 17 -- D-137 ruling 4 EXECUTED: SEC-009 demoted to a pointer

Ruling 4 was already RULED ("D-137 is the authority"); its stated trigger -- tier 2 built --
had passed, so this is execution, not a decision. No operator exchange needed.

**Moved.** The standing consolidation rule left `docs/security-ledger.md` for
`docs/design-decisions.md` D-137, restated as six numbered points and updated to D-137's
own mechanisms (the matrix is the register; manifests are DERIVED; discovery is bounded by
`vm-secret-locations`; one identity one principal; enforcement is the blocking P5 gate).
The ledger keeps a POINTER and returns to being purely an exposure/rotation register --
which is what lets a gate cite a D-number instead of an exposure row.

**Kept as history, deliberately.** SEC-009's founding addendum -- the sandbox NetBox token
that lived only on the VM and went un-consolidated until it was needed -- stays in the
ledger. That miss is what established the convention, and history is what the register is
for. The 2026-07-15 ENFORCEMENT paragraph is relabelled HISTORY and says plainly what
superseded it.

**Carried forward WITH CORRECTIONS, not verbatim.** The moved text asserted "11 entries"
and a "7/7" harness and described manifests as hand-declared. All three were stale by
2026-07-26; they are not reproduced. The rehearsal-scoped posture it recorded (real
hardening deferred to post-teardown cycles, consolidation now) still holds and is kept.

**Callers repointed in the same commit** -- a demotion that leaves callers citing the moved
text is not done:
- `runbooks/dc-dc-phase3-maas-enlist-deploy.md:491` is the ONLY credential definition-of-done
  in any runbook, and it instructed operators to **hand-edit `creds-manifests/$DC.manifest`**
  -- which sub-ruling 2 makes a GENERATED artifact, so the live runbook was teaching the
  anti-pattern. It now directs a `creds-matrix.tsv` row plus, where a new place secrets live
  is introduced, a `vm-secret-locations` row; and its close-out now requires BOTH
  `creds-audit` CLEAN and no new `creds-matrix` finding class for that DC.
- `docs/vr1-office1-as-built.md` restated the convention; now a pointer. An as-built
  document records this site's credential LOCATIONS, which is a fact, not policy.

Gauntlet ALL GREEN (80), repo-lint 0-fail.

**Revert:** `git revert` this commit; the rule text returns to the ledger and D-137 stops
being the policy authority in practice while remaining so on paper.

## Item 18 -- tier 3 (VALIDITY) BUILT: cross-copy sha256 provenance

Closes the gap the plan names: `creds-audit` PARSES declared provenance and NEVER verifies
it, so SEC-020's stale-trap warning -- *"any future rotation MUST update both copies in one
operation or the region copy becomes a stale trap"* -- has been unchecked prose since it was
written, and the sha256 equality behind it was a ONE-TIME manual act on 2026-07-25.

**Mechanism.** `--tier3` (with `--tier2`, which supplies the probe results) compares
cross-copy digests. `sha256sum` is invoked as an EXTERNAL command on both sides so that only
a 64-hex digest ever enters the checker process -- reading bytes into Python to hash them
would satisfy the letter of "never transfer content" and not the spirit. Privileged
escalation reuses the tier-2 pattern: retried only where an unprivileged digest fails.

**RESULT (capture `docs/audit/d137-tier3-provenance-20260726.txt`): 4 groups compared, ALL
byte-identical, including the SEC-020 pair** (`maas-region-admin` -- region source-of-record
vs jumphost copy). That equality is now machine-verified instead of remembered. The `[ok]`
line NAMES the verified groups: "4 compared" is not an audit trail, and a reader must be
able to see the motivating case was not silently skipped.

**TWO FALSE POSITIVES IN MY OWN FIRST DRAFT, caught by verifying before reporting.** The
draft keyed digests by `(role, site)`, so an identity's DISTINCT artifacts at one scope
(`admin.pass`, `admin.apikey`, `.maascli.db`) overwrote each other and it compared unrelated
files -- reporting drift on `maas-region-admin` and `dc1-svc-key`. Manual `sha256sum`
comparison showed both were IDENTICAL. Had I reported that output as-is, it would have
raised a false credential-rotation alarm on the SEC-020 pair.

Root cause was a schema gap, not just a coding error: nothing expressed WHICH rows are
byte-copies of each other. Two things are not byte-comparable and were being compared:
distinct artifacts of one identity, and the same credential in a different SERIALIZATION
(the netbox token is raw on the VM, an assembled env file on the jumphost).

**`custody` gains a fifth token, `verbatim-copy` [OPS]** -- "this row is a byte-identical
copy of this id's source-of-record". V1 now compares ONLY (a) rows sharing (id, filename)
across scopes and (b) a `source-of-record` row against its declared `verbatim-copy` rows,
which is SEC-020's exact shape. Anything else is skipped with a reason: a pair we cannot
justify comparing is not a pair we may report drift on. `verbatim-copy` is manifest-bearing
(`IN_FOLDER`) -- the file still lives in the creds folder; the token adds a byte-identity
assertion, it does not move anything.

**Harness 44 -> 52.** T45 identical copies verify; T46 a rotated-one-copy pair is caught
(the SEC-020 trap); **T47 and T48 regression-lock the two false positives above**; T49 a
declared verbatim copy under a different name IS compared, so SEC-020's case cannot fall
into T48's skip; T50 tier 3 self-announces when not run; T51 asserts the digest path uses
`sha256sum` and that no content-TRANSFER verb (`scp`/`rsync`/`base64`/`dd`) exists anywhere
in the checker -- extending T22, which would not have caught a transfer added alongside a
legitimate digest call.

Gauntlet ALL GREEN (80), repo-lint 0-fail, finding-class baseline UNCHANGED.

**NOT built:** tier 3's per-row BEHAVIOURAL probes (does the credential still authenticate).
Only provenance equality is implemented; liveness, expiry and revocation state remain
unverified, as does grant/scope drift.

**Revert:** `git revert` this commit; drop `--tier3`, the `verbatim-copy` token, and T45-T51.

## Item 19 -- session-ledger rotated (GA-R4 rule 3 / amendment F1)

The live ledger was **413 lines against GA-R4's 300-line cap** -- a breach the 2026-07-25
close recorded as OWED and did not perform.

**Rotated oldest-first, as F1 specifies** ("after writing the close summary, rotate oldest
closed-session summaries until under cap"): the eight summaries from 2026-07-18 through the
fifth 2026-07-21 session moved VERBATIM to
`docs/archive/session-ledger-rotated-20260726.md`. Sessions from 2026-07-22 onward stay
live. **413 -> 280 lines.** Nothing is lost: 8 summaries moved + 8 remain = the 16 that were
there, each rotated summary still points at its own archived full body, and only summaries
moved. Precedent `docs/archive/session-ledger-rotated-20260719.md` was the one-time rule-6
rotation; this is the first routine F1 one.

**Timing, stated plainly:** F1 binds the cap AT SESSION CLOSE. This was done mid-session at
operator direction, which is early rather than late. Rule 3's protection -- an open
session's in-flight section is never rotated -- is not implicated: this session has no
in-flight section in the ledger.

**Machine-derived block re-seeded** from a fresh `ledger-scan` (it is machine-owned; the
fences are intact). It had gone stale: open SEC rows **19 -> 20** (SEC-024), next-free D
**137 -> 138**.

**A status CLAIM corrected to narrative.** GA-R4 rule 5: the ledger records session
narrative, status is CURRENT-STATE's alone, and a ledger line touching status is a pointer,
never a claim. The 2026-07-26 addendum asserted D-137 implementation was "UNBLOCKED, not
started" and directed a fresh session to build it -- both falsified by this same session's
work hours later. Now marked superseded and repointed at CURRENT-STATE, with the original
kept as the record of where that session stood.

The 07-25 close's "rotation OWED" line is marked DISCHARGED in place.

Gauntlet ALL GREEN (80), repo-lint 0-fail, `ledger-scan` exit 0.

**Revert:** `git revert` this commit; the eight summaries return inline and the cap is
breached again.

## Item 20 -- SEC-024 remediated (mode); severity corrected; retention deferred

**Severity corrected BEFORE the fix.** The row as I opened it said "group- and
world-readable". Measuring the path showed `opentofu/` is 0700 and `~` is 0750, so no other
account could traverse to the file -- and neither it nor the snapshots are git-tracked, so
SEC-004's public-repo angle was never implicated. It was a DEFENCE-IN-DEPTH defect, not a
live exposure. Corrected in its own commit before any remediation, because a ledger row
that overstates an exposure is its own defect.

**Operator-approved mutation, executed and read-back verified.** `chmod 600` on
`terraform.tfstate.backup` and `terraform.tfstate.pre-G16-20260721`. All four state files
now 0600 (matching the live `terraform.tfstate` and `pre-G6`), and the checker's
`E2 WORLD-READABLE` finding cleared.

**Retention DEFERRED by ruling, not forgotten.** Question put: keep the two `pre-*`
state-surgery snapshots, delete them, keep permanently, or move them out of the clone.
Operator selected "Keep, decide at stage close" -- both gates (G6, G16) are CLOSED and the
outer plan has converged to zero diff repeatedly since, but deletion is irreversible with no
other copy of that state, so it belongs in the close-out review.

**Two things deliberately NOT done:** the umask CAUSE was out of the ruled scope, so a
future `tofu apply` may write 0664 again -- the P5 gate will catch it, which is the system
working rather than a gap. And the underlying issue that the key reaches state files in
plaintext at all is DOCFIX-175, with rotation owed under SEC-018/-019; neither is closed by
this.

**Caveat recorded in `creds-matrix-notes.md`:** E2 reports file MODE, not reachability.
That is the right default for a gate -- directory modes change and a `cp -p` escapes them --
but whoever writes up an E2 finding owes a reachability check before using the word
"exposed". This defect is exactly why.

**Revert:** `chmod 664` on the two files (restores the defect) and reopen the row.

## Item 21 -- tier 2 gate shape RULED + wired; --pending-stage replaced by a coupled projection

Two operator decisions, both taking the recommendation.

**Q1: how to gate tier 2.** Selected: **local-only blocking; remote operator-run.**
`preflight.sh` P5 now runs `creds-matrix.py --tier2` -- tier 1 STATIC plus tier 2 LOCAL,
which is HERMETIC: no ssh, no sudo, no reachability coupling. It already catches SEC-021's
three dc0 on-disk absences without leaving the jumphost, so the local half is not a token
subset. The REMOTE half stays an operator-run check at DC-standup close and stage close.

Reasoning, recorded because it will be re-litigated: wiring `--remote --privileged` into the
deploy gate would let an unrelated host outage, a NOPASSWD change, or a MAAS snap refresh
(already recorded as fragile across refreshes) turn the deploy gate red for reasons that are
not about credentials. A gate that fails environmentally is a gate people learn to bypass,
and a bypassed credential gate is worse than none because it still looks like coverage.
This repo already made the same call once: `run-tests-all.sh:7-8` -- "Deliberately NOT
folded into preflight.sh ... different cadence." And preflight already demonstrates the
failure mode: P4 currently reads `FAIL: MAAS unreachable` because it runs on vcloud while
MAAS lives on the headend (D-128).

**Q2: how to supply the stage input.** Selected: **declared file + lint coupling.**
`--pending-stage` is REPLACED by `creds-manifests/stages-reached`. A gate whose verdict
depends on a hand-typed parameter can be widened silently -- and GA-R1 puts status in
CURRENT-STATE and nowhere else, so the declaration is a machine-readable PROJECTION of it,
not a second authority. New **repo-lint L12** couples the two on the L10 pattern: editing
the projection without CURRENT-STATE in the same change set FAILS, because a drifted
projection makes the gate DEFER REAL ABSENCES, which is a false green.

**EXHAUSTIVE BY CONSTRUCTION, both bounds.** An unclassified mint-stage is a hard FAIL and
a classified-but-unused one is a stale-declaration FAIL. Neither default is safe:
assume-reached floods the run with false absences, assume-pending silently defers real ones.
That check earned itself immediately -- it caught `stage4`, which I had classified as
reached: Stage 4 is OPEN and executing but **mints no credential**, so no matrix row carries
the token and it did not belong in the file.

**A self-inflicted break, caught by the existing harness.** I first folded L12 into L10's
message, renaming its prefix to `L10/L12` -- which broke five repo-lint cases that grep the
established `L10` id. Reverted: L10 keeps its message, L12 is its own check with its own id.
New rules get their own messages; they do not rebrand old ones.

Harnesses: creds-matrix 52 -> **56** (T52 refuses without a declaration, T53/T54 the two
exhaustiveness bounds, T55 the same absent credential defers under `pending` and FAILS under
`reached`); repo-lint 45 -> **47** (T45 L12 fires alone, T46 does not fire when the authority
moves with it); preflight 10. The per-fixture stage declaration is DERIVED from each
fixture's own matrix -- a fixed default cannot satisfy an exhaustiveness check in both
directions.

Gauntlet ALL GREEN (80), repo-lint 0-fail, tier-1 finding-class baseline unchanged.

**Revert:** point P5 back at bare `creds-matrix.py`, drop L12 and `stages-reached`, restore
`--pending-stage`.

## Item 22 -- tier 3 behavioural half BUILT (boundary + cadence operator-ruled)

Operator selected **"Full -- including OpenStack service credentials"** and **"Stage close
+ DC standup close"**. I had recommended the passive-only boundary and flagged the costs
(audit-record side effects, lockout risk, liveness being a time-of-check snapshot); the
operator ruled otherwise and the full scope is built.

**V2 (passive, zero authentication) fires today on a real defect.** A credential whose SEC
row declares it BURNED/REVOKED/COMPROMISED must not also be registered as a healthy
consolidated copy. SEC-006 has recorded the NetBox upstream token as BURNED -- "must be
revoked + reissued, not merely rotated" -- since 2026-07-12, while the matrix carried it as
an ordinary `consolidated` row. Nothing reconciled those two surfaces. Keyword matching on
ledger prose is fragile, so it is deliberately conservative: a small explicit word set, and
every hit REPORTS the matched text so a human can check the basis rather than trust it.

**`scripts/creds-probe.sh` is a SEPARATE script, deliberately.** A behavioural probe must
USE a credential, and `creds-matrix.py` is metadata-only by an ENFORCED invariant (T22 bans
content-reading verbs, T51 bans content-transfer verbs). Folding probes in would have meant
relaxing that guard, and a guard relaxed once stays relaxed. The credential-using component
is therefore small, separate, and auditable on its own terms -- and it still never prints a
secret.

**Safety properties, each with a reason:** never retry a failed authentication (retry loops
are what lock accounts, and the MAAS `admin` identity backs 19 automation call sites);
never argv; never echo; stage-aware (a `pending` credential SKIPS, it does not fail, or
every stage-5 OpenStack credential would read as broken); and **per-tenant credentials are
REFUSED BY CONSTRUCTION** -- a declaration cannot opt them back in, because probing one
means authenticating AS a tenant (D-069, ruling 3). NOT a deploy gate, for the same reason
tier 2's remote half is not.

**A false green in my own first real run.** `ssh` inside a `while read` loop consumed the
loop's stdin: probe 1 ran, probes 2-13 were silently skipped, and the run exited **0**. A
partial run reporting clean is precisely the failure class this tool exists to catch, in
the tool itself. Fixed with `ssh -n` plus a redirect, and locked by a TOTAL ACCOUNTING check
-- every declared probe must land in exactly one bucket or the run is declared VOID.
Regression-locked as T5; T8 asserts the `-n` that is easy to drop when editing the command.

Declared coverage is 13 probes: 3 active today (one ssh key, two unauthenticated endpoint
checks that prove the service answers WITHOUT spending the credential), 10 skipped -- 4 as
stage-pending, 6 as `declared-only` with the reason recorded in the file so the decision is
visible rather than an omission. OpenStack service credentials are declared NOW and will
skip until Stage 5 lands, so the coverage exists the moment the stage runs.

Harness `tests/creds-probe` **9/9** (gauntlet 80 -> **81**); phase-3 DC-standup DoD wired.

**Revert:** delete `scripts/creds-probe.sh`, `creds-manifests/probes`, `tests/creds-probe/`,
and the V2 block; drop the DoD paragraph.
