@chrono-meta/fh-gate 1.4.78 → 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.
- package/.claude-plugin/marketplace.json +2 -2
- package/CLAUDE.md +7 -2
- package/knowledge/shared/harness-core/loop_engineering.md +1 -1
- package/knowledge/shared/rules/operational_adaptation.md +144 -1
- package/package.json +12 -2
- package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
- package/scripts/consent_registry_check.sh +472 -0
- package/scripts/fh_session_load.sh +42 -3
- package/scripts/halffix_propagation_scan.sh +126 -0
- package/scripts/package_coverage_check.sh +18 -1
- package/scripts/pipe_verdict_guard.sh +93 -0
- package/scripts/selfcheck.sh +99 -6
- package/scripts/session_close_check.sh +15 -1
- package/scripts/sidecar_calibrate.sh +85 -0
- package/scripts/test_card_drift_probe.sh +55 -0
- package/scripts/test_consent_registry.sh +434 -0
- package/scripts/test_halffix_lanes.sh +170 -0
- package/scripts/test_ollama_panel_lanes.sh +120 -0
- package/scripts/test_package_coverage_lanes.sh +250 -0
- package/scripts/test_pipe_verdict_guard_lanes.sh +96 -0
- package/scripts/test_sidecar_wait_stdin.sh +13 -1
- package/templates/.git-hooks/pre-commit +73 -5
- package/templates/consent_classes.yaml.example +75 -0
- package/templates/settings.PreToolUse.snippet.json +49 -0
|
@@ -0,0 +1,472 @@
|
|
|
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
|
+
# EXIT CONTRACT (typed — three states, three codes; do NOT grep the prose)
|
|
35
|
+
# 0 = VERIFIED the registry and the grants are well-formed and the floor join holds
|
|
36
|
+
# 3 = UNMEASURED there was nothing to join (no registry / zero classes / no UAP)
|
|
37
|
+
# 1 = BROKEN unparseable or invalid input; cannot decide == not allowed
|
|
38
|
+
#
|
|
39
|
+
# A caller may skip an approval prompt ONLY on 0. On 3 it must KEEP ASKING.
|
|
40
|
+
#
|
|
41
|
+
# WHY 3 EXISTS (added 2026-07-31, cross-family round 9 finding F4)
|
|
42
|
+
# Three states were encoded in two codes and disambiguated only in PROSE. The exit code is the
|
|
43
|
+
# only machine-readable channel, so the conventional caller —
|
|
44
|
+
# if scripts/consent_registry_check.sh; then run_unprompted; fi
|
|
45
|
+
# — read "there is nothing to measure" as "verified, go ahead". Printing "(not a PASS)" disables
|
|
46
|
+
# nothing. `not found` is not `0` (CLAUDE.md §Instrument-Calibration), and this file's own line
|
|
47
|
+
# below already said so in words while returning the PASS code.
|
|
48
|
+
#
|
|
49
|
+
# WHY NOT 1 — a missing registry is the state of every fresh clone. Failing there would paint the
|
|
50
|
+
# gate red on first run and train the override reflex, a failure this file's comments already
|
|
51
|
+
# record twice (the merge-key over-block, and the reverted parser fix). 1 stays BROKEN.
|
|
52
|
+
#
|
|
53
|
+
# WHY 3 IS THE SAFE DIRECTION — the fallback here is not "block", it is "ask the human". Consent
|
|
54
|
+
# promotion exists only to SKIP an approval prompt, so degrading to asking restores the system's
|
|
55
|
+
# original behaviour and WIDENS human-in-the-loop authority. Degrading to 0 narrows it, removing
|
|
56
|
+
# a human decision nobody granted.
|
|
57
|
+
#
|
|
58
|
+
# DEGRADE DIRECTION
|
|
59
|
+
# No registry file -> exit 3, prints "N/A: promotion DISABLED" (unmeasured, keep asking)
|
|
60
|
+
# No UAP file -> exit 3, same
|
|
61
|
+
# Unparseable either file -> exit 1 (fail-closed: cannot decide == not allowed)
|
|
62
|
+
# Any R1-R6 violation -> exit 1
|
|
63
|
+
#
|
|
64
|
+
# "N/A" is printed as N/A, never as PASS — an unmeasured surface is not a clean one.
|
|
65
|
+
#
|
|
66
|
+
# Usage: bash scripts/consent_registry_check.sh [registry.yaml] [uap.md-or-yaml]
|
|
67
|
+
set -uo pipefail
|
|
68
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
69
|
+
REG="${1:-$ROOT/tracks/_meta/consent_classes.yaml}"
|
|
70
|
+
UAP="${2:-$ROOT/tracks/_meta/user_adaptation_profile.md}"
|
|
71
|
+
|
|
72
|
+
python3 - "$REG" "$UAP" <<'PY'
|
|
73
|
+
import sys, os, re, datetime
|
|
74
|
+
reg_path, uap_path = sys.argv[1], sys.argv[2]
|
|
75
|
+
|
|
76
|
+
def out(sym, msg): print(f" {sym} {msg}")
|
|
77
|
+
|
|
78
|
+
if not os.path.exists(reg_path):
|
|
79
|
+
print(f"consent-registry: N/A — no registry at {reg_path}; promotion DISABLED (not a PASS)")
|
|
80
|
+
sys.exit(3) # UNMEASURED, not verified — see EXIT CONTRACT
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
import yaml
|
|
84
|
+
except ImportError:
|
|
85
|
+
print("consent-registry: FAIL — pyyaml unavailable, cannot validate; fail-closed")
|
|
86
|
+
sys.exit(1)
|
|
87
|
+
|
|
88
|
+
# H9 DUPLICATE YAML KEYS. yaml.safe_load is last-wins on duplicate mapping keys, so
|
|
89
|
+
# `expires: 2020-01-01` followed by `expires: 2099-01-01` silently keeps the future one, and a
|
|
90
|
+
# duplicated grant key keeps whichever was written last. A consent record whose meaning depends on
|
|
91
|
+
# which duplicate a parser happens to keep is not a record. Reject duplicates at load time.
|
|
92
|
+
class NoDupLoader(yaml.SafeLoader):
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
def _no_dup(loader, node, deep=False):
|
|
96
|
+
# Resolve `<<: *anchor` merge keys FIRST. Without this the merge key survives as a literal `<<`
|
|
97
|
+
# entry and a perfectly ordinary DRY registry was refused (measured: a valid grant written via
|
|
98
|
+
# a merge key exited 1 as an unregistered class). Over-blocking is not a safety win — it trains
|
|
99
|
+
# the override reflex and turns the gate into decoration, so it counts as a defect like any
|
|
100
|
+
# fail-open.
|
|
101
|
+
loader.flatten_mapping(node)
|
|
102
|
+
seen = set()
|
|
103
|
+
for k, _ in node.value:
|
|
104
|
+
key = loader.construct_object(k, deep=deep)
|
|
105
|
+
try:
|
|
106
|
+
if key in seen:
|
|
107
|
+
raise ValueError(f"duplicate key {key!r}")
|
|
108
|
+
except TypeError:
|
|
109
|
+
pass
|
|
110
|
+
seen.add(key)
|
|
111
|
+
return yaml.SafeLoader.construct_mapping(loader, node, deep)
|
|
112
|
+
|
|
113
|
+
NoDupLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _no_dup)
|
|
114
|
+
|
|
115
|
+
def load(text_or_stream, what):
|
|
116
|
+
global fails
|
|
117
|
+
try:
|
|
118
|
+
return yaml.load(text_or_stream, Loader=NoDupLoader)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
print(f"consent-registry: FAIL — {what} unparseable ({e}); fail-closed")
|
|
121
|
+
sys.exit(1)
|
|
122
|
+
|
|
123
|
+
fails = 0
|
|
124
|
+
reg = load(open(reg_path), "registry")
|
|
125
|
+
|
|
126
|
+
REQUIRED = ["name", "owner", "mode", "target", "capabilities", "sinks", "feeds", "promotion_eligible"]
|
|
127
|
+
STR_FIELDS = ["name", "owner", "mode", "target"]
|
|
128
|
+
IRREVERSIBLE = {"go-public", "publish", "delete", "history-rewrite", "unknown"}
|
|
129
|
+
NON_GRANT = {"declined", "unset", "revoked"}
|
|
130
|
+
MAX_LEASE_DAYS = 365
|
|
131
|
+
|
|
132
|
+
# The single normalizer for every scope comparison. Case-SENSITIVE by choice (a capability name is
|
|
133
|
+
# an identifier, not prose) and whitespace-insensitive; the point is that one function decides, so
|
|
134
|
+
# the two sides of a comparison can never drift apart.
|
|
135
|
+
def _norm(s):
|
|
136
|
+
return str(s).strip()
|
|
137
|
+
|
|
138
|
+
# H1 FALSY CONTAINERS. `reg or {}` / `classes or []` laundered `false`, `0`, `[]` and a bare
|
|
139
|
+
# `classes:` into a valid-empty registry that exited 0 — the same falsy-collapse defect already
|
|
140
|
+
# fixed on the GRANT side, left unfixed here. Half a fix propagated is a hole. Sentinel, then type,
|
|
141
|
+
# then default — and an empty registry is stated as N/A, never as a clean pass.
|
|
142
|
+
if not isinstance(reg, dict):
|
|
143
|
+
print(f"consent-registry: FAIL — registry root is {type(reg).__name__}, not a mapping; fail-closed")
|
|
144
|
+
sys.exit(1)
|
|
145
|
+
if "classes" not in reg:
|
|
146
|
+
print("consent-registry: FAIL — registry has no `classes` key; fail-closed")
|
|
147
|
+
sys.exit(1)
|
|
148
|
+
classes = reg["classes"]
|
|
149
|
+
if classes is None:
|
|
150
|
+
classes = []
|
|
151
|
+
if not isinstance(classes, list):
|
|
152
|
+
print(f"consent-registry: FAIL — `classes` is {type(classes).__name__}, not a list; fail-closed")
|
|
153
|
+
sys.exit(1)
|
|
154
|
+
if not classes:
|
|
155
|
+
print("consent-registry: N/A — registry declares zero classes; promotion DISABLED (not a PASS)")
|
|
156
|
+
sys.exit(3) # UNMEASURED, not verified — see EXIT CONTRACT
|
|
157
|
+
|
|
158
|
+
by_name = {}
|
|
159
|
+
for i, c in enumerate(classes):
|
|
160
|
+
if not isinstance(c, dict):
|
|
161
|
+
out("❌", f"R1 class #{i} is not a mapping"); fails += 1; continue
|
|
162
|
+
nm = c.get("name", f"<unnamed #{i}>")
|
|
163
|
+
missing = [f for f in REQUIRED if f not in c]
|
|
164
|
+
if missing:
|
|
165
|
+
out("❌", f"R1 `{nm}` missing required field(s): {', '.join(missing)}"); fails += 1
|
|
166
|
+
continue
|
|
167
|
+
# R1-b STRICT TYPES. `promotion_eligible: "false"` (quoted) is a truthy STRING, so every
|
|
168
|
+
# eligibility test below silently inverts and an intended-ineligible class becomes promotable.
|
|
169
|
+
# Measured 2026-07-29 against a control: quoted "false" PASSed a grant that real `false` blocked.
|
|
170
|
+
# One quote character disarmed the floor — so the type is checked, not coerced.
|
|
171
|
+
if not isinstance(c["promotion_eligible"], bool):
|
|
172
|
+
out("❌", f"R1-b `{nm}` promotion_eligible must be a YAML boolean, got "
|
|
173
|
+
f"{type(c['promotion_eligible']).__name__} {c['promotion_eligible']!r} "
|
|
174
|
+
f"(a quoted \"false\" is truthy and would invert the floor)")
|
|
175
|
+
fails += 1; continue
|
|
176
|
+
# H2 scalar fields: a `mode: 123` or `target: ""` described nothing, so the entry a human was
|
|
177
|
+
# asked to review as a grant-of-future-autonomy was unreadable. H3 list ITEMS were never typed,
|
|
178
|
+
# only their container — `sinks: [123]` counted as a declared sink.
|
|
179
|
+
bad_field = False
|
|
180
|
+
for fld in STR_FIELDS:
|
|
181
|
+
if not isinstance(c[fld], str) or not c[fld].strip():
|
|
182
|
+
out("❌", f"R1-b `{nm}` `{fld}` must be a non-blank string, got "
|
|
183
|
+
f"{type(c[fld]).__name__} {c[fld]!r}")
|
|
184
|
+
fails += 1; bad_field = True
|
|
185
|
+
for fld in ("capabilities", "sinks", "feeds"):
|
|
186
|
+
if not isinstance(c[fld], list):
|
|
187
|
+
out("❌", f"R1-b `{nm}` `{fld}` must be a list, got {type(c[fld]).__name__}")
|
|
188
|
+
fails += 1; bad_field = True
|
|
189
|
+
elif not all(isinstance(x, str) and x.strip() for x in c[fld]):
|
|
190
|
+
out("❌", f"R1-b `{nm}` `{fld}` must contain only non-blank strings, got {c[fld]!r} "
|
|
191
|
+
f"— an unreadable sink is an UNDECLARED sink, and undeclared is unknown")
|
|
192
|
+
fails += 1; bad_field = True
|
|
193
|
+
if bad_field:
|
|
194
|
+
continue
|
|
195
|
+
# R1-c UNIQUE NAMES. Consent is keyed by class name; a duplicate silently shadowed the earlier
|
|
196
|
+
# entry, so appending an eligible twin below an ineligible one granted the ineligible class.
|
|
197
|
+
if nm in by_name:
|
|
198
|
+
out("❌", f"R1-c duplicate class name `{nm}` — consent is keyed by name, so a duplicate "
|
|
199
|
+
f"shadows the earlier entry and can launder an ineligible class")
|
|
200
|
+
fails += 1; continue
|
|
201
|
+
by_name[nm] = c
|
|
202
|
+
# R2 — eligibility must be SOUND, not merely asserted. This is the line that stops a class from
|
|
203
|
+
# declaring itself promotable while naming an irreversible sink two fields above.
|
|
204
|
+
taint = set(map(str, c["sinks"] or [])) | set(map(str, c["feeds"] or []))
|
|
205
|
+
bad = taint & IRREVERSIBLE
|
|
206
|
+
# R2-b — the CAPABILITY field is part of the floor too. Until 2026-07-31 IRREVERSIBLE was
|
|
207
|
+
# intersected ONLY with sinks|feeds, so a class could DECLARE `capabilities: [history-rewrite]`
|
|
208
|
+
# outright and stay promotable as long as it named no sink: R2 saw an empty taint and R7's
|
|
209
|
+
# effect-subset rule then CONFIRMED the grant, because the effect really was a subset of the
|
|
210
|
+
# declared capabilities. The gate agreed with itself all the way to exit 0.
|
|
211
|
+
# R2's own comment above says "naming an irreversible SINK two fields above" — the guard was
|
|
212
|
+
# written against the sink field and the capability field was never in its scope. Sinks are
|
|
213
|
+
# where an effect LEAKS; capabilities are what the class is allowed to DO, and the floor cares
|
|
214
|
+
# about both. Checked separately from `bad` so the message names which field carried it.
|
|
215
|
+
# Found by codex/gpt-5.6-sol in round 9; the local canary returned CONVERGED on the same diff.
|
|
216
|
+
# `_norm`, not raw str(): R7 reads this SAME field through _norm, and the first cut of R2-b
|
|
217
|
+
# compared raw strings, so ` history-rewrite ` evaded R2-b while R7 still recognised it. One
|
|
218
|
+
# field with two spellings is the divergent-normalizer class, and it is the shape that turns a
|
|
219
|
+
# floor into decoration. Residual, named rather than fixed here: _norm strips whitespace but
|
|
220
|
+
# does NOT case-fold, so `History-Rewrite` still evades BOTH R2-b and R7 — case-folding would
|
|
221
|
+
# change R7's semantics too and belongs in its own change with its own lanes.
|
|
222
|
+
cap_bad = {_norm(x) for x in (c["capabilities"] or [])} & IRREVERSIBLE
|
|
223
|
+
if c["promotion_eligible"] and bad:
|
|
224
|
+
out("❌", f"R2 `{nm}` claims promotion_eligible:true but sinks/feeds include {sorted(bad)}")
|
|
225
|
+
fails += 1
|
|
226
|
+
elif c["promotion_eligible"] and cap_bad:
|
|
227
|
+
out("❌", f"R2-b `{nm}` claims promotion_eligible:true but declares irreversible "
|
|
228
|
+
f"capabilities {sorted(cap_bad)} — an irreversible act is not made reversible by "
|
|
229
|
+
f"having no declared sink")
|
|
230
|
+
fails += 1
|
|
231
|
+
elif c["promotion_eligible"] and taint:
|
|
232
|
+
out("❌", f"R2 `{nm}` claims promotion_eligible:true with non-empty sinks/feeds {sorted(taint)} "
|
|
233
|
+
f"— unlisted sinks are UNKNOWN, and unknown is not reversible")
|
|
234
|
+
fails += 1
|
|
235
|
+
|
|
236
|
+
if fails == 0:
|
|
237
|
+
out("✅", f"R1/R2 registry schema + eligibility soundness ({len(by_name)} class(es))")
|
|
238
|
+
|
|
239
|
+
# ---- standing_consent side --------------------------------------------------------
|
|
240
|
+
if not os.path.exists(uap_path):
|
|
241
|
+
print(f"consent-registry: N/A — no UAP at {uap_path}; nothing granted (not a PASS)")
|
|
242
|
+
# A registry failure still outranks: BROKEN beats UNMEASURED, because a broken registry is a
|
|
243
|
+
# decided negative while an absent UAP is merely nothing to join.
|
|
244
|
+
sys.exit(1 if fails else 3)
|
|
245
|
+
|
|
246
|
+
raw = open(uap_path, errors="replace").read()
|
|
247
|
+
# ---- the ONE machine-read region: YAML frontmatter ---------------------------------
|
|
248
|
+
# STORAGE-FORM CHANGE, operator decision 2026-07-31. What stood here was a line-slicer that scraped a
|
|
249
|
+
# `standing_consent:` key out of markdown prose. It was not under-engineered — it had absorbed
|
|
250
|
+
# first-match shadowing, key-spacing equivalence, inline-vs-block forms, falsy-sentinel laundering,
|
|
251
|
+
# duplicate keys, and explicit-key form. It still lost, and the shape of the losing was the tell:
|
|
252
|
+
# every special case it closed opened the next one — loader-identity → nested key → explicit-key →
|
|
253
|
+
# comment-vs-heading, THREE of them in a single day (2026-07-31), one of which the fixing session
|
|
254
|
+
# introduced itself while fixing the previous.
|
|
255
|
+
#
|
|
256
|
+
# The root was never slicer quality. It was that a machine field lived in markdown, where `#` and
|
|
257
|
+
# indentation carry different meaning to YAML than to markdown, so every disambiguation rule had to
|
|
258
|
+
# guess which language a line was written in. Frontmatter deletes the DECISION, not merely this
|
|
259
|
+
# implementation: "the first `---` block at the top of the file" has exactly one referent, so there
|
|
260
|
+
# is no next special case to lose to. The region is handed to the canonical loader whole.
|
|
261
|
+
#
|
|
262
|
+
# What this also retires: the measured merge-key OVER-BLOCK (`defaults: &d` + `<<: *d` was refused
|
|
263
|
+
# because the anchor lived outside the extracted fragment). Anchors now resolve normally — the
|
|
264
|
+
# fragment IS the document. Over-blocking was a real defect here, not a safety win: it trains the
|
|
265
|
+
# override reflex and turns a gate into decoration.
|
|
266
|
+
#
|
|
267
|
+
# Fail-closed direction is UNCHANGED, and one net is deliberately kept and generalized: a grant that
|
|
268
|
+
# exists where nothing reads it must never report as "nothing granted". That is a false clean, the
|
|
269
|
+
# worst outcome for a floor.
|
|
270
|
+
grants = {}
|
|
271
|
+
# The opening/closing fence must accept every shape the CANONICAL loader accepts, or this regex
|
|
272
|
+
# quietly becomes a second, stricter grammar sitting in front of the loader — the exact defect the
|
|
273
|
+
# storage-form change was made to retire. Cross-family review 2026-07-31 measured five valid YAML
|
|
274
|
+
# stream forms this pattern rejected: a `...` document terminator, a `#` comment on the opening or
|
|
275
|
+
# closing fence, a `%YAML` directive, and — worst, because a lane claimed to cover it — the EMPTY
|
|
276
|
+
# block `---\n---`, which the first version could not match at all (it consumed the opening newline
|
|
277
|
+
# and then demanded another before the closing fence). All four rejections failed CLOSED, so no
|
|
278
|
+
# grant leaked; but each was an OVER-BLOCK whose message blamed a missing frontmatter that was in
|
|
279
|
+
# fact present, and a gate that misdirects the fix gets overridden.
|
|
280
|
+
# %YAML directives -> allowed before the opening fence
|
|
281
|
+
# opening/closing fence -> may carry trailing spaces and a # comment
|
|
282
|
+
# closing fence -> `---` OR `...` (both end a YAML document)
|
|
283
|
+
# body -> may be empty
|
|
284
|
+
# BOM-tolerant too: a leading would otherwise make the region look absent — safe, but for a
|
|
285
|
+
# misleading reason.
|
|
286
|
+
_FM = re.compile(
|
|
287
|
+
r"\A?(?:%[^\n]*\r?\n)*" # optional %YAML / %TAG directives
|
|
288
|
+
r"---[ \t]*(?:\#[^\n]*)?\r?\n" # opening fence (+ optional comment)
|
|
289
|
+
r"(.*?)" # body (possibly empty)
|
|
290
|
+
r"(?:\r?\n)?" # the body's own trailing newline, if it has one
|
|
291
|
+
r"^(?:---|\.\.\.)[ \t]*(?:\#[^\n]*)?(?:\r?\n|\Z)", # closing fence: --- or ...
|
|
292
|
+
re.S | re.M)
|
|
293
|
+
_fm_match = _FM.match(raw)
|
|
294
|
+
if _fm_match is None:
|
|
295
|
+
# No machine region at all. A UAP predating this format legitimately has none, so absence alone
|
|
296
|
+
# is "nothing granted" — but if the prose MENTIONS standing_consent, someone wrote a grant into a
|
|
297
|
+
# region no parser reads. Fail closed: that is the false-clean case, not an absence.
|
|
298
|
+
if re.search(r"standing_consent", raw):
|
|
299
|
+
out("❌", "R3 `standing_consent` appears in the UAP but the file has no YAML frontmatter — "
|
|
300
|
+
"a grant written outside the machine region is never read; fail-closed")
|
|
301
|
+
fails += 1; grants = None
|
|
302
|
+
else:
|
|
303
|
+
try:
|
|
304
|
+
# NoDupLoader, same as the registry side: yaml.safe_load is last-wins on duplicate keys, so
|
|
305
|
+
# `expires: 2020-01-01` followed by `expires: 2099-01-01` would silently keep the future one.
|
|
306
|
+
_fm = yaml.load(_fm_match.group(1), Loader=NoDupLoader)
|
|
307
|
+
except Exception as e:
|
|
308
|
+
out("❌", f"R3 UAP frontmatter unparseable ({e}); fail-closed"); fails += 1; grants = None
|
|
309
|
+
else:
|
|
310
|
+
if _fm is None:
|
|
311
|
+
_fm = {} # an empty frontmatter block is a real empty mapping
|
|
312
|
+
if not isinstance(_fm, dict):
|
|
313
|
+
out("❌", f"R3 UAP frontmatter is not a mapping ({type(_fm).__name__}); fail-closed")
|
|
314
|
+
fails += 1; grants = None
|
|
315
|
+
elif "standing_consent" not in _fm and \
|
|
316
|
+
re.search(r"""^[ \t]*(?:["']?)standing_consent(?:["']?)[ \t]*:""",
|
|
317
|
+
raw[_fm_match.end():], re.M):
|
|
318
|
+
# Quoted spellings included: YAML reads `"standing_consent":` as the identical key,
|
|
319
|
+
# so a bare-key-only net would miss a grant written that way in prose and report
|
|
320
|
+
# "nothing granted" — the false clean this net exists to prevent (cross-family
|
|
321
|
+
# 2026-07-31). The F1 net above already matched quoted forms via substring search;
|
|
322
|
+
# this one was anchored and did not, so the two nets disagreed on the same input.
|
|
323
|
+
# Frontmatter exists but the grant was written BELOW it, in prose. Same false-clean as
|
|
324
|
+
# above: the machine region is authoritative, so an unread grant is not an absent one.
|
|
325
|
+
# RESIDUAL — MEASURED OVER-BLOCK RISK (named, not silently accepted): this net is a
|
|
326
|
+
# regex over the prose region, so a DOCUMENTATION EXAMPLE of `standing_consent:` in the
|
|
327
|
+
# body — including inside a fenced code block — trips it and fails the file closed. The
|
|
328
|
+
# trade was taken deliberately: the false-clean it prevents is silent, while this
|
|
329
|
+
# over-block is loud and its message names the fix. But over-blocking is a defect of the
|
|
330
|
+
# same weight as a fail-open (it trains the override reflex), so if a real UAP ever needs
|
|
331
|
+
# to document the key, exclude fenced regions here rather than deleting the net.
|
|
332
|
+
out("❌", "R3 `standing_consent` is written in the UAP prose region but absent from the "
|
|
333
|
+
"frontmatter — the frontmatter is authoritative; fail-closed")
|
|
334
|
+
fails += 1; grants = None
|
|
335
|
+
else:
|
|
336
|
+
# NO `or {}` here. `[] or {}` / `False or {}` / `0 or {}` all evaluate to `{}`, which
|
|
337
|
+
# would reach the type check already laundered into a valid-empty mapping — so a
|
|
338
|
+
# `standing_consent: []` would report "nothing recorded" while the truthy `[a, b]` was
|
|
339
|
+
# caught (measured with that control, cross-family round 5). Sentinel first, type check
|
|
340
|
+
# second, defaulting last.
|
|
341
|
+
grants = _fm.get("standing_consent", {})
|
|
342
|
+
if grants is None:
|
|
343
|
+
grants = {} # an explicitly empty `standing_consent:` key is a real empty set
|
|
344
|
+
if not isinstance(grants, dict):
|
|
345
|
+
out("❌", f"R3 standing_consent is not a mapping ({type(grants).__name__}); fail-closed")
|
|
346
|
+
fails += 1; grants = None
|
|
347
|
+
|
|
348
|
+
if grants is None:
|
|
349
|
+
pass
|
|
350
|
+
elif not grants:
|
|
351
|
+
out("✅", "R3-R6 no standing consent recorded (nothing to validate)")
|
|
352
|
+
else:
|
|
353
|
+
today = datetime.date.today()
|
|
354
|
+
validated = 0
|
|
355
|
+
for name, g in grants.items():
|
|
356
|
+
# H4 grant key: registry `name: 123` + UAP `123:` used to join fine because neither side was
|
|
357
|
+
# typed. Both sides are strings or the join is meaningless.
|
|
358
|
+
if not isinstance(name, str) or not name.strip():
|
|
359
|
+
out("❌", f"R3 grant key {name!r} must be a non-blank string"); fails += 1; continue
|
|
360
|
+
# `declined` / `unset` / `revoked` are first-class states in this storage model, not grants.
|
|
361
|
+
# Coercing them into `{"granted": ...}` turned a legitimate refusal into a malformed grant
|
|
362
|
+
# and reported violations against a user who said no.
|
|
363
|
+
if isinstance(g, str) and g.strip().lower() in NON_GRANT:
|
|
364
|
+
continue
|
|
365
|
+
if not isinstance(g, dict):
|
|
366
|
+
out("❌", f"R3 `{name}` has value {g!r} — not a grant mapping and not one of "
|
|
367
|
+
f"{sorted(NON_GRANT)}; fail-closed")
|
|
368
|
+
fails += 1; continue
|
|
369
|
+
# Normalize identically to the scalar branch above. Without .strip() a `state: "revoked "`
|
|
370
|
+
# fell through and was validated as an ACTIVE grant (cross-family round 4) — two spellings of
|
|
371
|
+
# the same state judged by two different normalizers is the divergent-normalizer class.
|
|
372
|
+
# H6 UNKNOWN STATE. Only the three canonical states were recognized; anything else — a typo
|
|
373
|
+
# like `revokedd`, or a state a future version adds — fell through and was validated as an
|
|
374
|
+
# ACTIVE grant. A record whose state we cannot read is not a grant we may honour.
|
|
375
|
+
if "state" in g:
|
|
376
|
+
st = g["state"]
|
|
377
|
+
if not isinstance(st, str) or st.strip().lower() not in (NON_GRANT | {"granted"}):
|
|
378
|
+
out("❌", f"R3 `{name}` has unrecognized state {st!r} — expected one of "
|
|
379
|
+
f"{sorted(NON_GRANT | {'granted'})}; fail-closed")
|
|
380
|
+
fails += 1; continue
|
|
381
|
+
if st.strip().lower() in NON_GRANT:
|
|
382
|
+
continue
|
|
383
|
+
validated += 1
|
|
384
|
+
c = by_name.get(name)
|
|
385
|
+
if c is None:
|
|
386
|
+
out("❌", f"R3 `{name}` granted but NOT in the registry (unregistered == unknown)"); fails += 1; continue
|
|
387
|
+
if not c.get("promotion_eligible"):
|
|
388
|
+
out("❌", f"R4 `{name}` granted but registry says promotion_eligible:false"); fails += 1; continue
|
|
389
|
+
# H5 `granted` was never checked at all — a grant with no grant date cannot be audited
|
|
390
|
+
# against the three approvals that were supposed to produce it.
|
|
391
|
+
def _date(v, fld):
|
|
392
|
+
if isinstance(v, datetime.date):
|
|
393
|
+
return v
|
|
394
|
+
if isinstance(v, str) and re.fullmatch(r"\d{4}-\d{2}-\d{2}", v.strip()):
|
|
395
|
+
try:
|
|
396
|
+
return datetime.date.fromisoformat(v.strip())
|
|
397
|
+
except ValueError:
|
|
398
|
+
return None
|
|
399
|
+
return None # ints like 29991231 are NOT dates; basic-format parsing accepted them
|
|
400
|
+
gd = _date(g.get("granted"), "granted")
|
|
401
|
+
if gd is None:
|
|
402
|
+
out("❌", f"R5 `{name}` has missing or non-ISO `granted` ({g.get('granted')!r}) — "
|
|
403
|
+
f"expected YYYY-MM-DD"); fails += 1
|
|
404
|
+
elif gd > today:
|
|
405
|
+
# A consent dated in the future has not been given. `granted: 2099-01-01` passed.
|
|
406
|
+
out("❌", f"R5 `{name}` is `granted` {gd}, in the FUTURE — a consent that has not "
|
|
407
|
+
f"happened yet cannot authorize anything"); fails += 1
|
|
408
|
+
exp = g.get("expires")
|
|
409
|
+
if exp is None:
|
|
410
|
+
out("❌", f"R5 `{name}` granted with no `expires` — standing consent is a lease, not a transfer"); fails += 1
|
|
411
|
+
else:
|
|
412
|
+
d = _date(exp, "expires")
|
|
413
|
+
if d is None:
|
|
414
|
+
out("❌", f"R5 `{name}` has non-ISO expires={exp!r} — expected YYYY-MM-DD "
|
|
415
|
+
f"(`29991231` parsed as a date under basic-format rules and slipped through)")
|
|
416
|
+
fails += 1
|
|
417
|
+
elif d < today:
|
|
418
|
+
out("❌", f"R5 `{name}` expired {d} — must lapse to unset, not keep running"); fails += 1
|
|
419
|
+
# H7 LEASE BOUND. `expires: 9999-12-31` satisfied "has an expiry" while defeating the
|
|
420
|
+
# entire point of one. A lease longer than the maximum is a transfer wearing a lease's
|
|
421
|
+
# clothes.
|
|
422
|
+
elif gd is not None and (d - gd).days > MAX_LEASE_DAYS:
|
|
423
|
+
out("❌", f"R5 `{name}` lease is {(d - gd).days} days (max {MAX_LEASE_DAYS}) — "
|
|
424
|
+
f"an unbounded expiry is a transfer, not a lease"); fails += 1
|
|
425
|
+
# R6 — a grant with no recorded scope can never be re-validated against a drifted action.
|
|
426
|
+
# R6 — presence AND type. The registry side got strict types at R1-b; the grant side did not,
|
|
427
|
+
# so `effects: true` / `target: 123` were "recorded" and passed while a MISSING field was
|
|
428
|
+
# caught (measured with that control, cross-family round 7). Half a fix propagated is a hole:
|
|
429
|
+
# a baseline that is not a list-of-effects and a real target string cannot be compared against
|
|
430
|
+
# anything later, which is the entire purpose of recording it.
|
|
431
|
+
eff, tgt = g.get("effects"), g.get("target")
|
|
432
|
+
if eff is None or tgt is None:
|
|
433
|
+
out("❌", f"R6 `{name}` grant records no `effects`+`target` — the subset check has no baseline")
|
|
434
|
+
fails += 1
|
|
435
|
+
else:
|
|
436
|
+
if not isinstance(eff, list) or not eff or not all(isinstance(e, str) and e.strip() for e in eff):
|
|
437
|
+
out("❌", f"R6 `{name}` `effects` must be a non-empty list of strings, got "
|
|
438
|
+
f"{type(eff).__name__} {eff!r} — an uncomparable baseline is not a baseline")
|
|
439
|
+
fails += 1
|
|
440
|
+
if not isinstance(tgt, str) or not tgt.strip():
|
|
441
|
+
out("❌", f"R6 `{name}` `target` must be a non-empty string, got "
|
|
442
|
+
f"{type(tgt).__name__} {tgt!r}")
|
|
443
|
+
fails += 1
|
|
444
|
+
# R7 STORED-SCOPE SUBSET. The grant recorded its own scope and nothing compared it to
|
|
445
|
+
# what the registry actually authorizes, so `effects: [repo-mutation]` sat happily under
|
|
446
|
+
# a `capabilities: [read]` class. This is the mechanizable HALF of the subset rule: it
|
|
447
|
+
# cannot see a LIVE action (still a runtime obligation, still named as residual), but a
|
|
448
|
+
# STORED grant wider than its own class is checkable right here — and was not checked.
|
|
449
|
+
if isinstance(eff, list) and all(isinstance(e, str) for e in eff):
|
|
450
|
+
# One normalizer, both sides. Before this the grant side was stripped and the
|
|
451
|
+
# registry side was not, so `[" read "]` matched while `[READ]` did not — whitespace
|
|
452
|
+
# forgiving, case strict, for no stated reason. Divergent normalizers on the two
|
|
453
|
+
# sides of a comparison is the same defect class this file already fixed twice.
|
|
454
|
+
over = sorted({_norm(e) for e in eff} - {_norm(x) for x in c["capabilities"]})
|
|
455
|
+
if over:
|
|
456
|
+
out("❌", f"R7 `{name}` grant claims effect(s) {over} outside its registered "
|
|
457
|
+
f"capabilities {c['capabilities']} — the grant is wider than the class")
|
|
458
|
+
fails += 1
|
|
459
|
+
if isinstance(tgt, str) and _norm(tgt) != _norm(str(c["target"])):
|
|
460
|
+
out("❌", f"R7 `{name}` grant target {tgt!r} does not match the registered target "
|
|
461
|
+
f"{c['target']!r} — scope drift between grant and class")
|
|
462
|
+
fails += 1
|
|
463
|
+
if fails == 0:
|
|
464
|
+
skipped = len(grants) - validated
|
|
465
|
+
note = f" ({skipped} non-grant state(s) skipped)" if skipped else ""
|
|
466
|
+
out("✅", f"R3-R6 all {validated} active grant(s) registered, eligible, unexpired, "
|
|
467
|
+
f"scope-recorded{note}")
|
|
468
|
+
|
|
469
|
+
print("----")
|
|
470
|
+
print(f"consent-registry: {'PASS' if fails == 0 else f'{fails} violation(s)'}")
|
|
471
|
+
sys.exit(0 if fails == 0 else 1)
|
|
472
|
+
PY
|
|
@@ -103,6 +103,22 @@ _fd_hit() { find "$1" -maxdepth 1 -name "frontier_digest_$(date +%Y_%m_%d)*.md"
|
|
|
103
103
|
# 계기의 스코프가 대상보다 좁았던 케이스 — 대상은 '오늘 digest 가 있나'지 '이 디스크에 있나'가 아니다.)
|
|
104
104
|
# 술어는 로컬과 **동일**(glob + -size +1k) — divergent-leniency 를 만들지 않는다.
|
|
105
105
|
_fd_ready() { _fd_hit "$FH/tracks/_meta" || { [ -n "$BE" ] && _fd_hit "$BE/tracks-meta"; }; }
|
|
106
|
+
# THE SECOND HALF OF THE SAME SCOPE BUG (2026-07-31). The comment above got the principle right —
|
|
107
|
+
# "대상은 '오늘 digest 가 있나'지 '이 디스크에 있나'가 아니다" — and then widened the predicate by
|
|
108
|
+
# exactly ONE surface (the companion store), leaving it file-only. There are TWO live producers:
|
|
109
|
+
# the launchd file runner AND an app routine that posts the digest as a comment on GitHub issue
|
|
110
|
+
# #102 (measured 2026-07-31: 47 comments, one per day, including today's at 09:09 KST). So on a day
|
|
111
|
+
# when the file runner fails, today's digest EXISTS and the hook said "부재가 아니라 실패다" — a
|
|
112
|
+
# true statement about this disk stated as a claim about the digest.
|
|
113
|
+
# The fix is NOT to call the network from a SessionStart hook (it must never block turn 0 and must
|
|
114
|
+
# never fail on an offline node). It is to stop over-claiming: report the scope actually measured
|
|
115
|
+
# ("this node's file output failed") and NAME the surface not measured, so the reader checks it in
|
|
116
|
+
# one step instead of re-running a job whose output already exists elsewhere.
|
|
117
|
+
_fd_issue_note() {
|
|
118
|
+
echo " ⓘ 파일만 본 판정이다 — 오늘치는 **GH issue #102**(앱 routine, 매일 ~09:09 KST)에 이미 있을 수 있다."
|
|
119
|
+
echo " 확인: gh issue view 102 --repo chrono-meta/forge-harness --comments | tail -40"
|
|
120
|
+
echo " → 파일 재생성이 필요한지는 그걸 보고 판단하라. '오늘은 뉴스 없음'으로 읽지 말 것."
|
|
121
|
+
}
|
|
106
122
|
if _fd_ready; then
|
|
107
123
|
# 로컬엔 없고 컴패니언에만 있으면 = 이 노드는 러너가 아니다. 침묵하면 토폴로지가 안 보이므로 한 줄 알린다.
|
|
108
124
|
if ! _fd_hit "$FH/tracks/_meta"; then
|
|
@@ -125,15 +141,38 @@ elif [ -f "$_FD_LOG" ]; then
|
|
|
125
141
|
if [ -n "$_FD_ALIVE" ]; then
|
|
126
142
|
echo "ℹ️ [frontier-digest] 잡이 아직 돌고 있는 중일 수 있다($_FD_STAMP, $_FD_ALIVE) — 실패 판정 보류, 나중에 재확인."
|
|
127
143
|
else
|
|
128
|
-
echo "⚠️ [frontier-digest]
|
|
144
|
+
echo "⚠️ [frontier-digest] **이 노드의 파일 산출**이 실패했다($_FD_STAMP) — 부재가 아니라 실패다."
|
|
129
145
|
echo " 마지막 로그: $(tail -1 "$_FD_LOG" 2>/dev/null | cut -c1-90)"
|
|
130
|
-
|
|
146
|
+
_fd_issue_note
|
|
131
147
|
fi
|
|
132
148
|
else
|
|
133
|
-
echo "⚠️ [frontier-digest] 스케줄(09:00) 지났는데 로그도 산출물도 없다($_FD_STAMP) — 잡이 아예 안 돌았을 수 있다(launchd 확인)."
|
|
149
|
+
echo "⚠️ [frontier-digest] 스케줄(09:00) 지났는데 로그도 파일 산출물도 없다($_FD_STAMP) — 이 노드에서 잡이 아예 안 돌았을 수 있다(launchd 확인)."
|
|
150
|
+
_fd_issue_note
|
|
134
151
|
fi
|
|
135
152
|
# (BE is resolved at the top of this script — the frontier-digest block above needs it too.)
|
|
136
153
|
|
|
154
|
+
# 1-b) Weekly-audit cadence — mechanical, for the same reason the frontier-digest block above is.
|
|
155
|
+
# MEASURED 2026-07-31: operations.md §Session start auto-detection (L1) promises "propose the audit
|
|
156
|
+
# if 7+ days elapsed", and the audit had not run for FIFTY days. The frontier-digest cadence, which
|
|
157
|
+
# is instrumented, never went a day unnoticed over the same window. The difference is not
|
|
158
|
+
# importance; it is that one cadence is a hook and the other is prose, and this repo's own N=3
|
|
159
|
+
# escalation rule says to instrument rather than to add a habit. One line in a hook that already
|
|
160
|
+
# runs, not a new mechanism. Advisory by design: an overdue audit is not an irreversible surface,
|
|
161
|
+
# so it surfaces and never blocks. Silent when current, and silent when the dir does not exist
|
|
162
|
+
# (a fresh clone has no audit history and must not be nagged about one).
|
|
163
|
+
_AUDIT_DIR="$FH/tracks/_audit"
|
|
164
|
+
if [ -d "$_AUDIT_DIR" ]; then
|
|
165
|
+
_LATEST_AUDIT="$(find "$_AUDIT_DIR" -maxdepth 1 -name 'weekly_audit_*.md' -print 2>/dev/null \
|
|
166
|
+
| sort | tail -1)"
|
|
167
|
+
if [ -n "$_LATEST_AUDIT" ]; then
|
|
168
|
+
_AUDIT_AGE_D=$(( ( $(date +%s) - $(_mtime "$_LATEST_AUDIT") ) / 86400 ))
|
|
169
|
+
if [ "$_AUDIT_AGE_D" -ge 7 ]; then
|
|
170
|
+
echo "🗓️ [weekly-audit] 마지막 감사가 ${_AUDIT_AGE_D}일 전이다($(basename "$_LATEST_AUDIT")) — 캐던스는 7일."
|
|
171
|
+
echo " → /harvest-loop (lightweight) 또는 operations.md §Weekly Improvement Cycle 수동 절차."
|
|
172
|
+
fi
|
|
173
|
+
fi
|
|
174
|
+
fi
|
|
175
|
+
|
|
137
176
|
# Non-Mode-D / no companion store → silent no-op (this is the majority path for public users).
|
|
138
177
|
[ -d "$BE/.git" ] || exit 0
|
|
139
178
|
|