@chrono-meta/fh-gate 1.4.79 → 1.4.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,434 @@
1
+ #!/usr/bin/env bash
2
+ # test_consent_registry.sh — known-pair anchor for scripts/consent_registry_check.sh.
3
+ #
4
+ # WHY (cross-family review rounds 1-3, 2026-07-29)
5
+ # The accept-side consent-promotion rule went REJECT -> NARROW-IT -> NARROW-IT across three
6
+ # adversarial rounds. Round 3 stopped attacking the prose and attacked the validator, and found
7
+ # four ways the FLOOR ITSELF was fail-open. Three were confirmed against controls:
8
+ #
9
+ # - `promotion_eligible: "false"` (quoted) is a truthy STRING. Every eligibility test inverted,
10
+ # so an intended-ineligible class was granted. One quote character disarmed the floor.
11
+ # - a duplicate class name silently shadowed the earlier entry, so appending an eligible twin
12
+ # below an ineligible one laundered the ineligible class into a grant.
13
+ # - `standing_consent: {inline: ...}` matched no pattern, so an EXPIRED inline grant was read as
14
+ # "no standing consent recorded" and reported PASS — a grant the checker cannot see is not an
15
+ # absent grant, it is a false clean.
16
+ #
17
+ # Each of those is a check that reported green while doing nothing, which is worse than an absent
18
+ # check: it buys confidence without enforcement. Hence this anchor.
19
+ #
20
+ # Lanes: N* = known-negative (must pass) · P* = known-positive (must be caught) · D* = degrade.
21
+ # Exit 0 = all lanes correct. Exit 1 = the validator regressed toward permissiveness.
22
+ set -uo pipefail
23
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
24
+ CHK="$ROOT/scripts/consent_registry_check.sh"
25
+ [ -f "$CHK" ] || { echo "FAIL: $CHK not found"; exit 1; }
26
+
27
+ pass=0; fail=0
28
+ ok() { printf ' ✅ %s\n' "$1"; pass=$((pass+1)); }
29
+ bad() { printf ' ❌ %s\n' "$1"; fail=$((fail+1)); }
30
+ TD="$(mktemp -d)"; trap 'rm -rf "$TD"' EXIT
31
+
32
+ # want_rc: expected exit. want_rule: a rule id that must appear in the output ("" = don't care).
33
+ lane() {
34
+ local desc="$1" want_rc="$2" want_rule="$3"
35
+ bash "$CHK" "$TD/r.yaml" "$TD/u.md" >"$TD/o" 2>&1
36
+ local rc=$?
37
+ if [ "$rc" -ne "$want_rc" ]; then
38
+ bad "$desc — exit $rc, expected $want_rc"; sed 's/^/ /' "$TD/o"; return
39
+ fi
40
+ if [ -n "$want_rule" ] && ! grep -q "❌ $want_rule" "$TD/o"; then
41
+ bad "$desc — exit was right but rule $want_rule was not the reason"; sed 's/^/ /' "$TD/o"; return
42
+ fi
43
+ ok "$desc"
44
+ }
45
+
46
+ OKCLASS=' - {name: ok, owner: o, mode: m, target: t, capabilities: [read], sinks: [], feeds: [], promotion_eligible: true}'
47
+ mkreg() { printf 'classes:\n%s\n' "$1" > "$TD/r.yaml"; }
48
+ # The UAP machine region is YAML FRONTMATTER (storage-form change 2026-07-31). A fixture body is the
49
+ # frontmatter payload, so every grant-semantics lane below stays written exactly as before and keeps
50
+ # testing what it tested. Lanes that exercise the storage form itself write their own file directly
51
+ # and do not go through this helper.
52
+ mkuap() { printf -- '---\n%s\n---\n\n# UAP (fixture)\n\nprose below the machine region.\n' "$1" > "$TD/u.md"; }
53
+ # Raw writer for storage-form lanes — no frontmatter wrapping, byte-exact.
54
+ mkuap_raw() { printf '%s\n' "$1" > "$TD/u.md"; }
55
+
56
+ mkreg "$OKCLASS"
57
+ mkuap 'standing_consent:
58
+ ok: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
59
+ lane "N1 a well-formed registry + eligible unexpired scoped grant passes" 0 ""
60
+
61
+ mkuap 'standing_consent:
62
+ ok: declined'
63
+ lane "N2 a declined record is a valid non-grant state, not a malformed grant" 0 ""
64
+
65
+ mkreg ' - {name: s, owner: o, mode: m, target: t, capabilities: [read], sinks: [go-public], feeds: [], promotion_eligible: true}'
66
+ mkuap 'standing_consent: {}'
67
+ lane "P1 a class naming an irreversible SINK cannot declare itself promotable" 1 "R2"
68
+
69
+ mkreg ' - {name: f, owner: o, mode: m, target: t, capabilities: [read], sinks: [], feeds: [go-public], promotion_eligible: true}'
70
+ lane "P2 taint — a class that FEEDS an irreversible sink cannot be promotable" 1 "R2"
71
+
72
+ mkreg "$OKCLASS"
73
+ mkuap 'standing_consent:
74
+ ghost: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
75
+ lane "P3 a grant on an unregistered class is refused (unregistered == unknown)" 1 "R3"
76
+
77
+ mkuap 'standing_consent:
78
+ ok: {granted: 2026-01-01, expires: 2026-06-30, effects: [read], target: t}'
79
+ lane "P4 an expired lease does not keep running" 1 "R5"
80
+
81
+ mkuap 'standing_consent:
82
+ ok: {granted: 2026-07-29, expires: 2026-12-31}'
83
+ lane "P5 a grant with no recorded scope has no re-validation baseline" 1 "R6"
84
+
85
+ # P6-P8 are the round-3 fail-opens. Each of these PASSED before the fix.
86
+ mkreg ' - {name: q, owner: o, mode: m, target: t, capabilities: [read], sinks: [], feeds: [], promotion_eligible: "false"}'
87
+ mkuap 'standing_consent:
88
+ q: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
89
+ lane "P6 quoted \"false\" is rejected as a type error, not read as truthy" 1 "R1-b"
90
+
91
+ mkreg ' - {name: d, owner: o, mode: m, target: t, capabilities: [read], sinks: [go-public], feeds: [], promotion_eligible: false}
92
+ - {name: d, owner: o, mode: m, target: t, capabilities: [read], sinks: [], feeds: [], promotion_eligible: true}'
93
+ mkuap 'standing_consent:
94
+ d: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
95
+ lane "P7 a duplicate class name cannot launder an ineligible class" 1 "R1-c"
96
+
97
+ mkreg "$OKCLASS"
98
+ mkuap 'standing_consent: {ok: {granted: 2026-01-01, expires: 2020-01-01, effects: [read], target: t}}'
99
+ lane "P8 an INLINE grant is parsed, not silently read as 'nothing granted'" 1 "R5"
100
+
101
+ # P10-P11 are the round-4 fail-opens. Both PASSED before the fix, and both were confirmed against a
102
+ # control: the SAME expired grant was caught when it stood alone.
103
+ mkreg "$OKCLASS"
104
+ mkuap 'standing_consent: {}
105
+
106
+ notes in between
107
+
108
+ standing_consent:
109
+ ok: {granted: 2026-01-01, expires: 2020-01-01, effects: [read], target: t}'
110
+ lane "P10 an early empty consent block cannot shadow a later grant (first-match)" 1 "R3"
111
+
112
+ mkuap 'standing_consent:
113
+ ok: {state: "revoked ", granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
114
+ if bash "$CHK" "$TD/r.yaml" "$TD/u.md" 2>&1 | grep -q "0 active grant"; then
115
+ ok "P11 a mapping non-grant state is normalized like the scalar one (no divergent normalizer)"
116
+ else
117
+ bad "P11 \`state: \"revoked \"\` was validated as an ACTIVE grant"
118
+ bash "$CHK" "$TD/r.yaml" "$TD/u.md" 2>&1 | sed 's/^/ /'
119
+ fi
120
+
121
+ # P12 is the round-5 fail-open: `or {}` collapsed FALSY non-mappings into a valid-empty mapping
122
+ # before the type check, so `[]`, `false` and `0` all reported "no standing consent" and exit 0 —
123
+ # while the truthy `[a, b]` was caught. The falsy branch is the one an accident actually produces.
124
+ # FIXTURE FIX 2026-07-31 (cross-family): this loop wrote the UAP directly, so after the storage-form
125
+ # change it had no frontmatter and every case exited 1 via the "grant in prose" net — the right code
126
+ # for the WRONG REASON, which meant the falsy-type check it exists to guard was no longer exercised
127
+ # at all. Same shape as the defect the suite is about: a green lane that stopped measuring its
128
+ # subject. Routed through mkuap so the value lands inside the machine region.
129
+ for badval in ' []' ' false' ' 0'; do
130
+ mkreg "$OKCLASS"
131
+ mkuap "$(printf 'standing_consent:\n%s' "$badval")"
132
+ lane "P12 a falsy non-mapping standing_consent ($badval) is a type error, not 'none granted'" 1 "R3"
133
+ done
134
+
135
+ # P13/N3 — round-6 fail-open: `standing_consent :` (space or tab before the colon) is the SAME YAML
136
+ # key but matched none of the patterns. Standalone it still failed closed via the no-known-form net;
137
+ # paired with a normal empty block it was invisible to both the count and the extraction, so the
138
+ # empty block parsed and the real grant vanished. Confirmed against a control (the identical expired
139
+ # grant in canonical form was caught).
140
+ mkreg "$OKCLASS"
141
+ mkuap 'standing_consent: {}
142
+
143
+ standing_consent : {ok: {granted: 2026-01-01, expires: 2020-01-01, effects: [read], target: t}}'
144
+ lane "P13 a space-before-colon key cannot hide behind a canonical block" 1 "R3"
145
+
146
+ mkuap 'standing_consent :
147
+ ok: {granted: 2026-01-01, expires: 2020-01-01, effects: [read], target: t}'
148
+ lane "P13-b a space-before-colon BLOCK form is parsed and its grant validated" 1 "R5"
149
+
150
+ # N3 EXPECTATION INVERTED 2026-07-31 — and the reason is the whole point of the storage-form change.
151
+ # The slicer-era comment asserted that `standing_consent\t:` "is the SAME YAML key" as
152
+ # `standing_consent:`, and this lane pinned a PASS on that belief. The canonical loader refuses it:
153
+ # PyYAML rejects a tab there with a ScannerError, because YAML forbids tabs as structural whitespace.
154
+ # So the belief was false, and a regex that approximates the YAML spec had been quietly ratifying a
155
+ # document the spec rejects. Handing the region to the real loader replaces a guess with an answer —
156
+ # a malformed document is now BROKEN (fail-closed), not silently accepted as equivalent.
157
+ mkuap "$(printf 'standing_consent\t: {ok: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}}')"
158
+ lane "N3 a tab-before-colon key is invalid YAML and fails closed (loader, not regex, decides)" 1 "R3"
159
+
160
+ # P14 — round-7 fail-open: R6 presence-checked `effects`/`target` but never typed them, while the
161
+ # registry side had strict types since R1-b. A fix propagated to one side only is a hole. Control:
162
+ # a MISSING field was caught; a type-wrong one passed.
163
+ mkreg "$OKCLASS"
164
+ mkuap 'standing_consent:
165
+ ok: {granted: 2026-07-29, expires: 2026-12-31, effects: true, target: 123}'
166
+ lane "P14 a type-wrong grant scope is rejected, not counted as recorded" 1 "R6"
167
+
168
+ mkuap 'standing_consent:
169
+ ok: {granted: 2026-07-29, expires: 2026-12-31, effects: "read", target: " "}'
170
+ lane "P14-b a scalar effects / whitespace-only target is rejected" 1 "R6"
171
+
172
+ mkuap 'standing_consent:
173
+ ok: {granted: 2026-07-29, expires: 2026-12-31, effects: [], target: t}'
174
+ lane "P14-c an EMPTY effects list is not a baseline" 1 "R6"
175
+
176
+ # H1-H9 — round-8 exhaustive field audit found NINE remaining holes in one pass, after four rounds
177
+ # of one-per-round trickle. The reframe ("audit every field, don't hand me the most important one")
178
+ # is what surfaced them; the per-round drip was a slow enumeration, not convergence.
179
+ mkuap 'standing_consent: {}'
180
+ printf 'classes: false\n' > "$TD/r.yaml"
181
+ lane "H1 a falsy \`classes\` is not laundered into an empty registry" 1 ""
182
+ printf 'classes:\n' > "$TD/r.yaml"
183
+ # Expectation changed 0 -> 3 with the R9-F4 exit contract. This is NOT a lane weakened to fit a
184
+ # change: the lane's own NAME says "N/A, not a clean PASS", and 0 was the clean-PASS code. It
185
+ # asserted the opposite of what it described, which is precisely the two-codes-three-states
186
+ # conflation F4 named. 3 is the code that finally means what this lane always claimed to test.
187
+ lane "H1-b a null \`classes\` is N/A (3), not a clean PASS" 3 ""
188
+ if grep -q 'N/A' "$TD/o"; then ok "H1-c the empty registry is LABELLED N/A"; else bad "H1-c empty registry read as clean"; fi
189
+
190
+ mkreg ' - {name: n, owner: o, mode: 123, target: t, capabilities: [read], sinks: [], feeds: [], promotion_eligible: true}'
191
+ lane "H2 a non-string scalar registry field is a type error" 1 "R1-b"
192
+ mkreg ' - {name: n, owner: o, mode: m, target: "", capabilities: [read], sinks: [], feeds: [], promotion_eligible: true}'
193
+ lane "H2-b a blank registry target is a type error" 1 "R1-b"
194
+ mkreg ' - {name: n, owner: o, mode: m, target: t, capabilities: [read], sinks: [123], feeds: [], promotion_eligible: false}'
195
+ lane "H3 an unreadable sink item is UNDECLARED, not an empty sink list" 1 "R1-b"
196
+
197
+ mkreg "$OKCLASS"
198
+ mkuap 'standing_consent:
199
+ ok: {expires: 2026-12-31, effects: [read], target: t}'
200
+ lane "H5 a grant with no \`granted\` date cannot be audited" 1 "R5"
201
+ mkuap 'standing_consent:
202
+ ok: {granted: 2026-07-29, expires: 29991231, effects: [read], target: t}'
203
+ lane "H7 a non-ISO integer expiry is not a date" 1 "R5"
204
+ mkuap 'standing_consent:
205
+ ok: {granted: 2026-07-29, expires: 9999-12-31, effects: [read], target: t}'
206
+ lane "H7-b an unbounded lease is a transfer wearing a lease's clothes" 1 "R5"
207
+ mkuap 'standing_consent:
208
+ ok: {state: revokedd, granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
209
+ lane "H6 a typo'\''d state fails closed instead of passing as active" 1 "R3"
210
+ mkuap 'standing_consent:
211
+ ok: {granted: 2026-07-29, expires: 2026-12-31, effects: [repo-mutation], target: t}'
212
+ lane "H8 a grant wider than its registered capabilities is refused" 1 "R7"
213
+ mkuap 'standing_consent:
214
+ ok: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: everything}'
215
+ lane "H8-b grant/class target drift is refused" 1 "R7"
216
+ mkuap 'standing_consent:
217
+ ok: {granted: 2026-07-29, expires: 2020-01-01, effects: [read], target: t}
218
+ ok: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
219
+ lane "H9 duplicate YAML keys are rejected, not resolved last-wins" 1 ""
220
+
221
+ # H4 had a fix but NO lane — the mutation sweep caught that (disabling it failed zero lanes).
222
+ # An uncovered check is a check that gets deleted quietly later.
223
+ # NOTE the assertion is on the MESSAGE, not the rule id: with the type check disabled a numeric key
224
+ # still exits 1 via "not in the registry", so a rule-id assertion passed either way and the mutation
225
+ # sweep caught zero lanes. A lane that cannot separate two paths is not measuring the one it names.
226
+ mkuap 'standing_consent:
227
+ 123: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
228
+ bash "$CHK" "$TD/r.yaml" "$TD/u.md" >"$TD/o" 2>&1
229
+ if [ $? -eq 1 ] && grep -q 'grant key 123 must be a non-blank string' "$TD/o"; then
230
+ ok "H4 a non-string grant key is refused AS A TYPE ERROR (not merely as unregistered)"
231
+ else
232
+ bad "H4 numeric grant key was not refused by the type check"
233
+ sed 's/^/ /' "$TD/o"
234
+ fi
235
+
236
+ mkreg ' - {name: c, owner: o, mode: m, target: t, capabilities: "read", sinks: [], feeds: [], promotion_eligible: true}'
237
+ mkuap 'standing_consent: {}'
238
+ lane "P9 a scalar where a list is required is a type error" 1 "R1-b"
239
+
240
+ # N4 — PINS A KNOWN OVER-BLOCK, it does not endorse it. A grant written with a YAML merge key is
241
+ # currently REFUSED because the anchor lives outside the extracted fragment (see the residual note
242
+ # in consent_registry_check.sh). This lane asserts the CURRENT behaviour so that a future fix shows
243
+ # up as a lane change rather than being rediscovered from scratch — and so nobody reads the 40/40
244
+ # as "no known over-block".
245
+ mkreg "$OKCLASS"
246
+ mkuap 'defaults: &d {ok: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}}
247
+ standing_consent:
248
+ <<: *d'
249
+ # N4 REMOVED 2026-07-31 — it called ok() in BOTH branches, so it passed on every possible exit code
250
+ # and could not fail. A lane that cannot fail measures nothing while adding a green tick, which is
251
+ # the false-clean this suite exists to prevent (cross-family review caught it; it had been reporting
252
+ # green in a 49-lane run all along). The over-block it pinned is now GONE and is asserted for real
253
+ # by lane F4, which requires exit 0 rather than accepting anything.
254
+ lane "N4 (retired — see F4, which asserts the merge-key grant actually passes)" 0 ""
255
+
256
+ rm -f "$TD/r.yaml"
257
+ # ── R9-F4 (2026-07-31) — N/A gets its OWN exit code. ────────────────────────────────────────────
258
+ # Three states (verified / unmeasured / broken) were encoded in two exit codes and disambiguated
259
+ # only in PROSE. The exit code is the only machine-readable channel, so a caller writing the
260
+ # conventional `if check; then run_unprompted; fi` treated "there is nothing to measure" as
261
+ # "verified, go ahead" — printing "(not a PASS)" disables nothing.
262
+ #
263
+ # The fix is NOT to return 1. A missing registry is the state of every fresh clone, and failing
264
+ # there would paint the gate red on first run and train the override reflex — a failure this
265
+ # checker's own comments already record twice. `1` must keep meaning BROKEN.
266
+ #
267
+ # 3 = UNMEASURED. The asymmetry that decides the direction: the fallback here is not "block", it is
268
+ # "ask the human". Consent promotion exists only to SKIP an approval prompt, so degrading to asking
269
+ # restores the system's original behaviour and widens human-in-the-loop authority rather than
270
+ # narrowing it. Degrading to exit 0 does the opposite — it removes a human decision no one granted.
271
+ lane "D1 a missing registry is UNMEASURED (3), not verified (0)" 3 ""
272
+ if grep -q 'N/A' "$TD/o"; then
273
+ ok "D1-b the missing-registry result is LABELLED N/A, not reported as clean"
274
+ else
275
+ bad "D1-b a missing registry produced no N/A label — that reads as PASS"
276
+ fi
277
+
278
+ printf 'classes: []\n' > "$TD/r.yaml"
279
+ mkuap 'standing_consent: {}'
280
+ lane "D1-c a registry with zero classes is UNMEASURED (3)" 3 ""
281
+
282
+ mkreg "$OKCLASS"
283
+ rm -f "$TD/u.md"
284
+ lane "D1-d a missing UAP is UNMEASURED (3) — no grants is not a verified pass" 3 ""
285
+
286
+ # The contract's whole point: 0 must be reachable ONLY by a real join. If this lane and D1* ever
287
+ # return the same code again, the channel has collapsed back into prose.
288
+ mkreg "$OKCLASS"
289
+ mkuap 'standing_consent:
290
+ ok: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
291
+ lane "D1-e 0 still means VERIFIED, and only that" 0 ""
292
+
293
+ printf 'classes: [ {name: broken\n' > "$TD/r.yaml"
294
+ lane "D2 an unparseable registry fails closed (cannot decide == not allowed)" 1 ""
295
+
296
+ # ── R9 (2026-07-31, cross-family codex @ gpt-5.6-sol) — the floor read the wrong field. ──────────
297
+ # R2 intersected IRREVERSIBLE with `sinks | feeds` and NEVER with `capabilities`, so a class could
298
+ # DECLARE an irreversible capability outright and still be promotable as long as it named no sink.
299
+ # Grepped to be sure before fixing: IRREVERSIBLE appeared in exactly one place in the checker, and
300
+ # its only operand was the sink/feed union. R2's own comment gives the reason away — it says
301
+ # "naming an irreversible SINK two fields above" — the guard was written against the sink field and
302
+ # the capability field was never in scope.
303
+ #
304
+ # The local canary (qwen3.6:27b, same prompt, adequate budget) returned CONVERGED on this same diff.
305
+ # Taking that as the verdict would have declared convergence on a floor that lets history-rewrite
306
+ # through — which is exactly why the doctrine calls local tier evidence-of and never terminal.
307
+ mkreg ' - {name: rewrite, owner: o, mode: m, target: t, capabilities: [history-rewrite], sinks: [], feeds: [], promotion_eligible: true}'
308
+ mkuap 'standing_consent:
309
+ rewrite: {granted: 2026-07-29, expires: 2026-12-31, effects: [history-rewrite], target: t}'
310
+ lane "R9-1 a declared irreversible CAPABILITY is not promotable, empty sinks or not" 1 "R2-b"
311
+
312
+ # The same class WITHOUT the promotion claim is fine: declaring an irreversible capability is not
313
+ # itself an error, and blocking it would over-block a registry that merely describes what a class
314
+ # can do. Over-blocking trains the override reflex — the checker already records one such revert.
315
+ mkreg ' - {name: rewrite, owner: o, mode: m, target: t, capabilities: [history-rewrite], sinks: [], feeds: [], promotion_eligible: false}'
316
+ mkuap 'standing_consent: {}'
317
+ lane "R9-1b the same irreversible capability is fine when not claimed promotable" 0 ""
318
+
319
+ # `unknown` is in IRREVERSIBLE for the sink rule ("unlisted sinks are UNKNOWN, and unknown is not
320
+ # reversible"); it must mean the same thing in the capability position.
321
+ mkreg ' - {name: u, owner: o, mode: m, target: t, capabilities: [unknown], sinks: [], feeds: [], promotion_eligible: true}'
322
+ mkuap 'standing_consent: {}'
323
+ lane "R9-1c an \`unknown\` capability is not promotable either" 1 "R2-b"
324
+
325
+ # Guard against over-reach in the other direction: an ordinary reversible capability with empty
326
+ # sinks is the N1 shape and must stay passing. If this lane ever flips, the fix went too wide.
327
+ mkreg "$OKCLASS"
328
+ mkuap 'standing_consent:
329
+ ok: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
330
+ lane "R9-1d a reversible capability with empty sinks still passes (anti-over-block)" 0 ""
331
+
332
+ # R9-1e — the SAME field must be normalised the SAME way by every rule that reads it. R7 compares
333
+ # capabilities through `_norm` (which strips surrounding whitespace); the first cut of R2-b compared
334
+ # raw `str(x)`, so ` history-rewrite ` slipped past R2-b while R7 still recognised it — one field,
335
+ # two spellings, which is the divergent-normalizer class psa_scan_lib.sh's header is entirely about.
336
+ # Caught reviewing the diff before merge, not by the lanes above.
337
+ mkreg ' - {name: w, owner: o, mode: m, target: t, capabilities: [" history-rewrite "], sinks: [], feeds: [], promotion_eligible: true}'
338
+ mkuap 'standing_consent: {}'
339
+ lane "R9-1e whitespace around an irreversible capability does not evade R2-b" 1 "R2-b"
340
+
341
+ # The shipped example must satisfy the validator — otherwise the artifact FH hands users is the
342
+ # first counter-example. (Hand-verify-one-sample discipline, applied to our own template.)
343
+ if bash "$CHK" "$ROOT/templates/consent_classes.yaml.example" /dev/null >/dev/null 2>&1; then
344
+ ok "S1 the shipped templates/consent_classes.yaml.example validates"
345
+ else
346
+ bad "S1 the shipped example registry does NOT validate"
347
+ fi
348
+
349
+ # ---- F: STORAGE FORM (frontmatter) -------------------------------------------------
350
+ # These lanes test the machine-region boundary itself, not grant semantics, so they write the UAP
351
+ # byte-exact via mkuap_raw instead of going through the frontmatter-wrapping helper.
352
+ mkreg "$OKCLASS"
353
+ GRANT=' ok: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
354
+
355
+ # F1/F2 are the two false-clean shapes, and they are the reason this section exists. A grant that
356
+ # sits where no parser reads it must never report as "nothing granted" — an unread grant is not an
357
+ # absent one. This is the ONE net carried over from the slicer, generalized to the new form.
358
+ mkuap_raw "$(printf '# UAP\n\nstanding_consent:\n%s\n' "$GRANT")"
359
+ lane "F1 a grant in prose with NO frontmatter fails closed (never 'nothing granted')" 1 "R3"
360
+
361
+ mkuap_raw "$(printf -- '---\nsidecar_consent: granted\n---\n\n# UAP\n\nstanding_consent:\n%s\n' "$GRANT")"
362
+ lane "F2 frontmatter exists but the grant was written BELOW it — fails closed" 1 "R3"
363
+
364
+ # F3 is the anti-over-block control for F1: absence must stay cheap. A UAP predating this format has
365
+ # no machine region and grants nothing; painting that red would train the override reflex.
366
+ mkuap_raw "$(printf '# UAP\n\nno machine region here, and nothing granted.\n')"
367
+ lane "F3 no frontmatter and no grant mentioned is simply nothing granted (not a failure)" 0 ""
368
+
369
+ # F4 pins the OVER-BLOCK THIS CHANGE RETIRES. Under the slicer a merge-key grant was refused because
370
+ # the anchor lived outside the extracted fragment; the fragment is now the whole document, so
371
+ # ordinary DRY YAML resolves. Over-blocking was a defect of the same weight as a fail-open.
372
+ mkuap_raw "$(printf -- '---\ndefaults: &d {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}\nstanding_consent:\n ok:\n <<: *d\n---\n')"
373
+ lane "F4 a merge-key/anchor grant now resolves (the measured over-block is retired)" 0 ""
374
+
375
+ # F5 — duplicate keys stay rejected on this side too. safe_load is last-wins, so an expired grant
376
+ # followed by a future one would silently keep the future one.
377
+ mkuap_raw "$(printf -- '---\nstanding_consent:\n ok: {granted: 2026-01-01, expires: 2020-01-01, effects: [read], target: t}\n ok: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}\n---\n')"
378
+ lane "F5 duplicate grant keys are refused, not last-wins" 1 "R3"
379
+
380
+ # F6 — a leading BOM must not make the machine region look absent. Without the tolerance this still
381
+ # fails closed, i.e. safe, but for a misleading reason, and a confusing gate gets overridden.
382
+ mkuap_raw "$(printf -- '\357\273\277---\nstanding_consent:\n%s\n---\n' "$GRANT")"
383
+ lane "F6 a BOM before the frontmatter does not hide the machine region" 0 ""
384
+
385
+ # F7 — the region must be a mapping. A list parses fine as YAML and would otherwise be read for a
386
+ # `standing_consent` key it can never have.
387
+ mkuap_raw "$(printf -- '---\n- a\n- b\n---\n\nstanding_consent: whatever\n')"
388
+ lane "F7 a non-mapping frontmatter is BROKEN, not an empty grant set" 1 "R3"
389
+
390
+ # F8 — an unterminated block is not frontmatter. It then falls to the F1 net, which is the correct
391
+ # landing: the grant is unread, so fail closed.
392
+ mkuap_raw "$(printf -- '---\nstanding_consent:\n%s\n' "$GRANT")"
393
+ lane "F8 an unterminated --- block is not a machine region and fails closed" 1 "R3"
394
+
395
+ # F9 — an empty machine region is a real empty set, not a parse failure.
396
+ # NOTE (cross-family 2026-07-31): this lane was PASSING FOR THE WRONG REASON. The first regex could
397
+ # not match `---\n---` at all, so the file fell through to "no frontmatter, nothing mentioned" and
398
+ # went green without the empty-region path ever running. Lane F9-b below is its control: same empty
399
+ # region, but WITH a prose mention, which can only stay green if the region was really recognized.
400
+ mkuap_raw "$(printf -- '---\n---\n\n# UAP\n')"
401
+ lane "F9 an empty frontmatter block grants nothing and is not an error" 0 ""
402
+
403
+ # F9-b — the discriminating control for F9. If the empty region is NOT recognized, this file reads as
404
+ # "no frontmatter + standing_consent mentioned" and fails closed via F1. Green here means the empty
405
+ # frontmatter was genuinely parsed. (Without this control F9 cannot tell the two paths apart.)
406
+ mkuap_raw "$(printf -- '---\n---\n\n# UAP\n\nthe key name standing_consent appears in prose but not as a key.\n')"
407
+ lane "F9-b empty region + a prose MENTION is still nothing granted (region truly parsed)" 0 ""
408
+
409
+ # F10-F13 — YAML stream forms the canonical loader accepts and the first fence regex rejected. Each
410
+ # failed CLOSED (no leak) but as an OVER-BLOCK whose message blamed an absent frontmatter that was
411
+ # present. A gate that misdirects the fix gets overridden, so these are lanes, not footnotes.
412
+ EXPIRED=' ok: {granted: 2026-01-01, expires: 2020-01-01, effects: [read], target: t}'
413
+ mkreg "$OKCLASS"
414
+ mkuap_raw "$(printf -- '---\nstanding_consent:\n%s\n...\n' "$EXPIRED")"
415
+ lane "F10 a '...' document terminator closes the machine region (grant is READ, then R5-caught)" 1 "R5"
416
+
417
+ mkuap_raw "$(printf -- '--- # metadata\nstanding_consent:\n%s\n---\n' "$EXPIRED")"
418
+ lane "F11 a comment on the opening fence does not hide the region" 1 "R5"
419
+
420
+ mkuap_raw "$(printf -- '---\nstanding_consent:\n%s\n--- # end\n' "$EXPIRED")"
421
+ lane "F12 a comment on the closing fence does not hide the region" 1 "R5"
422
+
423
+ mkuap_raw "$(printf -- '%%YAML 1.2\n---\nstanding_consent:\n%s\n...\n' "$EXPIRED")"
424
+ lane "F13 a %YAML directive before the fence does not hide the region" 1 "R5"
425
+
426
+ # F14 — quoted key spelling in prose. YAML reads `"standing_consent":` as the same key, so the
427
+ # prose-region net must see it too; an anchored bare-key-only net missed it while the F1 net caught
428
+ # it, i.e. the two nets disagreed on one input.
429
+ mkuap_raw "$(printf -- '---\nsidecar_consent: granted\n---\n\n# UAP\n\n"standing_consent":\n%s\n' "$EXPIRED")"
430
+ lane "F14 a QUOTED grant key in the prose region is caught too (nets agree)" 1 "R3"
431
+
432
+ echo "----"
433
+ echo "consent-registry anchor: $pass passed, $fail failed"
434
+ [ "$fail" -eq 0 ] || exit 1
@@ -617,12 +617,71 @@ if [ ! -f "$MANIFEST" ]; then
617
617
  echo " ❌ FAIL — tracks/_meta/edit_manifest.yaml not found"
618
618
  echo " Run /edit-manifest RECORD or create the file manually."
619
619
  FAILED=1
620
- elif grep -q "date: $TODAY" "$MANIFEST" 2>/dev/null; then
621
- echo " ✅ PASS (entry for $TODAY found in edit_manifest.yaml)"
622
620
  else
623
- echo " ❌ FAIL no entry dated $TODAY in edit_manifest.yaml"
624
- echo " Run /edit-manifest RECORD to log predicted impact for today's changes."
625
- FAILED=1
621
+ # PRESENCE **AND** VALIDITY, decided by the canonical loader rather than by grep.
622
+ #
623
+ # This axis had been a pure substring grep, so it never asked whether the file it gates is the
624
+ # thing its name promises. It was not: 12 of 147 entries were invalid YAML for months, silently,
625
+ # because every consumer reads it line-wise (this hook, activity_log.sh awk, sync-to-be.sh cp).
626
+ #
627
+ # The FIRST fix for that was itself defective, and cross-family review measured all three holes —
628
+ # which is why the verdict now travels on the EXIT CODE and one Python pass owns every predicate:
629
+ # - `$(python3 ...) || echo unchecked` SWALLOWED the exit code, so any interpreter failure, on a
630
+ # manifest with a confirmed syntax error, degraded to PASS. A checker whose crash means "fine"
631
+ # is not a checker.
632
+ # - it discarded the parse RESULT, so a duplicate `date:` key (YAML is last-wins) could delete
633
+ # today's entry while still reporting valid, and a list item drifted outside its parent list
634
+ # still passed — the exact class the same commit had just repaired, left undetected.
635
+ # - the grep was substring-based: a COMMENTED `# date: <today>` counted as an entry, while a
636
+ # quoted `date: "<today>"` or extra spacing did not. Fail-open and over-block in one predicate.
637
+ # Free-form stdout is never the verdict channel either — a python3 shim printing one banner line
638
+ # would otherwise fail a healthy manifest.
639
+ MANIFEST_MSG=$(python3 - "$MANIFEST" "$TODAY" <<'PYEOF'
640
+ import sys
641
+ try:
642
+ import yaml
643
+ except Exception:
644
+ sys.exit(3) # 3 = checker unavailable (env gap, not a defect in the change)
645
+ try:
646
+ doc = yaml.safe_load(open(sys.argv[1]))
647
+ except Exception as e:
648
+ mk = getattr(e, "problem_mark", None)
649
+ print("invalid YAML at line %s" % (mk.line + 1 if mk else "?")); sys.exit(1)
650
+ if not isinstance(doc, list):
651
+ print("top level is %s, expected a list of entries" % type(doc).__name__); sys.exit(1)
652
+ for i, entry in enumerate(doc):
653
+ if not isinstance(entry, dict):
654
+ print("entry #%d is a %s, not a mapping — a list item may have drifted outside its parent"
655
+ % (i, type(entry).__name__)); sys.exit(1)
656
+ def norm(v):
657
+ return v.isoformat() if hasattr(v, "isoformat") else str(v).strip()
658
+ if not any(norm(e.get("date")) == sys.argv[2] for e in doc):
659
+ sys.exit(2) # 2 = parses fine, but no entry for today
660
+ sys.exit(0)
661
+ PYEOF
662
+ )
663
+ MANIFEST_RC=$?
664
+ case "$MANIFEST_RC" in
665
+ 0) echo " ✅ PASS (entry for $TODAY present; manifest parses and its shape is intact)" ;;
666
+ 3) # Degrade-to-advisory on purpose: a commit is a REVERSIBLE surface (CLAUDE.md
667
+ # §Irreversibility Surface-Class Degrade Invariant), and a missing PyYAML is an environment
668
+ # gap rather than a defect in the change being committed. Loud, never silent.
669
+ if grep -q "date: $TODAY" "$MANIFEST" 2>/dev/null; then
670
+ echo " ✅ PASS (entry for $TODAY found by fallback grep; validity UNCHECKED — PyYAML unavailable)"
671
+ else
672
+ echo " ❌ FAIL — no entry dated $TODAY in edit_manifest.yaml (fallback grep; PyYAML unavailable)"
673
+ echo " Run /edit-manifest RECORD to log predicted impact for today's changes."
674
+ FAILED=1
675
+ fi ;;
676
+ 2) echo " ❌ FAIL — manifest is valid YAML but has no entry dated $TODAY"
677
+ echo " Run /edit-manifest RECORD to log predicted impact for today's changes."
678
+ FAILED=1 ;;
679
+ *) echo " ❌ FAIL — edit_manifest.yaml did not validate: ${MANIFEST_MSG:-checker exited $MANIFEST_RC}"
680
+ echo " Usual causes: an unquoted value containing ': ' (quote it), or a list item indented"
681
+ echo " outside its parent list. Check:"
682
+ echo " python3 -c \"import yaml;yaml.safe_load(open('tracks/_meta/edit_manifest.yaml'))\""
683
+ FAILED=1 ;;
684
+ esac
626
685
  fi
