@chrono-meta/fh-gate 1.4.77 → 1.4.78

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.
@@ -1,390 +0,0 @@
1
- #!/usr/bin/env bash
2
- # consent_registry_check.sh — mechanical floor for accept-side consent promotion.
3
- #
4
- # WHY (cross-family review round 2, 2026-07-29)
5
- # Round 1 verdict on the prose rule was REJECT; the revision moved it to NARROW-IT, and the
6
- # reviewer's sharpest remaining point was that the revision "reads mechanical" while several of its
7
- # predicates — sink tainting, promotion eligibility, effect-subset, expiry — were still semantic.
8
- # A rule that reads as a control but cannot be checked is worse than an absent one: it buys the
9
- # confidence without the enforcement. This script is the missing half. It does not judge; it joins
10
- # `standing_consent` against the declared registry and fails closed on anything it cannot decide.
11
- #
12
- # WHAT IT ENFORCES (all mechanical — no model, no judgment)
13
- # R1 registry schema — every class carries all required fields
14
- # R2 eligibility soundness — promotion_eligible:true is FORBIDDEN when sinks/feeds are non-empty
15
- # or contain `unknown` (the taint + unknown-is-not-reversible rules)
16
- # R3 join — every standing_consent key resolves to a registered class
17
- # R4 floor — every standing_consent key resolves to a promotion_eligible class
18
- # R5 lease — every grant carries `expires` and is not past it
19
- # R6 scope — every grant records `effects` AND `target`, so the subset check has something to
20
- # compare against (a grant whose scope was never recorded cannot be re-validated)
21
- #
22
- # WHAT IT DOES *NOT* CHECK (named, so the prose above it cannot over-claim)
23
- # - the effect-SUBSET comparison itself. R6 proves a baseline was recorded; it does not compare a
24
- # live action's fingerprint against it, because this script never sees the live action. That
25
- # comparison is still a runtime obligation of the rule, i.e. still salience-dependent.
26
- # - the 3-consecutive count, retry dedupe, and same-operation identity — those live in the UAP
27
- # logger, not here.
28
- # - `excludes` / adversarial examples / independent review on a registry entry — required by the
29
- # rule, not yet mechanized. Do not read a PASS here as "the registry was reviewed."
30
- # - the existence or contents of `consent_runs.log`.
31
- # A PASS from this script means the registry and the grants are WELL-FORMED and the floor join
32
- # holds. It does not mean the promotion mechanism as a whole was verified.
33
- #
34
- # DEGRADE DIRECTION
35
- # No registry file -> exit 0, prints "N/A: promotion DISABLED" (safe: nothing can promote)
36
- # No UAP file -> exit 0, same
37
- # Unparseable either file -> exit 1 (fail-closed: cannot decide == not allowed)
38
- # Any R1-R6 violation -> exit 1
39
- #
40
- # "N/A" is printed as N/A, never as PASS — an unmeasured surface is not a clean one.
41
- #
42
- # Usage: bash scripts/consent_registry_check.sh [registry.yaml] [uap.md-or-yaml]
43
- set -uo pipefail
44
- ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
45
- REG="${1:-$ROOT/tracks/_meta/consent_classes.yaml}"
46
- UAP="${2:-$ROOT/tracks/_meta/user_adaptation_profile.md}"
47
-
48
- python3 - "$REG" "$UAP" <<'PY'
49
- import sys, os, re, datetime
50
- reg_path, uap_path = sys.argv[1], sys.argv[2]
51
-
52
- def out(sym, msg): print(f" {sym} {msg}")
53
-
54
- if not os.path.exists(reg_path):
55
- print(f"consent-registry: N/A — no registry at {reg_path}; promotion DISABLED (not a PASS)")
56
- sys.exit(0)
57
-
58
- try:
59
- import yaml
60
- except ImportError:
61
- print("consent-registry: FAIL — pyyaml unavailable, cannot validate; fail-closed")
62
- sys.exit(1)
63
-
64
- # H9 DUPLICATE YAML KEYS. yaml.safe_load is last-wins on duplicate mapping keys, so
65
- # `expires: 2020-01-01` followed by `expires: 2099-01-01` silently keeps the future one, and a
66
- # duplicated grant key keeps whichever was written last. A consent record whose meaning depends on
67
- # which duplicate a parser happens to keep is not a record. Reject duplicates at load time.
68
- class NoDupLoader(yaml.SafeLoader):
69
- pass
70
-
71
- def _no_dup(loader, node, deep=False):
72
- # Resolve `<<: *anchor` merge keys FIRST. Without this the merge key survives as a literal `<<`
73
- # entry and a perfectly ordinary DRY registry was refused (measured: a valid grant written via
74
- # a merge key exited 1 as an unregistered class). Over-blocking is not a safety win — it trains
75
- # the override reflex and turns the gate into decoration, so it counts as a defect like any
76
- # fail-open.
77
- loader.flatten_mapping(node)
78
- seen = set()
79
- for k, _ in node.value:
80
- key = loader.construct_object(k, deep=deep)
81
- try:
82
- if key in seen:
83
- raise ValueError(f"duplicate key {key!r}")
84
- except TypeError:
85
- pass
86
- seen.add(key)
87
- return yaml.SafeLoader.construct_mapping(loader, node, deep)
88
-
89
- NoDupLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _no_dup)
90
-
91
- def load(text_or_stream, what):
92
- global fails
93
- try:
94
- return yaml.load(text_or_stream, Loader=NoDupLoader)
95
- except Exception as e:
96
- print(f"consent-registry: FAIL — {what} unparseable ({e}); fail-closed")
97
- sys.exit(1)
98
-
99
- fails = 0
100
- reg = load(open(reg_path), "registry")
101
-
102
- REQUIRED = ["name", "owner", "mode", "target", "capabilities", "sinks", "feeds", "promotion_eligible"]
103
- STR_FIELDS = ["name", "owner", "mode", "target"]
104
- IRREVERSIBLE = {"go-public", "publish", "delete", "history-rewrite", "unknown"}
105
- NON_GRANT = {"declined", "unset", "revoked"}
106
- MAX_LEASE_DAYS = 365
107
-
108
- # The single normalizer for every scope comparison. Case-SENSITIVE by choice (a capability name is
109
- # an identifier, not prose) and whitespace-insensitive; the point is that one function decides, so
110
- # the two sides of a comparison can never drift apart.
111
- def _norm(s):
112
- return str(s).strip()
113
-
114
- # H1 FALSY CONTAINERS. `reg or {}` / `classes or []` laundered `false`, `0`, `[]` and a bare
115
- # `classes:` into a valid-empty registry that exited 0 — the same falsy-collapse defect already
116
- # fixed on the GRANT side, left unfixed here. Half a fix propagated is a hole. Sentinel, then type,
117
- # then default — and an empty registry is stated as N/A, never as a clean pass.
118
- if not isinstance(reg, dict):
119
- print(f"consent-registry: FAIL — registry root is {type(reg).__name__}, not a mapping; fail-closed")
120
- sys.exit(1)
121
- if "classes" not in reg:
122
- print("consent-registry: FAIL — registry has no `classes` key; fail-closed")
123
- sys.exit(1)
124
- classes = reg["classes"]
125
- if classes is None:
126
- classes = []
127
- if not isinstance(classes, list):
128
- print(f"consent-registry: FAIL — `classes` is {type(classes).__name__}, not a list; fail-closed")
129
- sys.exit(1)
130
- if not classes:
131
- print("consent-registry: N/A — registry declares zero classes; promotion DISABLED (not a PASS)")
132
- sys.exit(0)
133
-
134
- by_name = {}
135
- for i, c in enumerate(classes):
136
- if not isinstance(c, dict):
137
- out("❌", f"R1 class #{i} is not a mapping"); fails += 1; continue
138
- nm = c.get("name", f"<unnamed #{i}>")
139
- missing = [f for f in REQUIRED if f not in c]
140
- if missing:
141
- out("❌", f"R1 `{nm}` missing required field(s): {', '.join(missing)}"); fails += 1
142
- continue
143
- # R1-b STRICT TYPES. `promotion_eligible: "false"` (quoted) is a truthy STRING, so every
144
- # eligibility test below silently inverts and an intended-ineligible class becomes promotable.
145
- # Measured 2026-07-29 against a control: quoted "false" PASSed a grant that real `false` blocked.
146
- # One quote character disarmed the floor — so the type is checked, not coerced.
147
- if not isinstance(c["promotion_eligible"], bool):
148
- out("❌", f"R1-b `{nm}` promotion_eligible must be a YAML boolean, got "
149
- f"{type(c['promotion_eligible']).__name__} {c['promotion_eligible']!r} "
150
- f"(a quoted \"false\" is truthy and would invert the floor)")
151
- fails += 1; continue
152
- # H2 scalar fields: a `mode: 123` or `target: ""` described nothing, so the entry a human was
153
- # asked to review as a grant-of-future-autonomy was unreadable. H3 list ITEMS were never typed,
154
- # only their container — `sinks: [123]` counted as a declared sink.
155
- bad_field = False
156
- for fld in STR_FIELDS:
157
- if not isinstance(c[fld], str) or not c[fld].strip():
158
- out("❌", f"R1-b `{nm}` `{fld}` must be a non-blank string, got "
159
- f"{type(c[fld]).__name__} {c[fld]!r}")
160
- fails += 1; bad_field = True
161
- for fld in ("capabilities", "sinks", "feeds"):
162
- if not isinstance(c[fld], list):
163
- out("❌", f"R1-b `{nm}` `{fld}` must be a list, got {type(c[fld]).__name__}")
164
- fails += 1; bad_field = True
165
- elif not all(isinstance(x, str) and x.strip() for x in c[fld]):
166
- out("❌", f"R1-b `{nm}` `{fld}` must contain only non-blank strings, got {c[fld]!r} "
167
- f"— an unreadable sink is an UNDECLARED sink, and undeclared is unknown")
168
- fails += 1; bad_field = True
169
- if bad_field:
170
- continue
171
- # R1-c UNIQUE NAMES. Consent is keyed by class name; a duplicate silently shadowed the earlier
172
- # entry, so appending an eligible twin below an ineligible one granted the ineligible class.
173
- if nm in by_name:
174
- out("❌", f"R1-c duplicate class name `{nm}` — consent is keyed by name, so a duplicate "
175
- f"shadows the earlier entry and can launder an ineligible class")
176
- fails += 1; continue
177
- by_name[nm] = c
178
- # R2 — eligibility must be SOUND, not merely asserted. This is the line that stops a class from
179
- # declaring itself promotable while naming an irreversible sink two fields above.
180
- taint = set(map(str, c["sinks"] or [])) | set(map(str, c["feeds"] or []))
181
- bad = taint & IRREVERSIBLE
182
- if c["promotion_eligible"] and bad:
183
- out("❌", f"R2 `{nm}` claims promotion_eligible:true but sinks/feeds include {sorted(bad)}")
184
- fails += 1
185
- elif c["promotion_eligible"] and taint:
186
- out("❌", f"R2 `{nm}` claims promotion_eligible:true with non-empty sinks/feeds {sorted(taint)} "
187
- f"— unlisted sinks are UNKNOWN, and unknown is not reversible")
188
- fails += 1
189
-
190
- if fails == 0:
191
- out("✅", f"R1/R2 registry schema + eligibility soundness ({len(by_name)} class(es))")
192
-
193
- # ---- standing_consent side --------------------------------------------------------
194
- if not os.path.exists(uap_path):
195
- print(f"consent-registry: N/A — no UAP at {uap_path}; nothing granted (not a PASS)")
196
- sys.exit(1 if fails else 0)
197
-
198
- raw = open(uap_path, errors="replace").read()
199
- # Match BOTH the block form (`standing_consent:` then an indented body) and the inline flow form
200
- # (`standing_consent: {a: {...}}`). The first version matched only the block form, so an inline
201
- # grant — including an EXPIRED one — was read as "no standing consent recorded" and reported PASS.
202
- # A grant the checker cannot see is not an absent grant; that is a false clean, the worst outcome
203
- # for a floor. Measured 2026-07-29 with a control (block form caught it, inline form did not).
204
- grants = {}
205
- # FIRST-MATCH SHADOWING (cross-family round 4, confirmed against a control): reading only the first
206
- # `standing_consent:` meant a benign or empty one earlier in the file HID a real grant later in it —
207
- # `standing_consent: {}` followed by an expired grant reported PASS, while the same expired grant
208
- # alone was caught. A checker that stops at the first occurrence is trivially defeated by appending.
209
- # There is exactly one consent block or the file is not decidable.
210
- # YAML-KEY EQUIVALENCE (round 6): `standing_consent : {...}` — a space or tab before the colon —
211
- # is the SAME YAML key but did not match `^standing_consent:`. Standalone it still failed closed
212
- # via the no-known-form net below; but paired with a normal empty block it was invisible to both
213
- # the count and the extraction, so the empty block was parsed and the real grant vanished. All
214
- # three patterns now allow `[ \t]*` before the colon.
215
- # RESIDUAL — MEASURED OVER-BLOCK (2026-07-29): a grant written with a YAML merge key
216
- # (`defaults: &d {...}` + `standing_consent:\n <<: *d`) is REFUSED, because the anchor `&d` lives
217
- # outside the extracted fragment and the alias is undefined when the fragment is re-parsed alone.
218
- # That is ordinary DRY YAML, and over-blocking is a defect of the same weight as a fail-open: it
219
- # trains the override reflex and turns the gate into decoration. A whole-file-parse-first path was
220
- # attempted and reverted the same session — it regressed 16 of 40 anchor lanes, and shipping a
221
- # broken floor to close an over-block is a worse trade. Anchor lane N4 pins the current behaviour so
222
- # the next attempt is a measured delta, not a rediscovery.
223
- # RESIDUAL (named): this is regex-scraping a YAML key out of a markdown file, so it approximates
224
- # the YAML spec rather than implementing it (quoted keys, anchors, multi-doc streams are not
225
- # handled). The mitigation is the fail-closed net below, not a claim of completeness.
226
- occurrences = re.findall(r"^standing_consent[ \t]*:", raw, re.M)
227
- if len(occurrences) > 1:
228
- out("❌", f"R3 the UAP declares `standing_consent` {len(occurrences)} times — ambiguous which "
229
- f"is authoritative, and an early benign block would shadow a later grant; fail-closed")
230
- fails += 1
231
- body = None
232
- grants = None
233
- else:
234
- m = re.search(r"^standing_consent[ \t]*:[ \t]*(\{.*)$", raw, re.M) # inline flow form
235
- if not m:
236
- m = re.search(r"^standing_consent[ \t]*:[ \t]*$(.*?)(?=^\S|\Z)", raw, re.M | re.S) # block form
237
- body = ("standing_consent:\n" + m.group(1)) if m else None # re-emitted canonically
238
- else:
239
- body = "standing_consent: " + m.group(1)
240
- if grants is not None and body is not None:
241
- try:
242
- # NoDupLoader here too. The duplicate-key rejection was added on the registry side and
243
- # NOT here in the same edit — the third half-fix of this session, committed while
244
- # fixing a half-fix. Duplicate grant keys were still last-wins: an expired grant
245
- # followed by a future one silently kept the future one.
246
- parsed = yaml.load(body, Loader=NoDupLoader)
247
- # NO `or {}` HERE. `[] or {}` / `False or {}` / `0 or {}` all evaluate to `{}`, which reached
248
- # the type check already laundered into a valid-empty mapping — so `standing_consent:\n []`
249
- # reported "no standing consent recorded" and exit 0, while the truthy `[a, b]` was caught
250
- # (measured with that control, cross-family round 5). The falsy branch is exactly the one an
251
- # accident produces. Sentinel first, type check second, defaulting last.
252
- grants = (parsed if isinstance(parsed, dict) else {}).get("standing_consent", {})
253
- if grants is None:
254
- grants = {} # an explicitly empty `standing_consent:` key is a real empty set
255
- if not isinstance(grants, dict):
256
- out("❌", f"R3 standing_consent is not a mapping ({type(grants).__name__}); fail-closed")
257
- fails += 1; grants = None
258
- except Exception as e:
259
- out("❌", f"R3 standing_consent block unparseable ({e}); fail-closed"); fails += 1
260
- grants = None
261
- elif re.search(r"standing_consent", raw):
262
- # The key appears but neither form matched — do not silently read that as "nothing granted".
263
- out("❌", "R3 `standing_consent` appears in the UAP but matched no known form; fail-closed")
264
- fails += 1; grants = None
265
-
266
- if grants is None:
267
- pass
268
- elif not grants:
269
- out("✅", "R3-R6 no standing consent recorded (nothing to validate)")
270
- else:
271
- today = datetime.date.today()
272
- validated = 0
273
- for name, g in grants.items():
274
- # H4 grant key: registry `name: 123` + UAP `123:` used to join fine because neither side was
275
- # typed. Both sides are strings or the join is meaningless.
276
- if not isinstance(name, str) or not name.strip():
277
- out("❌", f"R3 grant key {name!r} must be a non-blank string"); fails += 1; continue
278
- # `declined` / `unset` / `revoked` are first-class states in this storage model, not grants.
279
- # Coercing them into `{"granted": ...}` turned a legitimate refusal into a malformed grant
280
- # and reported violations against a user who said no.
281
- if isinstance(g, str) and g.strip().lower() in NON_GRANT:
282
- continue
283
- if not isinstance(g, dict):
284
- out("❌", f"R3 `{name}` has value {g!r} — not a grant mapping and not one of "
285
- f"{sorted(NON_GRANT)}; fail-closed")
286
- fails += 1; continue
287
- # Normalize identically to the scalar branch above. Without .strip() a `state: "revoked "`
288
- # fell through and was validated as an ACTIVE grant (cross-family round 4) — two spellings of
289
- # the same state judged by two different normalizers is the divergent-normalizer class.
290
- # H6 UNKNOWN STATE. Only the three canonical states were recognized; anything else — a typo
291
- # like `revokedd`, or a state a future version adds — fell through and was validated as an
292
- # ACTIVE grant. A record whose state we cannot read is not a grant we may honour.
293
- if "state" in g:
294
- st = g["state"]
295
- if not isinstance(st, str) or st.strip().lower() not in (NON_GRANT | {"granted"}):
296
- out("❌", f"R3 `{name}` has unrecognized state {st!r} — expected one of "
297
- f"{sorted(NON_GRANT | {'granted'})}; fail-closed")
298
- fails += 1; continue
299
- if st.strip().lower() in NON_GRANT:
300
- continue
301
- validated += 1
302
- c = by_name.get(name)
303
- if c is None:
304
- out("❌", f"R3 `{name}` granted but NOT in the registry (unregistered == unknown)"); fails += 1; continue
305
- if not c.get("promotion_eligible"):
306
- out("❌", f"R4 `{name}` granted but registry says promotion_eligible:false"); fails += 1; continue
307
- # H5 `granted` was never checked at all — a grant with no grant date cannot be audited
308
- # against the three approvals that were supposed to produce it.
309
- def _date(v, fld):
310
- if isinstance(v, datetime.date):
311
- return v
312
- if isinstance(v, str) and re.fullmatch(r"\d{4}-\d{2}-\d{2}", v.strip()):
313
- try:
314
- return datetime.date.fromisoformat(v.strip())
315
- except ValueError:
316
- return None
317
- return None # ints like 29991231 are NOT dates; basic-format parsing accepted them
318
- gd = _date(g.get("granted"), "granted")
319
- if gd is None:
320
- out("❌", f"R5 `{name}` has missing or non-ISO `granted` ({g.get('granted')!r}) — "
321
- f"expected YYYY-MM-DD"); fails += 1
322
- elif gd > today:
323
- # A consent dated in the future has not been given. `granted: 2099-01-01` passed.
324
- out("❌", f"R5 `{name}` is `granted` {gd}, in the FUTURE — a consent that has not "
325
- f"happened yet cannot authorize anything"); fails += 1
326
- exp = g.get("expires")
327
- if exp is None:
328
- out("❌", f"R5 `{name}` granted with no `expires` — standing consent is a lease, not a transfer"); fails += 1
329
- else:
330
- d = _date(exp, "expires")
331
- if d is None:
332
- out("❌", f"R5 `{name}` has non-ISO expires={exp!r} — expected YYYY-MM-DD "
333
- f"(`29991231` parsed as a date under basic-format rules and slipped through)")
334
- fails += 1
335
- elif d < today:
336
- out("❌", f"R5 `{name}` expired {d} — must lapse to unset, not keep running"); fails += 1
337
- # H7 LEASE BOUND. `expires: 9999-12-31` satisfied "has an expiry" while defeating the
338
- # entire point of one. A lease longer than the maximum is a transfer wearing a lease's
339
- # clothes.
340
- elif gd is not None and (d - gd).days > MAX_LEASE_DAYS:
341
- out("❌", f"R5 `{name}` lease is {(d - gd).days} days (max {MAX_LEASE_DAYS}) — "
342
- f"an unbounded expiry is a transfer, not a lease"); fails += 1
343
- # R6 — a grant with no recorded scope can never be re-validated against a drifted action.
344
- # R6 — presence AND type. The registry side got strict types at R1-b; the grant side did not,
345
- # so `effects: true` / `target: 123` were "recorded" and passed while a MISSING field was
346
- # caught (measured with that control, cross-family round 7). Half a fix propagated is a hole:
347
- # a baseline that is not a list-of-effects and a real target string cannot be compared against
348
- # anything later, which is the entire purpose of recording it.
349
- eff, tgt = g.get("effects"), g.get("target")
350
- if eff is None or tgt is None:
351
- out("❌", f"R6 `{name}` grant records no `effects`+`target` — the subset check has no baseline")
352
- fails += 1
353
- else:
354
- if not isinstance(eff, list) or not eff or not all(isinstance(e, str) and e.strip() for e in eff):
355
- out("❌", f"R6 `{name}` `effects` must be a non-empty list of strings, got "
356
- f"{type(eff).__name__} {eff!r} — an uncomparable baseline is not a baseline")
357
- fails += 1
358
- if not isinstance(tgt, str) or not tgt.strip():
359
- out("❌", f"R6 `{name}` `target` must be a non-empty string, got "
360
- f"{type(tgt).__name__} {tgt!r}")
361
- fails += 1
362
- # R7 STORED-SCOPE SUBSET. The grant recorded its own scope and nothing compared it to
363
- # what the registry actually authorizes, so `effects: [repo-mutation]` sat happily under
364
- # a `capabilities: [read]` class. This is the mechanizable HALF of the subset rule: it
365
- # cannot see a LIVE action (still a runtime obligation, still named as residual), but a
366
- # STORED grant wider than its own class is checkable right here — and was not checked.
367
- if isinstance(eff, list) and all(isinstance(e, str) for e in eff):
368
- # One normalizer, both sides. Before this the grant side was stripped and the
369
- # registry side was not, so `[" read "]` matched while `[READ]` did not — whitespace
370
- # forgiving, case strict, for no stated reason. Divergent normalizers on the two
371
- # sides of a comparison is the same defect class this file already fixed twice.
372
- over = sorted({_norm(e) for e in eff} - {_norm(x) for x in c["capabilities"]})
373
- if over:
374
- out("❌", f"R7 `{name}` grant claims effect(s) {over} outside its registered "
375
- f"capabilities {c['capabilities']} — the grant is wider than the class")
376
- fails += 1
377
- if isinstance(tgt, str) and _norm(tgt) != _norm(str(c["target"])):
378
- out("❌", f"R7 `{name}` grant target {tgt!r} does not match the registered target "
379
- f"{c['target']!r} — scope drift between grant and class")
380
- fails += 1
381
- if fails == 0:
382
- skipped = len(grants) - validated
383
- note = f" ({skipped} non-grant state(s) skipped)" if skipped else ""
384
- out("✅", f"R3-R6 all {validated} active grant(s) registered, eligible, unexpired, "
385
- f"scope-recorded{note}")
386
-
387
- print("----")
388
- print(f"consent-registry: {'PASS' if fails == 0 else f'{fails} violation(s)'}")
389
- sys.exit(0 if fails == 0 else 1)
390
- PY
@@ -1,255 +0,0 @@
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
- mkuap() { printf '%s\n' "$1" > "$TD/u.md"; }
49
-
50
- mkreg "$OKCLASS"
51
- mkuap 'standing_consent:
52
- ok: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
53
- lane "N1 a well-formed registry + eligible unexpired scoped grant passes" 0 ""
54
-
55
- mkuap 'standing_consent:
56
- ok: declined'
57
- lane "N2 a declined record is a valid non-grant state, not a malformed grant" 0 ""
58
-
59
- mkreg ' - {name: s, owner: o, mode: m, target: t, capabilities: [read], sinks: [go-public], feeds: [], promotion_eligible: true}'
60
- mkuap 'standing_consent: {}'
61
- lane "P1 a class naming an irreversible SINK cannot declare itself promotable" 1 "R2"
62
-
63
- mkreg ' - {name: f, owner: o, mode: m, target: t, capabilities: [read], sinks: [], feeds: [go-public], promotion_eligible: true}'
64
- lane "P2 taint — a class that FEEDS an irreversible sink cannot be promotable" 1 "R2"
65
-
66
- mkreg "$OKCLASS"
67
- mkuap 'standing_consent:
68
- ghost: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
69
- lane "P3 a grant on an unregistered class is refused (unregistered == unknown)" 1 "R3"
70
-
71
- mkuap 'standing_consent:
72
- ok: {granted: 2026-01-01, expires: 2026-06-30, effects: [read], target: t}'
73
- lane "P4 an expired lease does not keep running" 1 "R5"
74
-
75
- mkuap 'standing_consent:
76
- ok: {granted: 2026-07-29, expires: 2026-12-31}'
77
- lane "P5 a grant with no recorded scope has no re-validation baseline" 1 "R6"
78
-
79
- # P6-P8 are the round-3 fail-opens. Each of these PASSED before the fix.
80
- mkreg ' - {name: q, owner: o, mode: m, target: t, capabilities: [read], sinks: [], feeds: [], promotion_eligible: "false"}'
81
- mkuap 'standing_consent:
82
- q: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
83
- lane "P6 quoted \"false\" is rejected as a type error, not read as truthy" 1 "R1-b"
84
-
85
- mkreg ' - {name: d, owner: o, mode: m, target: t, capabilities: [read], sinks: [go-public], feeds: [], promotion_eligible: false}
86
- - {name: d, owner: o, mode: m, target: t, capabilities: [read], sinks: [], feeds: [], promotion_eligible: true}'
87
- mkuap 'standing_consent:
88
- d: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
89
- lane "P7 a duplicate class name cannot launder an ineligible class" 1 "R1-c"
90
-
91
- mkreg "$OKCLASS"
92
- mkuap 'standing_consent: {ok: {granted: 2026-01-01, expires: 2020-01-01, effects: [read], target: t}}'
93
- lane "P8 an INLINE grant is parsed, not silently read as 'nothing granted'" 1 "R5"
94
-
95
- # P10-P11 are the round-4 fail-opens. Both PASSED before the fix, and both were confirmed against a
96
- # control: the SAME expired grant was caught when it stood alone.
97
- mkreg "$OKCLASS"
98
- mkuap 'standing_consent: {}
99
-
100
- notes in between
101
-
102
- standing_consent:
103
- ok: {granted: 2026-01-01, expires: 2020-01-01, effects: [read], target: t}'
104
- lane "P10 an early empty consent block cannot shadow a later grant (first-match)" 1 "R3"
105
-
106
- mkuap 'standing_consent:
107
- ok: {state: "revoked ", granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
108
- if bash "$CHK" "$TD/r.yaml" "$TD/u.md" 2>&1 | grep -q "0 active grant"; then
109
- ok "P11 a mapping non-grant state is normalized like the scalar one (no divergent normalizer)"
110
- else
111
- bad "P11 \`state: \"revoked \"\` was validated as an ACTIVE grant"
112
- bash "$CHK" "$TD/r.yaml" "$TD/u.md" 2>&1 | sed 's/^/ /'
113
- fi
114
-
115
- # P12 is the round-5 fail-open: `or {}` collapsed FALSY non-mappings into a valid-empty mapping
116
- # before the type check, so `[]`, `false` and `0` all reported "no standing consent" and exit 0 —
117
- # while the truthy `[a, b]` was caught. The falsy branch is the one an accident actually produces.
118
- for badval in ' []' ' false' ' 0'; do
119
- mkreg "$OKCLASS"
120
- printf 'standing_consent:\n%s\n' "$badval" > "$TD/u.md"
121
- lane "P12 a falsy non-mapping standing_consent ($badval) is a type error, not 'none granted'" 1 "R3"
122
- done
123
-
124
- # P13/N3 — round-6 fail-open: `standing_consent :` (space or tab before the colon) is the SAME YAML
125
- # key but matched none of the patterns. Standalone it still failed closed via the no-known-form net;
126
- # paired with a normal empty block it was invisible to both the count and the extraction, so the
127
- # empty block parsed and the real grant vanished. Confirmed against a control (the identical expired
128
- # grant in canonical form was caught).
129
- mkreg "$OKCLASS"
130
- mkuap 'standing_consent: {}
131
-
132
- standing_consent : {ok: {granted: 2026-01-01, expires: 2020-01-01, effects: [read], target: t}}'
133
- lane "P13 a space-before-colon key cannot hide behind a canonical block" 1 "R3"
134
-
135
- mkuap 'standing_consent :
136
- ok: {granted: 2026-01-01, expires: 2020-01-01, effects: [read], target: t}'
137
- lane "P13-b a space-before-colon BLOCK form is parsed and its grant validated" 1 "R5"
138
-
139
- mkuap "$(printf 'standing_consent\t: {ok: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}}')"
140
- lane "N3 a tab-before-colon key with a VALID grant passes (no over-block)" 0 ""
141
-
142
- # P14 — round-7 fail-open: R6 presence-checked `effects`/`target` but never typed them, while the
143
- # registry side had strict types since R1-b. A fix propagated to one side only is a hole. Control:
144
- # a MISSING field was caught; a type-wrong one passed.
145
- mkreg "$OKCLASS"
146
- mkuap 'standing_consent:
147
- ok: {granted: 2026-07-29, expires: 2026-12-31, effects: true, target: 123}'
148
- lane "P14 a type-wrong grant scope is rejected, not counted as recorded" 1 "R6"
149
-
150
- mkuap 'standing_consent:
151
- ok: {granted: 2026-07-29, expires: 2026-12-31, effects: "read", target: " "}'
152
- lane "P14-b a scalar effects / whitespace-only target is rejected" 1 "R6"
153
-
154
- mkuap 'standing_consent:
155
- ok: {granted: 2026-07-29, expires: 2026-12-31, effects: [], target: t}'
156
- lane "P14-c an EMPTY effects list is not a baseline" 1 "R6"
157
-
158
- # H1-H9 — round-8 exhaustive field audit found NINE remaining holes in one pass, after four rounds
159
- # of one-per-round trickle. The reframe ("audit every field, don't hand me the most important one")
160
- # is what surfaced them; the per-round drip was a slow enumeration, not convergence.
161
- mkuap 'standing_consent: {}'
162
- printf 'classes: false\n' > "$TD/r.yaml"
163
- lane "H1 a falsy \`classes\` is not laundered into an empty registry" 1 ""
164
- printf 'classes:\n' > "$TD/r.yaml"
165
- lane "H1-b a null \`classes\` is N/A, not a clean PASS" 0 ""
166
- 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
167
-
168
- mkreg ' - {name: n, owner: o, mode: 123, target: t, capabilities: [read], sinks: [], feeds: [], promotion_eligible: true}'
169
- lane "H2 a non-string scalar registry field is a type error" 1 "R1-b"
170
- mkreg ' - {name: n, owner: o, mode: m, target: "", capabilities: [read], sinks: [], feeds: [], promotion_eligible: true}'
171
- lane "H2-b a blank registry target is a type error" 1 "R1-b"
172
- mkreg ' - {name: n, owner: o, mode: m, target: t, capabilities: [read], sinks: [123], feeds: [], promotion_eligible: false}'
173
- lane "H3 an unreadable sink item is UNDECLARED, not an empty sink list" 1 "R1-b"
174
-
175
- mkreg "$OKCLASS"
176
- mkuap 'standing_consent:
177
- ok: {expires: 2026-12-31, effects: [read], target: t}'
178
- lane "H5 a grant with no \`granted\` date cannot be audited" 1 "R5"
179
- mkuap 'standing_consent:
180
- ok: {granted: 2026-07-29, expires: 29991231, effects: [read], target: t}'
181
- lane "H7 a non-ISO integer expiry is not a date" 1 "R5"
182
- mkuap 'standing_consent:
183
- ok: {granted: 2026-07-29, expires: 9999-12-31, effects: [read], target: t}'
184
- lane "H7-b an unbounded lease is a transfer wearing a lease's clothes" 1 "R5"
185
- mkuap 'standing_consent:
186
- ok: {state: revokedd, granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
187
- lane "H6 a typo'\''d state fails closed instead of passing as active" 1 "R3"
188
- mkuap 'standing_consent:
189
- ok: {granted: 2026-07-29, expires: 2026-12-31, effects: [repo-mutation], target: t}'
190
- lane "H8 a grant wider than its registered capabilities is refused" 1 "R7"
191
- mkuap 'standing_consent:
192
- ok: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: everything}'
193
- lane "H8-b grant/class target drift is refused" 1 "R7"
194
- mkuap 'standing_consent:
195
- ok: {granted: 2026-07-29, expires: 2020-01-01, effects: [read], target: t}
196
- ok: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
197
- lane "H9 duplicate YAML keys are rejected, not resolved last-wins" 1 ""
198
-
199
- # H4 had a fix but NO lane — the mutation sweep caught that (disabling it failed zero lanes).
200
- # An uncovered check is a check that gets deleted quietly later.
201
- # NOTE the assertion is on the MESSAGE, not the rule id: with the type check disabled a numeric key
202
- # still exits 1 via "not in the registry", so a rule-id assertion passed either way and the mutation
203
- # sweep caught zero lanes. A lane that cannot separate two paths is not measuring the one it names.
204
- mkuap 'standing_consent:
205
- 123: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
206
- bash "$CHK" "$TD/r.yaml" "$TD/u.md" >"$TD/o" 2>&1
207
- if [ $? -eq 1 ] && grep -q 'grant key 123 must be a non-blank string' "$TD/o"; then
208
- ok "H4 a non-string grant key is refused AS A TYPE ERROR (not merely as unregistered)"
209
- else
210
- bad "H4 numeric grant key was not refused by the type check"
211
- sed 's/^/ /' "$TD/o"
212
- fi
213
-
214
- mkreg ' - {name: c, owner: o, mode: m, target: t, capabilities: "read", sinks: [], feeds: [], promotion_eligible: true}'
215
- mkuap 'standing_consent: {}'
216
- lane "P9 a scalar where a list is required is a type error" 1 "R1-b"
217
-
218
- # N4 — PINS A KNOWN OVER-BLOCK, it does not endorse it. A grant written with a YAML merge key is
219
- # currently REFUSED because the anchor lives outside the extracted fragment (see the residual note
220
- # in consent_registry_check.sh). This lane asserts the CURRENT behaviour so that a future fix shows
221
- # up as a lane change rather than being rediscovered from scratch — and so nobody reads the 40/40
222
- # as "no known over-block".
223
- mkreg "$OKCLASS"
224
- mkuap 'defaults: &d {ok: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}}
225
- standing_consent:
226
- <<: *d'
227
- bash "$CHK" "$TD/r.yaml" "$TD/u.md" >"$TD/o" 2>&1
228
- if [ $? -eq 1 ]; then
229
- ok "N4 merge-key grant is refused — KNOWN OVER-BLOCK, pinned not endorsed (see residual note)"
230
- else
231
- ok "N4 merge-key grant now PASSES — the over-block was fixed; update this lane and the residual note"
232
- fi
233
-
234
- rm -f "$TD/r.yaml"
235
- lane "D1 a missing registry is N/A with promotion disabled (never a silent PASS)" 0 ""
236
- if grep -q 'N/A' "$TD/o"; then
237
- ok "D1-b the missing-registry result is LABELLED N/A, not reported as clean"
238
- else
239
- bad "D1-b a missing registry produced exit 0 without an N/A label — that reads as PASS"
240
- fi
241
-
242
- printf 'classes: [ {name: broken\n' > "$TD/r.yaml"
243
- lane "D2 an unparseable registry fails closed (cannot decide == not allowed)" 1 ""
244
-
245
- # The shipped example must satisfy the validator — otherwise the artifact FH hands users is the
246
- # first counter-example. (Hand-verify-one-sample discipline, applied to our own template.)
247
- if bash "$CHK" "$ROOT/templates/consent_classes.yaml.example" /dev/null >/dev/null 2>&1; then
248
- ok "S1 the shipped templates/consent_classes.yaml.example validates"
249
- else
250
- bad "S1 the shipped example registry does NOT validate"
251
- fi
252
-
253
- echo "----"
254
- echo "consent-registry anchor: $pass passed, $fail failed"
255
- [ "$fail" -eq 0 ] || exit 1