diff --git a/creds-manifests/stages-reached b/creds-manifests/stages-reached new file mode 100644 index 0000000..4e5cf65 --- /dev/null +++ b/creds-manifests/stages-reached @@ -0,0 +1,56 @@ +# stages-reached -- which mint stages THIS deployment has completed (D-137 tier 2). +# +# WHY THIS FILE EXISTS. Tier 2 must distinguish "this credential is MISSING" from "this +# credential has not been minted YET". That distinction is a STATUS fact, and GA-R1 puts +# status in docs/CURRENT-STATE.md and nowhere else -- so the checker carries no stage list +# of its own, and until now the caller typed one on the command line with --pending-stage. +# A gate whose verdict depends on a hand-typed parameter can be widened silently, so the +# declaration moved here: config-as-data with a drift gate, the pattern the repo already +# uses for state-bearing surfaces. +# +# THIS FILE IS A DERIVED STATUS CLAIM. docs/CURRENT-STATE.md remains the authority; this is +# a machine-readable projection of it, and repo-lint L12 FAILS if it is edited without +# CURRENT-STATE being touched in the same change set (the L10 pattern). If the two ever +# disagree, CURRENT-STATE wins and this file is the defect. +# +# EXHAUSTIVE BY CONSTRUCTION. Every `mint-stage` token used in creds-matrix.tsv must appear +# here exactly once. The checker FAILS on an unclassified token rather than defaulting it, +# because either default is wrong in a dangerous direction: defaulting to `reached` floods +# the run with false absences, and defaulting to `pending` silently defers real ones -- a +# false green, which is the failure class this whole control exists to prevent. +# +# FORMAT. Whitespace-separated: +# reached the stage has RUN in this deployment; its credentials are expected NOW and a +# missing one is a real finding. +# pending the stage has NOT run; its credentials are deferred, reported as an explicit +# [ok] skip, never as a silent pass. +# `#` comments and blank lines ignored. +# +# EVIDENCE for the current values (docs/CURRENT-STATE.md section 1): Stage 3 / Phase 2 is +# CLOSED for both DCs; Stage 4 / Phase 3 is OPEN and executing; "stages 5-7 are authored, +# not executed". The VR0 `phase-NN` track is the SEPARATE single-DC testcloud lineage -- do +# NOT conflate the two numbering tracks (the operating skill warns on exactly this) -- and +# none of its credential artifacts exist on this jumphost (measured 2026-07-26: the Vault +# init and Octavia PKI directories are absent). + +# --- VR1 DC-DC track ------------------------------------------------------------------- +stage2 reached # Office1 headend standup: region MAAS, LXD, NetBox +stage3 reached # per-DC substrate: rack, edge, node VMs, power + service keys +# stage4 is deliberately ABSENT: Stage 4 (MAAS enlist/commission/deploy) is OPEN and +# executing, but it MINTS NO CREDENTIAL -- no matrix row carries it. The exhaustiveness +# check flags a classified-but-unused token as a stale declaration, which is how this was +# caught; a stage only belongs here once a credential is attributed to it. +stage5 pending # Juju bootstrap + per-DC bundle deploy -- authored, not executed +stage6 pending # DR / rbd-mirror / radosgw multisite -- authored, not executed + +# --- VR0 single-DC testcloud track (separate lineage; artifacts absent here) ------------ +vr0-phase01 pending # bundle deploy + Octavia PKI generation +vr0-phase02 pending # Vault bring-up (unseal shares, root token, charm-pki-local) +vr0-phase03 pending # admin-openrc materialization +vr0-phase06 pending # CAPI stack / mgmt cluster kubeconfig +vr0-phase07 pending # Magnum conductor graft + +# --- not stage-scoped ------------------------------------------------------------------ +tenant-onboard pending # per-tenant credential set; no tenant onboarded on this cloud +adhoc reached # one-off mints (the 2026-07-25 `operator` MAAS superuser) +- reached # rows with no stage: present-or-absent judged on their own diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 45226e1..e29b9b7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -978,8 +978,7 @@ it. The key reaching state files in plaintext at all remains DOCFIX-175, rotation owed under SEC-018/-019. **STILL OUTSTANDING (not done, not silently dropped):** ruling 1's TIER 2 `Pn` (tier 1 - is wired -- see above); tier 3 (BUILT 2026-07-26 -- see below); tier 3's behavioural-probe - half + is wired -- RESOLVED, see the tier-2 gate block below); tier 3's behavioural-probe half (VALIDITY); `--render`'s source-field derivation; and the `cardinality` field remains largely inert with a one-token S5 bypass (`per-DC` -> `singleton`). diff --git a/docs/changelog-20260726-d137-tier1.md b/docs/changelog-20260726-d137-tier1.md index f4cd74f..8626251 100644 --- a/docs/changelog-20260726-d137-tier1.md +++ b/docs/changelog-20260726-d137-tier1.md @@ -670,3 +670,55 @@ "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`. diff --git a/scripts/creds-matrix.py b/scripts/creds-matrix.py index c7bbd5e..5f8ae71 100644 --- a/scripts/creds-matrix.py +++ b/scripts/creds-matrix.py @@ -456,6 +456,36 @@ SAFE_TARGET_RE = re.compile(r"^[A-Za-z0-9_.-]+$") # no leading '-', no ssh options +def load_stages(path, rows, fails): + """The declared reach of each mint stage (D-137 tier 2). Returns {token: bool} or None. + + EXHAUSTIVE BY CONSTRUCTION: every mint-stage token the matrix uses must be classified + here. An unclassified token is a hard FAIL, never a default -- defaulting to `reached` + floods the run with false absences, and defaulting to `pending` silently defers real + ones, which is the false-green class this control exists to prevent.""" + if not os.path.exists(path): + return None + out = {} + for n, line in enumerate(open(path), 1): + text = line.split("#", 1)[0].strip() + if not text: + continue + parts = text.split() + if len(parts) != 2 or parts[1] not in ("reached", "pending"): + fails.append("stages-reached:%d must be ' reached|pending', got: %s" + % (n, text[:50])); continue + out[parts[0]] = (parts[1] == "reached") + used = {r.mint_stage for r in rows} + for tok in sorted(used - set(out)): + fails.append("stages-reached: mint-stage '%s' is used in the matrix but NOT " + "classified -- classify it reached|pending; the checker will not " + "default it in either direction" % tok) + for tok in sorted(set(out) - used): + fails.append("stages-reached: '%s' is classified but used by no matrix row -- " + "stale declaration" % tok) + return out + + def load_locations(path, repo, fails): """The ruling-3 declared-location list. Returns [(role, site, ssh_target, path)], or None if the file is ABSENT.""" @@ -596,7 +626,7 @@ return ("ok", found) -def tier2_existence(rows, locations, use_remote, use_privileged, pending_stages, +def tier2_existence(rows, locations, use_remote, use_privileged, stages, oks, fails): """EXISTENCE: everything expected is present at its expected role, and nothing undeclared sits at any declared location -- including the remote ones (ruling 3).""" @@ -709,9 +739,10 @@ if scope in incomplete: gated += 1 continue - if r.mint_stage in pending_stages: - # NOT-YET-EXPECTED, declared by the CALLER. The script carries no status - # claim of its own (GA-R1: status lives in CURRENT-STATE, nowhere else). + if stages.get(r.mint_stage, True) is False: + # NOT-YET-EXPECTED per creds-manifests/stages-reached, which is a derived + # projection of CURRENT-STATE coupled to it by repo-lint L12. The script still + # carries no status claim of its own (GA-R1). deferred += 1 continue absent += 1 @@ -742,7 +773,8 @@ "creds-audit cannot see" % (name, scope[0], scope[1])) if deferred: - oks.append("E1 %d expected artifact(s) deferred as not-yet-minted (--pending-stage)" + oks.append("E1 %d expected artifact(s) deferred as not-yet-minted per " + "creds-manifests/stages-reached" % deferred) if gated: oks.append("E1 %d expected artifact(s) NOT JUDGED -- their role (%s) has at least " @@ -904,10 +936,11 @@ help="allow a `sudo -n` ESCALATION RETRY on a remote location the " "unprivileged probe could not open (the root-owned region and " "netbox secret dirs). Retried only where needed; metadata only.") - ap.add_argument("--pending-stage", action="append", default=[], metavar="STAGE", - help="a mint-stage this deployment has NOT reached; its expected " - "artifacts are deferred, not failed. Supply from CURRENT-STATE -- " - "this script carries no status claim of its own (GA-R1).") + ap.add_argument("--stages", + default=os.path.join(repo, "creds-manifests", "stages-reached"), + help="declared reach of each mint stage. A derived projection of " + "CURRENT-STATE (repo-lint L12 couples them); the script carries " + "no status claim of its own (GA-R1).") ap.add_argument("--render", action="store_true", help="print manifests rendered from the matrix (does not write)") args = ap.parse_args() @@ -952,8 +985,14 @@ "one rather than affirming existence over zero locations" % args.locations) else: + stages = load_stages(args.stages, rows, fails) + if stages is None: + fails.append("tier 2: no stage declaration at %s -- without it the checker " + "cannot tell a MISSING credential from a NOT-YET-MINTED one, " + "and it will not guess" % args.stages) + stages = {} observed = tier2_existence(rows, locations, args.remote, args.privileged, - set(args.pending_stage), oks, fails) + stages, oks, fails) if args.tier3 or args.all: print("=== creds-matrix: tier 3 (VALIDITY) ===") tier3_validity(rows, locations, args.remote, args.privileged, diff --git a/scripts/preflight.sh b/scripts/preflight.sh index 56b39e0..dce908a 100644 --- a/scripts/preflight.sh +++ b/scripts/preflight.sh @@ -13,7 +13,8 @@ # P2 bundle invariants scripts/provider-bundle-check.py # P3 channel assert scripts/channel_assert.py (needs charmhub; WARN if offline) # P4 live pre-flight scripts/pre-flight-checks.sh (needs MAAS; overlay/VIP/planes/nodes) -# P5 credential matrix scripts/creds-matrix.py (D-137 ruling 1; tier 1 STATIC) +# P5 credential matrix scripts/creds-matrix.py --tier2 (D-137 ruling 1; +# tier 1 STATIC + tier 2 LOCAL. Hermetic: no ssh, no sudo.) # P6 reminders stage-2 gates: juju-spaces-check.sh (AFTER add-model), # osd-blank-check.sh (sudo), 'juju deploy --dry-run' (phase-01 1.2) # @@ -41,14 +42,21 @@ echo " [FAIL] scripts/pre-flight-checks.sh missing"; note 1 fi -echo "================ P5: credential matrix (D-137 tier 1) ================" +echo "================ P5: credential matrix (D-137 tier 1 + tier 2 local) ================" # D-137 ruling 1: enforcement is BLOCKING IN PREFLIGHT. Advisory was rejected as the # posture that demonstrably failed -- `creds-audit` read CLEAN on 2026-07-15 while four # region-VM secrets sat undeclared, and `admin.pass` surfaced 12 days later only because # someone looked. Tier 1 is wired here because it is OFFLINE and its findings ARE the three -# ruled failure classes (expected-but-absent / undeclared / per-DC-asymmetric). Tier 2 -# needs --remote/--privileged and a caller-supplied --pending-stage, so it stays out of an -# unattended gate; wiring it is a separate decision. +# ruled failure classes (expected-but-absent / undeclared / per-DC-asymmetric). +# +# LOCAL HALF ONLY, deliberately. `--tier2` without `--remote` is HERMETIC -- no ssh, no +# sudo, no reachability coupling -- and still checks every jumphost creds folder for +# undeclared files, wrong modes and missing credentials. The REMOTE half +# (`--tier2 --remote --privileged`) is an operator-run check at DC-standup close and stage +# close, NOT a deploy gate: it would let an unrelated host outage, a NOPASSWD change, or a +# MAAS snap refresh turn the deploy gate red for reasons that are not about credentials, +# and a gate that fails environmentally is a gate people learn to bypass. Same reasoning +# `run-tests-all.sh:7-8` gives for staying out of preflight: different cadence. # # EXPECTED TO FAIL TODAY, BY DESIGN. The register is red on real defects (SEC-021's # declaration gaps, the SEC-020 ruling-5 identity conflation, the per-DC power-key @@ -60,7 +68,7 @@ # it to a warning instead of stopping the deploy. A gate that vanishes must never read as # a pass (harness T9 encodes this). if [ -f scripts/creds-matrix.py ]; then - python3 scripts/creds-matrix.py; note $? + python3 scripts/creds-matrix.py --tier2; note $? else echo " [FAIL] scripts/creds-matrix.py missing"; note 1 fi diff --git a/scripts/repo-lint.sh b/scripts/repo-lint.sh index 7521ecf..42c0550 100644 --- a/scripts/repo-lint.sh +++ b/scripts/repo-lint.sh @@ -5,7 +5,7 @@ # Mutates NOTHING -- except under --record-clientdocs-sweep (DOCFIX-104, rewrites # ONLY clientdocs/sweep-receipt.txt) or --record-guide-skill-coupling (rewrites # ONLY clientdocs/guide-skill-coupling.txt, the L8 pin); both then lint as usual. -# See the .py docstring for the check catalog (L1-L11). +# See the .py docstring for the check catalog (L1-L12). # Exit: 0 clean | 1 FAIL findings | 2 warnings only. ASCII + LF. set -uo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/scripts/repo_lint.py b/scripts/repo_lint.py index b36e812..bc33b22 100644 --- a/scripts/repo_lint.py +++ b/scripts/repo_lint.py @@ -72,6 +72,10 @@ L10_FAIL (phase5-sweep-plan Batch 0: "WARN for one batch"). L11 caveman-resid caveman-compress residue (SEC-017): /caveman-compress + L12 stage-couple creds-manifests/stages-reached is a DERIVED projection of + CURRENT-STATE consumed by the blocking preflight P5 gate; editing it + without CURRENT-STATE in the same change set lets the projection drift + from its authority and the gate silently defer real absences (D-137) overwrites a prose file IN PLACE and drops a .original.md backup beside it -- the signature of a lossy in-place rewrite having run against a repo-tracked @@ -447,6 +451,32 @@ "references/kubernetes.md + troubleshooting.md for the SAME " "update, then re-record: %s" % COUPLING_CMD) + # ---- L12 stage-declaration coupling (D-137) ---- + # creds-manifests/stages-reached is a machine-readable PROJECTION of CURRENT-STATE's + # stage reach, consumed by the BLOCKING preflight P5 gate: it decides which missing + # credentials are "not minted yet" rather than "missing". Editing it without its + # authority in the same change set lets the projection drift silently, and a drifted + # projection makes the gate DEFER real absences -- a false green. Same coupling L10 + # applies to state-bearing surfaces, stated as its own rule so each has its own id. + if shutil.which("git") and \ + git("rev-parse", "--is-inside-work-tree").stdout.strip() == "true" and \ + git("rev-parse", "--verify", "-q", "HEAD").returncode == 0: + if git("status", "--porcelain").stdout.strip(): + l12_scope = "pending change set" + l12_changed = set(git("diff", "--name-only", "HEAD").stdout.splitlines()) + l12_changed |= set(git("ls-files", "--others", "--exclude-standard") + .stdout.splitlines()) + else: + l12_scope = "HEAD commit" + l12_changed = set(git("diff-tree", "-r", "--no-commit-id", "--name-only", + "--root", "HEAD").stdout.splitlines()) + if ("creds-manifests/stages-reached" in l12_changed + and "docs/CURRENT-STATE.md" not in l12_changed): + fails.append("L12 %s edits creds-manifests/stages-reached (the D-137 stage " + "projection consumed by the blocking preflight P5 gate) without " + "touching docs/CURRENT-STATE.md -- the projection must not drift " + "from its authority" % l12_scope) + # ---- L11 caveman-compress residue (SEC-017) ---- # /caveman-compress rewrites a prose file IN PLACE and drops a # .original.md backup beside it. That backup is the residue signature diff --git a/tests/creds-matrix/run-tests.sh b/tests/creds-matrix/run-tests.sh index fb001b9..6b69978 100644 --- a/tests/creds-matrix/run-tests.sh +++ b/tests/creds-matrix/run-tests.sh @@ -41,8 +41,14 @@ } run() { # run [extra args...] -> sets RC, writes $1/out local d="$1"; shift + # Derive the stage declaration from THIS fixture's matrix, all reached. A fixed default + # cannot work: the declaration must be exhaustive over the tokens used AND carry no + # unused token, so it has to track the fixture. Cases that need a `pending` stage write + # their own file before calling run(). + [ -f "$d/stages" ] || awk '!/^#/ && NF==12 {print $9" reached"}' "$d/matrix.tsv" \ + | sort -u > "$d/stages" python3 "$CHECK" --matrix "$d/matrix.tsv" --manifest-dir "$d/creds-manifests" \ - --notes "$d/notes.md" --repo "$REPO" \ + --notes "$d/notes.md" --repo "$REPO" --stages "$d/stages" \ --locations "$d/locations" "$@" > "$d/out" 2>&1 RC=$? } @@ -278,10 +284,11 @@ # T27 -- a credential whose mint-stage has not been reached is DEFERRED, not failed -- # and the caller declares that, never the script (GA-R1: no status claim lives in code). -D=$(mkenv); mkloc "$D"; rm "$D/store/alpha_ed25519"; run "$D" --tier2 --pending-stage stage2 +D=$(mkenv); mkloc "$D"; rm "$D/store/alpha_ed25519" +printf 'stage2 pending\n' > "$D/stages"; run "$D" --tier2 [ "$RC" = 0 ] && grep -q 'deferred as not-yet-minted' "$D/out" \ - && ok "T27 --pending-stage defers a not-yet-minted credential instead of failing it" \ - || { no "T27 pending-stage deferral (rc=$RC)"; sed 's/^/ /' "$D/out"; } + && ok "T27 a stage declared 'pending' defers its credential instead of failing it" \ + || { no "T27 pending deferral (rc=$RC)"; sed 's/^/ /' "$D/out"; } # T28 -- the SEC-020/-022 class from the other side: a real secret sitting at a declared # location that no matrix row accounts for. @@ -530,6 +537,46 @@ || ok "T51b no content-transfer verb anywhere in the checker" +# ---- stage declaration (D-137 tier 2 gate input) --------------------------------------- + +# T52 -- without a stage declaration the checker cannot tell MISSING from NOT-YET-MINTED, +# and it must refuse rather than guess. Guessing either way is wrong in a dangerous +# direction: assume-reached floods the run with false absences, assume-pending silently +# defers real ones. +D=$(mkenv); mkloc "$D"; printf 'stage2 reached\n' > "$D/stages"; rm "$D/stages" +python3 "$CHECK" --matrix "$D/matrix.tsv" --manifest-dir "$D/creds-manifests" \ + --notes "$D/notes.md" --repo "$REPO" --locations "$D/locations" \ + --stages "$D/nope" --tier2 > "$D/out" 2>&1; RC=$? +[ "$RC" = 1 ] && grep -q 'no stage declaration' "$D/out" \ + && ok "T52 a missing stage declaration -> tier 2 REFUSES, never guesses" \ + || { no "T52 missing stage decl must refuse (rc=$RC)"; sed 's/^/ /' "$D/out"; } + +# T53 -- EXHAUSTIVENESS. A mint-stage the declaration does not classify is a hard FAIL, not +# a default. This is the both-bounds discipline applied to the gate's own status input. +D=$(mkenv); mkloc "$D"; printf 'tenant-onboard pending\n' > "$D/stages"; run "$D" --tier2 +[ "$RC" = 1 ] && grep -q "mint-stage 'stage2' is used in the matrix but NOT classified" "$D/out" \ + && ok "T53 an unclassified mint-stage -> FAIL (no silent default in either direction)" \ + || { no "T53 unclassified stages must fail (rc=$RC)"; sed 's/^/ /' "$D/out"; } + +# T54 -- the other bound: a classified stage no row uses is a STALE declaration. This is +# how `stage4` was caught -- Stage 4 is open and executing but mints no credential. +D=$(mkenv); mkloc "$D"; printf 'stage2 reached\ntenant-onboard pending\nstage9 reached\n' > "$D/stages" +run "$D" --tier2 +[ "$RC" = 1 ] && grep -q "'stage9' is classified but used by no matrix row" "$D/out" \ + && ok "T54 a classified-but-unused stage -> stale-declaration FAIL (both bounds)" \ + || { no "T54 stale stage declarations must fail (rc=$RC)"; sed 's/^/ /' "$D/out"; } + +# T55 -- `pending` defers a missing credential; `reached` reports it. This is the whole +# point of the file, and it must swing BOTH ways on the same fixture. +D=$(mkenv); mkloc "$D"; rm "$D/store/alpha_ed25519" +printf 'stage2 pending\n' > "$D/stages"; run "$D" --tier2 +PEND=$RC +printf 'stage2 reached\n' > "$D/stages"; run "$D" --tier2 +[ "$PEND" = 0 ] && [ "$RC" = 1 ] && grep -q 'E1 EXPECTED-BUT-ABSENT' "$D/out" \ + && ok "T55 the same absent credential defers under 'pending' and FAILS under 'reached'" \ + || { no "T55 stage reach must drive the verdict (pending=$PEND reached=$RC)"; sed 's/^/ /' "$D/out"; } + + echo if [ "$F" = 0 ]; then echo "creds-matrix: $P/$P PASS"; exit 0; fi echo "creds-matrix: FAILURES: $F (passed $P)"; exit 1 diff --git a/tests/repo-lint/run-tests.sh b/tests/repo-lint/run-tests.sh index dd738c0..3950a6c 100644 --- a/tests/repo-lint/run-tests.sh +++ b/tests/repo-lint/run-tests.sh @@ -205,5 +205,21 @@ run 0 'PASS: repo lint' \ "T44 L11 excludes .claude worktrees" "$D" +# T45/T46 -- L12: creds-manifests/stages-reached is a DERIVED projection of CURRENT-STATE +# that the BLOCKING preflight P5 gate consumes to decide which missing credentials are +# merely not-minted-yet. A drifted projection makes the gate DEFER REAL ABSENCES -- a false +# green, the exact class D-137 exists to prevent. L12 couples the two the way L10 couples +# state-bearing surfaces. +mkl12() { local d; d="$(mkrepo "$1")"; mkdir -p "$d/creds-manifests" + printf 'stage2 reached\n' > "$d/creds-manifests/stages-reached" + printf '# state\n' > "$d/docs/CURRENT-STATE.md" + ( cd "$d" && git init -q . && git add -A && git -c user.email=t@t -c user.name=t \ + commit -qm base ) >/dev/null 2>&1 + printf 'stage2 reached\nstage3 reached\n' > "$d/creds-manifests/stages-reached" + echo "$d"; } +D=$(mkl12 l12a); run 1 'L12 .*stages-reached' "T45 L12 stage projection edited alone FAILS" "$D" +D=$(mkl12 l12b); printf 'moved\n' >> "$D/docs/CURRENT-STATE.md" +run 0 'PASS: repo lint' "T46 L12 does NOT fire when CURRENT-STATE moves with it" "$D" + echo; echo "RESULT: PASS=$PASS FAIL=$FAIL" [[ "$FAIL" -eq 0 ]] && { echo "ALL PASS"; exit 0; } || exit 1