627
686
 
628
687
  # Universal guards (defined above) — confidentiality/privacy boundary, every commit.
@@ -0,0 +1,75 @@
1
+ # consent_classes.yaml — the consent-class registry.
2
+ #
3
+ # Copy to `tracks/_meta/consent_classes.yaml` (gitignored, per-environment) to enable
4
+ # accept-side consent promotion. See `knowledge/shared/rules/operational_adaptation.md`
5
+ # §Consent promotion.
6
+ #
7
+ # WHY A REGISTRY AT ALL (cross-family review, 2026-07-29)
8
+ # The first draft let the running session name the class it was counting approvals against.
9
+ # That defeats the class-inflation guard before it fires: the class does not need to be WIDENED
10
+ # later if it was born wide. Recording three approvals as `sim-conductor` instead of
11
+ # `dispatch read-only sim on a local artifact` silently grants every future sim-conductor mode.
12
+ # So classes are DECLARED here, reviewed by a human, and the session may only count against
13
+ # an entry that already exists. A session may propose a new entry; it may not mint one mid-run.
14
+ #
15
+ # THE FLOOR IS READ FROM THIS FILE, NOT JUDGED AT RUNTIME
16
+ # `sinks` is the field the irreversibility floor is decided from. A session asking itself
17
+ # "is this reversible?" is the wrong arbiter — it is the party that wants to stop being asked.
18
+ # Absent entry / absent file / unparseable entry => NO promotion, keep asking. Unknown is not
19
+ # reversible.
20
+ #
21
+ # TAINT
22
+ # A class inherits the floor if it FEEDS an irreversible sink, not only if it crosses one.
23
+ # `local-commit` below is the worked example: committing is locally reversible, but if a
24
+ # post-commit hook, sync daemon, or CI workflow publishes from that commit, the chain crossed
25
+ # go-public and the class must be marked tainted. Declare `feeds:` honestly — the whole point
26
+ # is that no single step in a bad chain looks dangerous on its own.
27
+
28
+ classes:
29
+ # ---- promotion-eligible example -------------------------------------------------
30
+ - name: dispatch-readonly-sim-local-artifact
31
+ owner: fh-meta:sim-conductor # the gate/skill that does the asking
32
+ mode: blind-target-tier-sim
33
+ target: a single local file under the current repo
34
+ capabilities: [read, dispatch] # read · local-write · network · dispatch · repo-mutation
35
+ sinks: [] # crosses nothing irreversible
36
+ feeds: [] # enables/queues nothing irreversible
37
+ promotion_eligible: true
38
+ lease_days: 30 # standing consent is a renewable lease, never permanent
39
+
40
+ # ---- NOT promotion-eligible: sink is irreversible --------------------------------
41
+ - name: npm-publish
42
+ owner: Pre-Publish Surface Gate
43
+ mode: registry-publish
44
+ target: the package tarball file set
45
+ capabilities: [read, network]
46
+ sinks: [go-public] # irreversible => never promotes, at any count
47
+ feeds: []
48
+ promotion_eligible: false
49
+ reason: "publish is effectively irreversible — a package is live the instant it ships and may
50
+ be cached or forked before any scrub (CLAUDE.md §Pre-Publish Surface Gate)."
51
+
52
+ # ---- NOT promotion-eligible: TAINTED (this is the one that looks safe) -----------
53
+ - name: local-commit
54
+ owner: FH 4-axis commit gate
55
+ mode: git-commit
56
+ target: the working tree
57
+ capabilities: [local-write, repo-mutation]
58
+ sinks: [] # committing itself is reversible
59
+ feeds: [go-public] # ...but CI/sync publishes from it
60
+ promotion_eligible: false
61
+ reason: "taint — reversible in isolation, feeds an irreversible sink. Every step in the
62
+ workflow-edit -> release-notes -> commit -> CI-publish chain is individually
63
+ reversible; the chain is not."
64
+
65
+ # ---- NOT promotion-eligible: unclassified ---------------------------------------
66
+ - name: run-arbitrary-shell
67
+ owner: (none — catch-all)
68
+ mode: unrestricted
69
+ target: unbounded
70
+ capabilities: [read, local-write, network, dispatch, repo-mutation]
71
+ sinks: [unknown] # unknown is NOT reversible
72
+ feeds: [unknown]
73
+ promotion_eligible: false
74
+ reason: "sinks cannot be enumerated, so the floor cannot be decided mechanically. A class
75
+ whose blast radius is unbounded is never a candidate, regardless of approval count."