docs(decisions-log): PM handoff — two fail-open holes in the gate itself
Records the session's three pieces of work and, more importantly, the theme joining them: the gate was reporting green over things it had not checked. - runbooks/node-memory-swap.md, with the three claims I wrote and then removed because they were false (EtcdIsVoter does not exist here; node-drain-policy is not pinned so the runbook reads it live; the 5-vs-1 rebuild-limit confusion). - The nested-worktree scan: six discovery sites auditing 18 copies of the repo. - L1 rendering never running on a requirement-scoped gate, with the external-dns incident reproduced via --print-selection (0 tests before, 3 after). - The two defects that surfaced in the meta-check guarding all of it. PM HANDOFF lists the open items in priority order. The first is a judgement call worth flagging rather than burying: the planned memory swap on k3s-prod-1 IS a supervised single-node loss on prod, which is exactly the event R-STATE-02 describes and which is still proven only on the decommissioned dev cluster. Capturing evidence during the swap costs nothing; manufacturing the event later costs a second maintenance window. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
1 parent
16cbd4a79e
commit
623fa14256
1 file changed
+131
@@ -4265,3 +4265,134 @@ recreating the VMI. Then the leaked value never becomes a working password anywh
|
|||||||
`.data` answers a question nobody asked. Every previous secret check in this session used a
|
`.data` answers a question nobody asked. Every previous secret check in this session used a
|
||||||
keys-only or boolean form — this one regressed because I appended a convenience field to an
|
keys-only or boolean form — this one regressed because I appended a convenience field to an
|
||||||
otherwise-safe command without re-reading what it expanded to.
|
otherwise-safe command without re-reading what it expanded to.
|
||||||
|
|
||||||
|
## 2026-09-18 — Two fail-open holes in the gate itself: nested worktrees, and L1 never running
|
||||||
|
|
||||||
|
Three pieces of work, one theme: the gate was reporting green over things it had not checked.
|
||||||
|
|
||||||
|
### 1. `runbooks/node-memory-swap.md` — the procedure the operator asked for
|
||||||
|
|
||||||
|
Keith has a matched 32 GB pair to install and asked which node needs it. **`k3s-prod-1`**: `DIMM1` is a
|
||||||
|
Micron `8ATF2G64HZ-3G2E1` (16 GB, DDR4-3200, 1Rx8), `DIMM3` a `16ATF2G64HZ-2G6E1` (16 GB, DDR4-2667,
|
||||||
|
2Rx8). Capacity is correct; the pair runs at the slower 2667. Nothing is faulty. `k3s-prod-3` is a
|
||||||
|
matched Crucial pair.
|
||||||
|
|
||||||
|
The runbook front-loads the go/no-go checks because this is the awkward node to lose: 24 Longhorn
|
||||||
|
replicas, one of three etcd members (drain leaves quorum at 2 of 3, no further margin), and its
|
||||||
|
rebuilds are the load that starved etcd once. A BLOCKED drain is treated as the safety property
|
||||||
|
working, never something to force past.
|
||||||
|
|
||||||
|
Three things I had written and then removed, because they were not true:
|
||||||
|
|
||||||
|
- **`EtcdIsVoter` does not exist in this cluster.** I had an assertion built on it; it appears nowhere
|
||||||
|
in the repo. The house check is `curl http://127.0.0.1:2381/metrics | grep etcd_server_has_leader`
|
||||||
|
(`runbooks/node-down.md:93`).
|
||||||
|
- **`node-drain-policy` is not pinned** in `platform/longhorn/helmrelease.yaml`. The runbook has the
|
||||||
|
operator READ the live Setting and set it before draining rather than assuming the chart default —
|
||||||
|
that Setting is the entire safety story for 24 replicas.
|
||||||
|
- I had written "rebuild concurrency is deliberately 1". That is the purge/restore limits, not replica
|
||||||
|
rebuild — the same 5-vs-1 confusion already corrected once in `longhorn-degraded.md`.
|
||||||
|
|
||||||
|
Verification hinges on **`Configured Memory Speed`**, not `Speed`: a matched 3200 pair being clocked
|
||||||
|
down still reports `Speed: 3200 MT/s`, so matched part numbers alone do not prove the swap worked.
|
||||||
|
|
||||||
|
Also recorded: the `node-health` artifacts de-duplicate DIMM part numbers, so they can prove "two
|
||||||
|
different modules" but cannot distinguish one module from two identical ones — verification collects
|
||||||
|
`dmidecode` live. **Node 2's inventory remains stale and self-contradictory** (documented as the only
|
||||||
|
single-DIMM node at 16 GB, yet reporting ~31.1 GiB) and needs re-inventory before anyone claims
|
||||||
|
three-way symmetry. This is NOT the §0b "do not buy RAM" case, which was an EFI memattr kernel bug.
|
||||||
|
|
||||||
|
### 2. The whole-repo L0 checks were auditing 18 copies of the repo
|
||||||
|
|
||||||
|
Six discovery sites walked `ROOT` without pruning dot-directories, so they descended into
|
||||||
|
`.claude/worktrees/agent-*/` — full repo copies from isolated agent runs, gitignored at
|
||||||
|
`.gitignore:43`.
|
||||||
|
|
||||||
|
The visible half was false FAILs: four ServiceMonitors "selecting no Service", nine PrometheusRules
|
||||||
|
"under test but not in the repo", every path a worktree path. The whole-repo gate was red for reasons
|
||||||
|
with nothing to do with authored surface.
|
||||||
|
|
||||||
|
The dangerous half is quieter, and is why all six were fixed rather than only the failing ones: a check
|
||||||
|
asking *"does anything in the repo declare X"* can be satisfied by a worktree copy instead of real
|
||||||
|
source. The same bug that invents failures can manufacture a pass.
|
||||||
|
|
||||||
|
`egress-paths-selected-by-rules` already had the fix and documents hitting it (it once saw 5
|
||||||
|
`CiliumEgressGatewayPolicy` objects instead of the 1 authored). Its dot-directory predicate is now
|
||||||
|
applied everywhere, plus both `find` calls in `platform/monitoring/tests/run.sh`, whose `--list`
|
||||||
|
reported each manifest once per worktree — 21 real pairs where it had been returning them ~19x over.
|
||||||
|
|
||||||
|
### 3. L1 rendering never ran on a requirement-scoped gate — closed, with the incident reproduced
|
||||||
|
|
||||||
|
`ci/l1.sh` has passed `--include-scope whole-repo` since it was written, with a comment saying no L1
|
||||||
|
test declared the scope yet and it was wired ahead of time. Nothing ever declared it, so **the entire
|
||||||
|
L1 rendering layer only ran when a touched path happened to attribute R-PLAT-02, R-NET-01 or
|
||||||
|
R-NET-05/06/07.**
|
||||||
|
|
||||||
|
All three L1 tests now declare `conformance.home.k3s/scope: "whole-repo"`. The justification is one
|
||||||
|
shared code path — `catalog/render.py` against `schemas/servicedefinition.schema.json` — which
|
||||||
|
requirement-scoped selection cannot see.
|
||||||
|
|
||||||
|
The 2026-09-04 external-dns merge is the proof, reproduced rather than asserted.
|
||||||
|
`ci/touched-requirements.sh` attributes `platform/externaldns/helmrelease.yaml` to R-ENV-02,
|
||||||
|
R-OBS-01/02/04, R-STATE-03 and R-TEST-01/02/03/04 — **none** of the rendering requirements — yet that
|
||||||
|
change added `external-dns.alpha.kubernetes.io/controller: none` to the rendered output of all three
|
||||||
|
goldens. Measured with `--print-selection`:
|
||||||
|
|
||||||
|
| selection for that change | L1 tests selected |
|
||||||
|
|---|---|
|
||||||
|
| requirement-scoped only (the 2026-09-04 behaviour) | **0** |
|
||||||
|
| with `--include-scope whole-repo` (how `ci/l1.sh` invokes it) | **3** |
|
||||||
|
|
||||||
|
### 4. …which exposed two defects in the meta-check that guards all of this
|
||||||
|
|
||||||
|
Declaring the scope at L1 made `whole-repo-invariants-wired` (R-TEST-03) fail, and it was right to
|
||||||
|
change behaviour — but for the wrong reason, and it could not say so:
|
||||||
|
|
||||||
|
- **It was layer-agnostic where the design is layer-bounded.** It compared *every* scope-declaring dir
|
||||||
|
against an `--layer l0` selection, assuming only L0 tests would ever declare the scope — while
|
||||||
|
`ci/l1.sh` was written from the opposite premise, that a whole-repo invariant could appear "at any
|
||||||
|
layer". So it blamed the union for correctly excluding another layer. Now partitioned by layer, with
|
||||||
|
the L1 selection and L1 leakage assertions added — which is what makes the L1 declaration
|
||||||
|
load-bearing instead of decorative.
|
||||||
|
- **Every assertion in it failed silently.** The idiom was `<test>` on one line, `chk $?` on the next,
|
||||||
|
under `set -euo pipefail` — so a false test aborted the script *before* `chk` could print. The real
|
||||||
|
failure I hit emitted 26 `ok` lines, rc=1, and no message. All 12 sites now capture the status
|
||||||
|
(`chk $arc`), proven by forcing `MIN_DECLARED=999`: the failure prints its full diagnostic and the
|
||||||
|
run continues to the remaining assertions.
|
||||||
|
|
||||||
|
A meta-check that cannot report why it failed is worse than no meta-check, because the silence reads
|
||||||
|
as a tooling glitch rather than a finding.
|
||||||
|
|
||||||
|
### Gate
|
||||||
|
|
||||||
|
Offline `l0,l1,meta --include-scope whole-repo` in the pinned toolbox: **25 tests, 0 failures, 0
|
||||||
|
errors, exit 0**. L0 whole-repo went 4 failures → 1 → 0 across the two fixes;
|
||||||
|
`whole-repo-invariants-wired` now runs 35 assertions. Host runs report one skip because `promtool` is
|
||||||
|
not on `$PATH` there — the toolbox run is the real result, and that difference is worth remembering
|
||||||
|
before trusting a host-only gate run.
|
||||||
|
|
||||||
|
### PM HANDOFF
|
||||||
|
|
||||||
|
Open, in the order I would take them:
|
||||||
|
|
||||||
|
1. **The memory swap is a free R-STATE-02 proof.** `node-loss` is Phase 7's last exit item and is still
|
||||||
|
proven only on the decommissioned dev cluster. The swap is a planned, supervised single-node loss on
|
||||||
|
prod — exactly the event R-STATE-02 describes. Capturing evidence during it costs nothing extra;
|
||||||
|
manufacturing the event later costs a second maintenance window.
|
||||||
|
2. **Node 2 DIMM re-inventory** — blocked on the vault become password for `node_health`. Ansible only;
|
||||||
|
never `ansible-inventory --list` (it prints decrypted vault values).
|
||||||
|
3. **Workstation DNS, one sudo command.** `/etc/dnsmasq.d/10-home.keithjackson.dev.conf` contains
|
||||||
|
`local=/home.keithjackson.dev/` plus `address=/.home.keithjackson.dev/192.168.1.53` — the whole zone
|
||||||
|
hardcoded to the decommissioned dev cluster, with forwarding forbidden so no upstream could correct
|
||||||
|
it. That is one root cause for both the sonarr failure and `gitea` not resolving. Pi-hole answers
|
||||||
|
correctly (`gitea` → `192.168.2.20`); only this file is wrong. Correction to an earlier claim of
|
||||||
|
mine: there is no separate `20-prod.*` file — it is a replace of this one file. Router DHCP option
|
||||||
|
6 → `.23` is the fix that makes it unnecessary per-machine.
|
||||||
|
4. **UniFi guest has no IP** — needs console access; `cat /etc/netplan/*.yaml` is the confirming command.
|
||||||
|
5. **Five `public` services have no working public path** — waiting on WAN forwarding, which Keith has
|
||||||
|
said is deliberately not ready.
|
||||||
|
6. **18 stale agent worktrees** on already-merged branches remain on disk. No longer load-bearing for
|
||||||
|
the gate; cleaning them up is cosmetic and should check each for uncommitted work first.
|
||||||
|
7. Backlog cherry-picks: `feat/egress-gateway-staleness-alert`, two `rescued/*` fixes, 103 unmapped
|
||||||
|
paths, and two rendered manifests nothing applies (the keycloak one would stand up a conflicting
|
||||||
|
instance).
|
||||||
Reference in new issue
Block a user