@chrono-meta/fh-gate 1.4.98 → 1.4.99

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.
@@ -582,6 +582,78 @@ else
582
582
  fail=1
583
583
  fi
584
584
 
585
+ # directional_diff_gate — the LAST embedded --self-test subject with no caller, and it stayed
586
+ # invisible for a reason worth keeping written down: lane_runner_check.sh reported it WIRED off the
587
+ # subject's OWN usage comment (`# bash scripts/directional_diff_gate.sh --self-test`), so the
588
+ # report read `self-test: 10/10 wired` while `grep -rn directional_diff_gate` across every runner
589
+ # surface returned zero real callers. The detector was repaired in the same delta as this wiring
590
+ # (has_selftest_runner now skips the subject's own file, selftest_dispatched now drops comment
591
+ # lines — the two guards has_runner/runner_dispatches always had), which is why the two halves ship
592
+ # together: fixing the detector alone would have left a real debt item newly VISIBLE and still
593
+ # unrun, and wiring alone would have left the detector able to certify the next such subject.
594
+ # ⚠️ The cost of shipping both halves at once, named rather than left for the next reader to
595
+ # discover: the report reads `10/10` before the change and `10/10` after it, so the detector repair
596
+ # is NOT observable from this repo's own run — reverting the repair alone leaves the count
597
+ # unchanged, because the wiring that now exists is real. Its only evidence is the fixture lanes
598
+ # L14–L16 in scripts/test_lane_runner_lanes.sh, which is why L16 in particular is load-bearing:
599
+ # delete it and the self-exclusion guard has nothing anywhere that would go red.
600
+ #
601
+ # NOT in the `for _subj in ...` loop above, and that is the same call made for
602
+ # capability_registry_check: that loop gates on the substring 캘리브레이션, and this subject's
603
+ # terminal verdict is English ("✅ calibration passed (N pairs)"). Adding it there would produce
604
+ # "dispatcher missing?" — a confident and wrong cause — for a suite that passes. Gate on exit code
605
+ # instead, same shape as the two blocks above. It carries genuine HARNESS-ERROR paths (`exit 10` on
606
+ # mktemp failure, `return 10` on unreadable input), so a setup failure must not read as a lane
607
+ # failure. Ships via package.json files[] with no ACCEPTED_ABSENT declaration, so absence is
608
+ # deletion, not package mode. `< /dev/null` for the same stdin-wait reason the loop above documents.
609
+ if [ ! -f scripts/directional_diff_gate.sh ]; then
610
+ echo "FAIL directional_diff_gate.sh: missing — it ships via package.json files[], so absence is deletion, not package mode"
611
+ fail=1
612
+ else
613
+ _out=$($_LANE_TO bash scripts/directional_diff_gate.sh --self-test < /dev/null 2>&1); _dd_rc=$?
614
+ case "$_dd_rc" in
615
+ 10|2|124|126|127)
616
+ # 124 is `timeout`'s own kill code, so it belongs in this arm rather than the generic FAIL
617
+ # one below: a suite that was killed did not measure, and calling that "the lane failed"
618
+ # sends the reader to the wrong file. Reachable only because this dispatch is wrapped in
619
+ # `$_LANE_TO` — which is REUSED from the pair-suite loop above, not installed here, so do
620
+ # not delete it from this line believing the timeout is local to this block.
621
+ echo "HARNESS ERROR directional_diff_gate.sh --self-test: exited $_dd_rc — it could not measure,"
622
+ echo " so its verdicts prove nothing. Not a lane failure, and not a pass either."
623
+ fail=1 ;;
624
+ 0)
625
+ # rc=0 is NOT sufficient, and this is not a hypothetical: the `for _subj in ...` loop above
626
+ # records the measured case where a subject fell into an unrecognized mode, printed its usage
627
+ # banner and exited 0, and the checker read that as a pass while 25 lanes had vanished. This
628
+ # subject happens to exit 10 from that path TODAY — a revert probe (2026-08-15: dispatcher
629
+ # line deleted) confirmed the HARNESS ERROR arm fires — but that is incidental to how its
630
+ # usage function is written, not a property this block should depend on. Require the
631
+ # terminal verdict itself, so a future subject that exits 0 without running anything goes red
632
+ # here rather than certifying an empty run.
633
+ # Whole-LINE match, not a substring, and the pair count must be non-zero. Both halves were
634
+ # named by cross-family review (2026-08-15) and both are reachable in the real subject, not
635
+ # hypothetical: directional_diff_gate.sh's verdict line is
636
+ # `[ "$f" -eq 0 ] && echo "✅ calibration passed ($n pairs)"` — delete every `t` lane and it
637
+ # prints `calibration passed (0 pairs)` and exits 0, i.e. a suite with nothing left in it
638
+ # certifies itself. That is the quietest face of [[feedback_not_found_is_not_zero_family]]
639
+ # (deletion read as a pass), and a substring match would also let a usage banner or a prose
640
+ # line carrying the same words satisfy the gate.
641
+ _dd_verdict=$(printf '%s\n' "$_out" | grep -oE '^✅ calibration passed \([0-9]+ pairs\)$' | tail -1)
642
+ _dd_pairs=$(printf '%s' "$_dd_verdict" | grep -oE '[0-9]+' | tail -1)
643
+ if [ -n "$_dd_verdict" ] && [ "${_dd_pairs:-0}" -gt 0 ]; then
644
+ echo "PASS directional_diff_gate.sh --self-test ($_dd_verdict)"
645
+ else
646
+ echo "FAIL directional_diff_gate.sh: --self-test exited 0 without a non-zero calibration verdict (dispatcher missing, or every lane deleted?)"
647
+ _show_failure "$_out"
648
+ fail=1
649
+ fi ;;
650
+ *)
651
+ echo "FAIL directional_diff_gate.sh: --self-test failed (exit $_dd_rc)"
652
+ _show_failure "$_out"
653
+ fail=1 ;;
654
+ esac
655
+ fi
656
+
585
657
  # memory-link-check — the memory store is a GRAPH (memory_intent_recall.md: nodes=files,
586
658
  # edges=[[links]], recall walks one hop). Measured 2026-07-28: 50 of 872 edges pointed at a note
587
659
  # that existed under a different separator and 22 at nothing — a dead edge returns nothing and is
@@ -782,6 +782,105 @@ else
782
782
  echo " ✅ PROV-2 line tracks the resolution when it changes (shadowed → different value)"; pass=$((pass+1))
783
783
  fi
784
784
 
785
+ # ---- RC: --require-class — narrowing the verdict from the FILE to ONE CLASS ---------
786
+ # Added 2026-08-16 after a pre-publish security pass found the caller side broken: fh_node_check.sh
787
+ # gated a consumer-machine `git merge --ff-only` on this script's FILE-WIDE 0 plus a raw grep for
788
+ # the class name anywhere in the UAP. One unrelated validly-granted class, plus the target class
789
+ # appearing only in a prose line saying it was REVOKED, satisfied both — the merge ran and the
790
+ # banner reported a standing consent that did not exist.
791
+ # Every lane below is a REFUSAL that leaves the file-wide verdict at 0. That property is asserted,
792
+ # not assumed: without it these lanes would pass for the same reason the existing "nothing granted"
793
+ # lanes do, and the class-join axis would go unexercised a second time.
794
+ lane_args() { # desc want_rc [args...] — same as lane() but the flag order is the point
795
+ local desc="$1" want_rc="$2"; shift 2
796
+ bash "$CHK" "$@" >"$TD/o" 2>&1
797
+ local rc=$?
798
+ if [ "$rc" -ne "$want_rc" ]; then
799
+ bad "$desc — exit $rc, expected $want_rc"; sed 's/^/ /' "$TD/o"; return
800
+ fi
801
+ ok "$desc"
802
+ }
803
+
804
+ TWOCLASS="$OKCLASS
805
+ - {name: other, owner: o2, mode: m2, target: t2, capabilities: [read], sinks: [], feeds: [], promotion_eligible: true}"
806
+
807
+ # a) the class IS granted → 0
808
+ mkreg "$TWOCLASS"
809
+ mkuap 'standing_consent:
810
+ ok: {owner: o, mode: m, sinks: [], granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
811
+ lane_args "RC-a --require-class on a class that IS granted → 0" 0 --require-class ok "$TD/r.yaml" "$TD/u.md"
812
+
813
+ # b) a DIFFERENT class is granted, the target appears only as prose → 3, file-wide still 0
814
+ mkreg "$TWOCLASS"
815
+ printf -- '---\nstanding_consent:\n other: {owner: o2, mode: m2, sinks: [], granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t2}\n---\n\n# UAP (fixture)\n\n ok: 안 쓰기로 했다 (revoked)\n' > "$TD/u.md"
816
+ bash "$CHK" "$TD/r.yaml" "$TD/u.md" >/dev/null 2>&1; _fw=$?
817
+ lane_args "RC-b another class granted, target only in prose → 3" 3 --require-class ok "$TD/r.yaml" "$TD/u.md"
818
+ [ "$_fw" -eq 0 ] \
819
+ && ok "RC-b DISCRIMINATION: the file-wide verdict is still 0, so RC-b refused on the class join" \
820
+ || bad "RC-b VACUOUS: file-wide verdict was $_fw — this lane re-tests an existing axis, not the class join"
821
+
822
+ # c) the ON switch must not be silently OFF. A whitespace-only name passed a `-z` test in the first
823
+ # draft and then strip()ed to empty, disabling the narrowing and returning the file-wide 0.
824
+ lane_args "RC-c whitespace-only class name → 1 (fail-closed, never a silent fall-back)" 1 --require-class " " "$TD/r.yaml" "$TD/u.md"
825
+ lane_args "RC-d --require-class= empty form → 1" 1 --require-class= "$TD/r.yaml" "$TD/u.md"
826
+
827
+ # e) flag position. The first draft read argv[1] only, so a caller appending the flag got the old
828
+ # file-wide 0 with no warning — the failure mode is silence, which is why this is a lane.
829
+ lane_args "RC-e flag AFTER the paths is still honoured → 3" 3 "$TD/r.yaml" "$TD/u.md" --require-class ok
830
+ lane_args "RC-f --require-class=NAME form is honoured → 3" 3 --require-class=ok "$TD/r.yaml" "$TD/u.md"
831
+
832
+ # g) an unknown option must not be swallowed as a path
833
+ lane_args "RC-g unknown option → 1, not consumed as a path" 1 --bogus "$TD/r.yaml" "$TD/u.md"
834
+
835
+ # h) the human summary must agree with the typed exit — this file's own rule, applied to the new path
836
+ bash "$CHK" --require-class ok "$TD/r.yaml" "$TD/u.md" >"$TD/o" 2>&1
837
+ if grep -qE '^consent-registry: UNMEASURED for class `ok`' "$TD/o" && ! grep -qE '^consent-registry: PASS$' "$TD/o"; then
838
+ ok "RC-h summary agrees with the typed exit (no bare PASS above an exit-3 verdict)"
839
+ else
840
+ bad "RC-h summary contradicts the verdict"; sed 's/^/ /' "$TD/o"
841
+ fi
842
+
843
+ # i) no flag → behaviour unchanged. Backward compatibility is a claim, so it gets a lane.
844
+ lane_args "RC-i no flag → file-wide verdict unchanged (0)" 0 "$TD/r.yaml" "$TD/u.md"
845
+
846
+ # j) IDENTITY IS NOT NORMALISED FOR THE CALLER. A third adversarial round measured the first repair
847
+ # squeezing ALL whitespace out of the name, so `--require-class 'o k'` was answered with the
848
+ # verdict for class `ok` — a different question, silently answered. Asking about a name that is
849
+ # not a name is refused, never repaired.
850
+ mkreg "$TWOCLASS"
851
+ mkuap 'standing_consent:
852
+ ok: {owner: o, mode: m, sinks: [], granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
853
+ lane_args "RC-j interior whitespace in the class name → 1, never folded onto a real class" 1 --require-class "o k" "$TD/r.yaml" "$TD/u.md"
854
+ lane_args "RC-k surrounding whitespace IS trimmed → 0 (trim, not squeeze)" 0 --require-class " ok " "$TD/r.yaml" "$TD/u.md"
855
+
856
+ # l) argv preservation. The first repair rebuilt "$@" through a newline-delimited string, which split
857
+ # a path containing a newline and DROPPED an empty positional — the drop promoted the UAP path
858
+ # into the registry slot, i.e. the tool silently measured a different pair of files than it was
859
+ # handed. Both are exotic inputs; both are silent, which is why they are lanes.
860
+ _nlreg="$TD/reg
861
+ two.yaml"
862
+ cp "$TD/r.yaml" "$_nlreg" 2>/dev/null && lane_args "RC-l a registry path containing a newline is one argument" 0 --require-class ok "$_nlreg" "$TD/u.md" \
863
+ || ok "RC-l SKIPPED — this filesystem rejected a newline in a filename (not a defect)"
864
+ # RC-m asserts on WHICH registry path the tool reports, not on a success marker. The first version
865
+ # grepped for `R1/R2`, which only appears when the DEFAULT registry parses — and that default is
866
+ # `tracks/_meta/consent_classes.yaml`, which is **gitignored**, so it exists on the author's machine
867
+ # and not in CI. It passed locally and went red on the first CI run: a lane that measured the
868
+ # author's filesystem rather than the behaviour. This form works in both.
869
+ # Calibrated: the control proves the reported path tracks argv[1] at all, so the assertion below is
870
+ # a discrimination result and not a grep that could never match.
871
+ bash "$CHK" "/nonexistent-registry-probe.yaml" "$TD/u.md" >"$TD/oc" 2>&1
872
+ if grep -q "nonexistent-registry-probe.yaml" "$TD/oc"; then
873
+ ok "RC-m CONTROL: the reported registry path tracks argv[1]"
874
+ else
875
+ bad "RC-m CONTROL dead: the tool did not name the registry it was given — the assertion below proves nothing"; sed 's/^/ /' "$TD/oc"
876
+ fi
877
+ bash "$CHK" "" "$TD/u.md" >"$TD/o" 2>&1
878
+ if grep -qE "registry (at|unparseable).*$(basename "$TD")/u\.md" "$TD/o"; then
879
+ bad "RC-m empty positional shifted the argument list — the UAP was read as the registry"; sed 's/^/ /' "$TD/o"
880
+ else
881
+ ok "RC-m an empty first positional does not promote the UAP into the registry slot"
882
+ fi
883
+
785
884
  echo "----"
786
885
  echo "consent-registry anchor: $pass passed, $fail failed"
787
886
  [ "$fail" -eq 0 ] || exit 1
@@ -216,6 +216,80 @@ else
216
216
  printf '%s\n' "$out" | sed 's/^/ │ /'
217
217
  fi
218
218
 
219
+ # ── L14/L15 the SUBJECT'S OWN documentation is not a caller (2026-08-15) ──────────────────────
220
+ # Measured on this repo: `self-test: 10/10 wired` while directional_diff_gate.sh had ZERO callers
221
+ # (`grep -rn directional_diff_gate scripts/*.sh templates/.git-hooks/* .github/workflows/*.yml`,
222
+ # excluding the checker and the subject itself → nothing). The subject's own usage comment
223
+ # (`# bash scripts/directional_diff_gate.sh --self-test`) matched the direct-dispatch regex, so
224
+ # the checker read the file's documentation of itself as evidence that something ran it.
225
+ #
226
+ # This is the SAME class the checker already closes one level up and, until now, only there:
227
+ # `runners` drops lane_runner_check.sh so the tool cannot certify its own DEBT list as done, and
228
+ # has_runner() skips a suite's own file, and runner_dispatches() skips comment lines. None of the
229
+ # three guards reached the --self-test branch — has_selftest_runner() scanned every runner
230
+ # including the subject, over raw text including comments. A guard that exists in one predicate
231
+ # and not in its sibling is not a guard, it is a coincidence.
232
+ #
233
+ # Two lanes because the two halves fail independently: self-exclusion alone still lets a COMMENT
234
+ # in a third file certify a subject, and comment-skipping alone still lets a subject's own
235
+ # non-comment self-reference do it.
236
+
237
+ # L14 known-POSITIVE: the subject's OWN usage comment must not count as a caller.
238
+ D="$T/l14"; mkfixture_clean "$D"
239
+ cat > "$D/scripts/orphan_probe.sh" <<'SUBJ'
240
+ #!/usr/bin/env bash
241
+ # Usage:
242
+ # bash scripts/orphan_probe.sh --self-test # known-pair calibration
243
+ [ "${1:-}" = "--self-test" ] && { echo ok; exit 0; }
244
+ SUBJ
245
+ out=$(run "$D"); rc=$(rcof "$D")
246
+ if [ "$rc" = "0" ] && printf '%s' "$out" | grep -q 'orphan_probe'; then
247
+ ok "L14 known-positive: subject's own usage comment is not a caller — still reported unwired"
248
+ else
249
+ bad "L14 known-positive did not fire (rc=$rc) — self-referential comment read as a dispatcher"
250
+ printf '%s\n' "$out" | sed 's/^/ │ /'
251
+ fi
252
+
253
+ # L15 known-POSITIVE: a comment in a DIFFERENT file must not count either. Same discipline
254
+ # runner_dispatches() already applies to ordinary suites ("a mention is not an invocation").
255
+ D="$T/l15"; mkfixture_clean "$D"
256
+ printf '#!/usr/bin/env bash\n[ "${1:-}" = "--self-test" ] && { echo ok; exit 0; }\n' > "$D/scripts/orphan_probe.sh"
257
+ cat > "$D/scripts/selfcheck.sh" <<'SC'
258
+ #!/usr/bin/env bash
259
+ bash scripts/lane_runner_check.sh
260
+ bash scripts/test_ok_lanes.sh
261
+ # historical note: we used to run `bash scripts/orphan_probe.sh --self-test` here, removed 2026-01-01
262
+ SC
263
+ out=$(run "$D"); rc=$(rcof "$D")
264
+ if [ "$rc" = "0" ] && printf '%s' "$out" | grep -q 'orphan_probe'; then
265
+ ok "L15 known-positive: a commented-out dispatch in another file is not a caller"
266
+ else
267
+ bad "L15 known-positive did not fire (rc=$rc) — a comment read as a live dispatcher"
268
+ printf '%s\n' "$out" | sed 's/^/ │ /'
269
+ fi
270
+
271
+ # L16 known-POSITIVE: a subject that names its own dispatch on a NON-comment line. This is the only
272
+ # lane that actually pins the self-exclusion guard, and it exists because a cross-family round
273
+ # (2026-08-15) showed L14/L15 do not: both of their fixtures put the self-reference in a `#` line,
274
+ # so comment-stripping alone already satisfies them and the guard could be deleted with all lanes
275
+ # still green — a repair with no control, which is the exact failure this file was written to name.
276
+ # The shape is a usage helper, because that is how a real subject names its own flag in live code
277
+ # rather than in a header comment.
278
+ D="$T/l16"; mkfixture_clean "$D"
279
+ cat > "$D/scripts/orphan_probe.sh" <<'SUBJ'
280
+ #!/usr/bin/env bash
281
+ usage() { echo "run: bash scripts/orphan_probe.sh --self-test"; }
282
+ [ "${1:-}" = "--self-test" ] && { echo ok; exit 0; }
283
+ usage
284
+ SUBJ
285
+ out=$(run "$D"); rc=$(rcof "$D")
286
+ if [ "$rc" = "0" ] && printf '%s' "$out" | grep -q 'orphan_probe'; then
287
+ ok "L16 known-positive: a subject's own NON-comment self-reference is not a caller"
288
+ else
289
+ bad "L16 known-positive did not fire (rc=$rc) — self-exclusion guard is unanchored"
290
+ printf '%s\n' "$out" | sed 's/^/ │ /'
291
+ fi
292
+
219
293
  echo "----"
220
294
  echo "lane-runner lanes: $pass passed, $fail failed"
221
295
  [ "$fail" -eq 0 ] || exit 1
@@ -174,6 +174,223 @@ for h in "$FH_REPO"/templates/.git-hooks/pre-commit "$FH_REPO"/templates/.git-ho
174
174
  else bad "lane9 sentinel does NOT match shipped $(basename "$h") — every FH machine would report 'not FH gate'" "$(head -3 "$h")"; fi
175
175
  done
176
176
 
177
+ # ── lane10 — consent-gated auto-pull: the APPLY arm and every REFUSE arm ─────────────────────
178
+ # Added 2026-08-15 with the feature. These lanes exist because the feature's whole safety claim is
179
+ # about what it does NOT do, and "does not" is exactly what a green run cannot demonstrate by
180
+ # itself — every refuse arm below is paired against an apply arm in the same fixture, so a lane
181
+ # that goes silently inert fails the CONTROL rather than passing everything.
182
+ # 🟥 The load-bearing one is lane10-b: in a shared checkout a branch switch yanks the ground out
183
+ # from under a peer session (measured 2026-08-09, two sessions/one worktree). If this feature ever
184
+ # switches branches, that lane is what says so.
185
+ _ap_root="$(mktemp -d)"
186
+ (
187
+ cd "$_ap_root" || exit 1
188
+ # Pin the branch name: `git init` uses init.defaultBranch, which is main on this
189
+ # operator's machine and master on a stock Ubuntu (CI). Hardcoding "main" in the
190
+ # assertions below made lane10-b pass VACUOUSLY there (`rev-parse "$_DEF"` returned
191
+ # empty, and empty==empty compared true) while CONTROL's reset silently no-op'd.
192
+ git -c init.defaultBranch=main init -q up && cd up
193
+ git config user.email t@t; git config user.name t; git config commit.gpgsign false
194
+ mkdir -p scripts tracks/_meta
195
+ cp "$FH_REPO/scripts/fh_node_check.sh" "$FH_REPO/scripts/consent_registry_check.sh" scripts/
196
+ # Build the consent fixture from the SHIPPED TEMPLATE, never from the operator's local
197
+ # tracks/_meta/ — those are gitignored, so on CI (and on any fresh clone) they do not exist and
198
+ # the lanes would report a red FAIL for a file whose absence is correct by design. Measured on
199
+ # CI 2026-08-15: exactly that, "registry/UAP absent — lanes not run". Sourcing the template
200
+ # instead is strictly better than degrading to SKIP: the lanes then actually run everywhere, and
201
+ # they validate the very block a consumer is told to copy.
202
+ cp "$FH_REPO/templates/consent_classes.yaml.example" tracks/_meta/consent_classes.yaml
203
+ # The grant side has no shipped template (a UAP is per-operator by nature), so synthesize the
204
+ # minimum the registry check requires: the full R6 fingerprint, far-future expiry so the fixture
205
+ # never rots into a false refusal, joined to the template's own class name.
206
+ # Dates are computed at fixture-build time, not hardcoded, for two reasons that pull opposite
207
+ # ways: a fixed past date rots into a false REFUSE, and a far-future one is refused outright —
208
+ # R5 caps a lease at 365 days ("an unbounded expiry is a transfer, not a lease"), and the first
209
+ # version of this fixture used 2099-01-01 and was correctly rejected by the very floor the
210
+ # feature depends on. GNU-first with validation, same discipline as every _mtime helper here:
211
+ # `date -d` is GNU, `date -v` is BSD, and neither failing silently is acceptable.
212
+ _g="$(date +%Y-%m-%d)"
213
+ _e="$(date -d '+300 days' +%Y-%m-%d 2>/dev/null || date -v+300d +%Y-%m-%d 2>/dev/null || echo '')"
214
+ case "$_e" in
215
+ [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) : ;;
216
+ *) bad "lane10 fixture: could not compute a lease expiry (neither GNU nor BSD date worked)" "$_e"; _e="$_g" ;;
217
+ esac
218
+ cat > tracks/_meta/user_adaptation_profile.md <<UAPEOF
219
+ ---
220
+ standing_consent:
221
+ repo-freshness-autopull:
222
+ granted: $_g
223
+ expires: $_e
224
+ owner: scripts/fh_node_check.sh
225
+ mode: ff-only-merge-on-default-branch
226
+ target: this repo's own default branch, in the local clone, when it is already checked out
227
+ effects: [network, repo-mutation]
228
+ sinks: []
229
+ ---
230
+ lane fixture — not a real profile
231
+ UAPEOF
232
+ echo base > f.txt; git add -A >/dev/null; git commit -qm base
233
+ cd ..; git clone -q up dn; cd up; echo newer > f.txt; git commit -qam ahead
234
+ ) >/dev/null 2>&1
235
+ _dn="$_ap_root/dn"
236
+ if [ ! -d "$_dn/.git" ] || [ ! -f "$_dn/tracks/_meta/consent_classes.yaml" ]; then
237
+ bad "lane10 fixture: could not build the consent fixture (registry/UAP absent — lanes not run)" "$_ap_root"
238
+ else
239
+ git -C "$_dn" config user.email t@t >/dev/null 2>&1; git -C "$_dn" config user.name t >/dev/null 2>&1
240
+ # Derive rather than assume — belt-and-braces over the pin above, so a future git
241
+ # whose init behaviour changes again cannot make these lanes vacuous a second time.
242
+ _DEF="$(git -C "$_dn" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')"
243
+ [ -n "$_DEF" ] || _DEF=main
244
+ if ! git -C "$_dn" show-ref --verify --quiet "refs/heads/$_DEF"; then
245
+ bad "lane10 fixture: default branch '$_DEF' has no local ref — assertions below would be vacuous" "$(git -C "$_dn" branch -a)"
246
+ fi
247
+ git -C "$_dn" fetch -q origin >/dev/null 2>&1
248
+ _h0="$(git -C "$_dn" rev-parse HEAD)"
249
+ run "$_dn" "$_ap_root/s_a" >/dev/null 2>&1
250
+ if [ "$(git -C "$_dn" rev-parse HEAD)" != "$_h0" ]; then
251
+ ok "lane10-a APPLY: consented + on default branch → fast-forwarded"
252
+ else
253
+ bad "lane10-a APPLY: consented + on default branch did NOT fast-forward (feature inert — every refuse lane below is then vacuous)" "$_h0"
254
+ fi
255
+
256
+ # b — the envelope. Never applies off the default branch, and NEVER switches branches.
257
+ (cd "$_ap_root/up" && echo newer2 > f.txt && git commit -qam ahead2) >/dev/null 2>&1
258
+ git -C "$_dn" fetch -q origin >/dev/null 2>&1
259
+ git -C "$_dn" switch -c feat-x -q >/dev/null 2>&1
260
+ _m0="$(git -C "$_dn" rev-parse "$_DEF")"
261
+ # ⚠️ CHECKING main AND THE BRANCH NAME IS NOT ENOUGH — measured by a revert probe 2026-08-15.
262
+ # With the envelope deliberately broken, `merge --ff-only origin/main` fast-forwards THE BRANCH
263
+ # YOU ARE ON, so feat-x moved while `main` sat still and the branch name never changed. The first
264
+ # version of this lane asserted only those two and stayed green against a defeated envelope —
265
+ # decorative. The tip that must not move is the CURRENT branch's own.
266
+ _f0="$(git -C "$_dn" rev-parse feat-x)"
267
+ run "$_dn" "$_ap_root/s_b" >/dev/null 2>&1
268
+ if [ "$(git -C "$_dn" rev-parse "$_DEF")" = "$_m0" ] \
269
+ && [ "$(git -C "$_dn" rev-parse feat-x)" = "$_f0" ] \
270
+ && [ "$(git -C "$_dn" branch --show-current)" = "feat-x" ]; then
271
+ ok "lane10-b ENVELOPE: off default branch → no apply on EITHER branch, no switch"
272
+ else
273
+ bad "lane10-b ENVELOPE: a branch tip moved off the default branch — the shared-checkout hazard" "main=$_m0->$(git -C "$_dn" rev-parse "$_DEF") feat-x=$_f0->$(git -C "$_dn" rev-parse feat-x) branch=$(git -C "$_dn" branch --show-current)"
274
+ fi
275
+ git -C "$_dn" switch -q "$_DEF" >/dev/null 2>&1
276
+
277
+ # c — no grant. absent ≠ granted.
278
+ cp "$_dn/tracks/_meta/user_adaptation_profile.md" "$_ap_root/uap.bak"
279
+ python3 - "$_dn/tracks/_meta/user_adaptation_profile.md" <<'PYX'
280
+ import re,sys
281
+ p=sys.argv[1]; s=open(p,encoding='utf-8').read()
282
+ open(p,'w',encoding='utf-8').write(re.sub(r'\nstanding_consent:\n(?: .*\n| .*\n)*', '\n', s, count=1))
283
+ PYX
284
+ _h1="$(git -C "$_dn" rev-parse HEAD)"
285
+ run "$_dn" "$_ap_root/s_c" >/dev/null 2>&1
286
+ [ "$(git -C "$_dn" rev-parse HEAD)" = "$_h1" ] \
287
+ && ok "lane10-c NO-GRANT: absent standing_consent → no apply" \
288
+ || bad "lane10-c NO-GRANT: applied without a grant" "$_h1"
289
+
290
+ # d — expired lease. A lease nobody enforces is not a lease.
291
+ cp "$_ap_root/uap.bak" "$_dn/tracks/_meta/user_adaptation_profile.md"
292
+ sed -i.bak2 's/expires: [0-9-]*/expires: 2020-01-01/' "$_dn/tracks/_meta/user_adaptation_profile.md"
293
+ _h2="$(git -C "$_dn" rev-parse HEAD)"
294
+ run "$_dn" "$_ap_root/s_d" >/dev/null 2>&1
295
+ [ "$(git -C "$_dn" rev-parse HEAD)" = "$_h2" ] \
296
+ && ok "lane10-d EXPIRED: past-dated lease → no apply" \
297
+ || bad "lane10-d EXPIRED: applied under an expired lease" "$_h2"
298
+ cp "$_ap_root/uap.bak" "$_dn/tracks/_meta/user_adaptation_profile.md"
299
+
300
+ # e — divergence. --ff-only must refuse rather than absorb, and local work must survive.
301
+ (cd "$_dn" && echo local-only > mine.txt && git add -A && git commit -qm diverge) >/dev/null 2>&1
302
+ _h3="$(git -C "$_dn" rev-parse HEAD)"
303
+ run "$_dn" "$_ap_root/s_e" >/dev/null 2>&1
304
+ if [ "$(git -C "$_dn" rev-parse HEAD)" = "$_h3" ] && [ -f "$_dn/mine.txt" ]; then
305
+ ok "lane10-e DIVERGED: ff refused, local commit survives"
306
+ else
307
+ bad "lane10-e DIVERGED: local history was moved or lost" "$_h3 -> $(git -C "$_dn" rev-parse HEAD)"
308
+ fi
309
+
310
+ # f — WRONG CLASS. The refusal that the other three arms structurally cannot produce.
311
+ # Arms c/d/e all refuse by breaking the FILE-WIDE verdict (grant block deleted → exit 3; lease
312
+ # past-dated → exit 1; divergence → git itself refuses). None of them touches the question "is
313
+ # THIS class granted", so a caller keyed on the file-wide 0 passed them all — measured 2026-08-15
314
+ # by a pre-publish security pass, with a live control: one unrelated class validly granted plus
315
+ # the target class named only in prose, and the merge ran while the banner claimed a standing
316
+ # consent that never existed. The revoke path was the broken one.
317
+ # The second assertion below is the load-bearing half: this arm must refuse WHILE the bare
318
+ # file-wide check still returns 0. Without it the lane would pass for the same reason lane10-c
319
+ # does, and the axis would go unexercised again.
320
+ # ORDER MATTERS, and getting it wrong is silent: the UAP is a TRACKED file in this fixture, so
321
+ # `git reset --hard` reverts any edit made to it. The first draft transformed the UAP and then
322
+ # reset — restoring the original grant, so the apply fired legitimately and the lane failed for a
323
+ # reason that looked like the defect. Reset and advance origin FIRST, edit the UAP AFTER.
324
+ git -C "$_dn" reset -q --hard "origin/$_DEF" >/dev/null 2>&1 # noqa: destructive-op (throwaway fixture)
325
+ (cd "$_ap_root/up" && echo newer-f > f.txt && git commit -qam ahead-f) >/dev/null 2>&1
326
+ git -C "$_dn" fetch -q origin >/dev/null 2>&1
327
+ cp "$_ap_root/uap.bak" "$_dn/tracks/_meta/user_adaptation_profile.md"
328
+ python3 - "$_dn/tracks/_meta/user_adaptation_profile.md" <<'PYX'
329
+ import sys, re as _re
330
+ p = sys.argv[1]; s = open(p, encoding='utf-8').read()
331
+ old = " repo-freshness-autopull:"
332
+ assert old in s, "lane10-f fixture: grant key not found"
333
+ # Reuse the lease dates the fixture already computed, so this arm cannot fail for a date reason and
334
+ # be mistaken for a class-join refusal. (First draft omitted them: R5 fired, the file-wide verdict
335
+ # went to 1, and the arm's own vacuity guard caught it — which is what that guard is for.)
336
+ _g = _re.search(r"granted: (\S+)", s).group(1)
337
+ _e = _re.search(r"expires: (\S+)", s).group(1)
338
+ # A VALID grant for a different registered, promotion_eligible class, scope-matched to its
339
+ # registration so R6/R7 pass — the point is a file that is entirely well-formed.
340
+ other = (" dispatch-readonly-sim-local-artifact:\n"
341
+ f" granted: {_g}\n"
342
+ f" expires: {_e}\n"
343
+ " owner: fh-meta:sim-conductor\n"
344
+ " mode: blind-target-tier-sim\n"
345
+ " target: a single local file under the current repo\n"
346
+ " effects: [read, dispatch]\n"
347
+ " sinks: []\n")
348
+ s = s.replace(old, " __TARGET_GRANT__:", 1)
349
+ head, sep, tail = s.partition("standing_consent:\n")
350
+ s = head + sep + other + tail
351
+ # the target class survives ONLY as prose — the shape the old raw grep could not tell from a grant
352
+ s = s.replace("lane fixture — not a real profile",
353
+ "lane fixture — not a real profile\n\n repo-freshness-autopull: 철회함 (revoked)")
354
+ import re
355
+ s = re.sub(r" __TARGET_GRANT__:\n(?: .*\n)*", "", s, count=1)
356
+ open(p, 'w', encoding='utf-8').write(s)
357
+ PYX
358
+ # PRECONDITION, asserted rather than assumed. lane10-e leaves the clone DIVERGED, and a diverged
359
+ # clone refuses the fast-forward for a reason that has nothing to do with consent — so without the
360
+ # reset above the arm passes whether the class join works or not. Measured: a revert probe against
361
+ # the repaired fh_node_check.sh left this lane green, i.e. it was decorative. Same shape as the
362
+ # self-exclusion lane earlier the same day; the probe is what separates the two cases.
363
+ _h5="$(git -C "$_dn" rev-parse HEAD)"
364
+ if [ "$_h5" = "$(git -C "$_dn" rev-parse "origin/$_DEF")" ]; then
365
+ bad "lane10-f VACUOUS: clone is not behind origin, so 'no apply' proves nothing" "$_h5"
366
+ fi
367
+ run "$_dn" "$_ap_root/s_f" >/dev/null 2>&1
368
+ _filewide=0
369
+ bash "$FH_REPO/scripts/consent_registry_check.sh" \
370
+ "$_dn/tracks/_meta/consent_classes.yaml" \
371
+ "$_dn/tracks/_meta/user_adaptation_profile.md" >/dev/null 2>&1 || _filewide=$?
372
+ if [ "$(git -C "$_dn" rev-parse HEAD)" = "$_h5" ] && [ "$_filewide" = "0" ]; then
373
+ ok "lane10-f WRONG-CLASS: another class granted, this one only in prose → no apply (file-wide check still 0)"
374
+ elif [ "$_filewide" != "0" ]; then
375
+ bad "lane10-f VACUOUS: the fixture broke the file-wide verdict (rc=$_filewide), so it re-tests lane10-c's axis, not the class join" "rebuild the fixture so the bare check returns 0"
376
+ else
377
+ bad "lane10-f WRONG-CLASS: applied on a class that was never granted" "$_h5 -> $(git -C "$_dn" rev-parse HEAD)"
378
+ fi
379
+ cp "$_ap_root/uap.bak" "$_dn/tracks/_meta/user_adaptation_profile.md"
380
+
381
+ # CONTROL — after all the refuse arms, prove the apply arm still fires. Without this, a lane
382
+ # suite where the feature has gone inert reports 4 clean refusals and looks perfect.
383
+ git -C "$_dn" reset -q --hard "origin/$_DEF" >/dev/null 2>&1 # noqa: destructive-op (throwaway fixture)
384
+ (cd "$_ap_root/up" && echo newer3 > f.txt && git commit -qam ahead3) >/dev/null 2>&1
385
+ git -C "$_dn" fetch -q origin >/dev/null 2>&1
386
+ _h4="$(git -C "$_dn" rev-parse HEAD)"
387
+ run "$_dn" "$_ap_root/s_f" >/dev/null 2>&1
388
+ [ "$(git -C "$_dn" rev-parse HEAD)" != "$_h4" ] \
389
+ && ok "lane10-CONTROL: apply arm still fires after the refuse arms (instrument alive)" \
390
+ || bad "lane10-CONTROL: apply arm went inert — the refuse lanes above prove nothing" "$_h4"
391
+ fi
392
+ rm -rf "$_ap_root"
393
+
177
394
  printf '\nnode-check lanes: %d passed, %d failed\n' "$PASS" "$FAIL"
178
395
  [ "$FAIL" -eq 0 ] || exit 1
179
396
  exit 0
@@ -357,6 +357,67 @@ CTLPY
357
357
  [ "$_BARE_CTL" = FIRED ]
358
358
  chk $? "CONTROL: that detector fires on a planted declared-shipped subject (got $_BARE_CTL)"
359
359
 
360
+ echo ""
361
+ echo "── directional_diff_gate verdict arm: exit 0 is not a pass ──"
362
+ # WHY THIS LANE EXISTS (2026-08-15): the block wiring directional_diff_gate.sh --self-test gates on
363
+ # the subject's terminal verdict, not on rc=0 alone, and both halves of that were put there by a
364
+ # cross-family round rather than by the author. Its first draft gated on rc=0 plus a SUBSTRING of
365
+ # the verdict, and two reachable inputs satisfied it while nothing ran:
366
+ # · `✅ calibration passed (0 pairs)` — the subject's own verdict line is
367
+ # `[ "$f" -eq 0 ] && echo "✅ calibration passed ($n pairs)"`, so deleting every lane prints a
368
+ # PASS with n=0. Deletion read as a pass is the quietest face of the not-found-is-not-zero class.
369
+ # · `usage: calibration passed (28 pairs)` — a prose/usage line carrying the words.
370
+ # Without this lane both repairs are reversible to green, which is anchor-is-decorative.
371
+ # LIFTED from selfcheck.sh, not re-spelled — a lane that restates the condition stops seeing edits
372
+ # to it, which is the failure the two lanes above were written to avoid.
373
+ DD_ARM=$(sed -n '/^ _dd_verdict=\$(printf/,/^ fi ;;$/p' "$SELFCHECK" | sed 's/ ;;$//')
374
+ if [ -z "$DD_ARM" ]; then
375
+ echo "FAIL the directional_diff_gate verdict arm is no longer where this lane lifts it from."
376
+ echo " If the block was moved or renamed, update the lane WITH the subject."
377
+ exit 1
378
+ fi
379
+ # An empty lift is not the only bad lift. If the END anchor stops matching — re-indent the arm, or
380
+ # move it out of the `case` — the START anchor still matches and the range runs to EOF, which is
381
+ # non-empty and therefore passes the guard above. What saves it today is luck, not design: the
382
+ # `;;`-stripping applies to every line in the range, so downstream case arms lose their terminators
383
+ # and `eval` dies of a syntax error. A downstream span that happened to be self-contained would
384
+ # instead `eval` several hundred lines of selfcheck.sh, once per fixture, at the repo root. Bound
385
+ # the lift by size so the failure is a lane message rather than an accident. Cross-family, 2026-08-15.
386
+ if [ "$(printf '%s\n' "$DD_ARM" | wc -l)" -gt 15 ]; then
387
+ echo "FAIL the lifted verdict arm is $(printf '%s\n' "$DD_ARM" | wc -l) lines — the sed END anchor"
388
+ echo " stopped matching and the range ran past the block. Fix the anchor, do not widen this bound."
389
+ exit 1
390
+ fi
391
+ dd_arm_says() { # $1 = the subject's stdout; echoes PASS or FAIL
392
+ ( _out="$1"; fail=0; _show_failure() { :; }
393
+ eval "$DD_ARM" ) | grep -oE '^(PASS|FAIL)' | head -1
394
+ }
395
+
396
+ [ "$(dd_arm_says '✅ calibration passed (28 pairs)')" = PASS ]
397
+ chk $? "a real verdict with a non-zero pair count passes"
398
+
399
+ [ "$(dd_arm_says '✅ calibration passed (0 pairs)')" = FAIL ]
400
+ chk $? "every lane deleted (0 pairs, exit 0) is caught, not certified"
401
+
402
+ [ "$(dd_arm_says 'usage: calibration passed (28 pairs)')" = FAIL ]
403
+ chk $? "a usage/prose line carrying the words does not satisfy the gate"
404
+
405
+ # CONTROL — the pre-repair form (bare substring, no pair-count floor) must ACCEPT both bad
406
+ # fixtures. Without it these lanes could pass because the fixtures are wrong rather than because
407
+ # the repair works.
408
+ _pre() { printf '%s\n' "$1" | grep -oE 'calibration passed \([0-9]+ pairs\)' | tail -1; }
409
+ [ -n "$(_pre '✅ calibration passed (0 pairs)')" ] && [ -n "$(_pre 'usage: calibration passed (28 pairs)')" ]
410
+ chk $? "CONTROL: the pre-repair substring form accepts both — the fixtures discriminate"
411
+
412
+ # SCOPE of these four lanes, stated because the inherited §SCOPE block below is about a different
413
+ # anchor and does not cover them. The lift is the `0)` arm's BODY only, so these lanes see a change
414
+ # to the verdict predicate and are blind to the routing around it: widening `case ... in 0)` to
415
+ # `0|1)`, dropping `< /dev/null`, dropping the `$_LANE_TO` wrapper, or deleting the rc=124
416
+ # HARNESS-ERROR arm all leave every lane here green. Those are guarded by review, not by this file.
417
+ # The `_pre` control is also a RE-SPELLING of the pre-repair form rather than a lift of it — it has
418
+ # to be, since that form no longer exists in the subject to lift, but it means the control asserts
419
+ # what the old code did from memory. Both limits found by cross-family review, 2026-08-15.
420
+
360
421
  # ── SCOPE OF THIS ANCHOR — stated so it is not over-trusted ───────────────────
361
422
  # These lanes catch REVERSION (the run-twice form coming back, the helper being gutted, the
362
423
  # call-site redirect returning). They do NOT catch deliberate EVASION: a cross-family round
@@ -44,6 +44,36 @@
44
44
  # Re-point the class's `owner` or `mode` after the grant and the join fails: that is the point.
45
45
 
46
46
  classes:
47
+ # ---- REAL, ADOPTABLE class (not illustrative) -------------------------------------
48
+ # Ships with fh_node_check.sh's consent-gated auto-pull. Copy this block verbatim into your
49
+ # own tracks/_meta/consent_classes.yaml, then grant it in your UAP frontmatter to enable the
50
+ # apply arm. Without both, the check surfaces "N commits behind" and never touches the repo —
51
+ # absent is not granted.
52
+ #
53
+ # Read the reason before adopting: this class takes a DIFFERENT verdict from the `local-commit`
54
+ # example further down, on a deliberately narrow ground. If your integration branch is not
55
+ # PR-only/protected, that ground does not hold for you and you should not adopt it.
56
+ - name: repo-freshness-autopull
57
+ owner: scripts/fh_node_check.sh
58
+ mode: ff-only-merge-on-default-branch
59
+ target: this repo's own default branch, in the local clone, when it is already checked out
60
+ capabilities: [network, repo-mutation]
61
+ sinks: []
62
+ feeds: []
63
+ promotion_eligible: true
64
+ lease_days: 92
65
+ reason: >
66
+ A fast-forward pull introduces nothing new: every commit it brings down is already on the
67
+ integration branch, which is PR-only and server-side protected, so all of it has passed the
68
+ gates. `local-commit` below is classified feeds:[go-public] because it CREATES ungated
69
+ content that publish can then ship; this makes the tree MATCH canon instead. Publish packs
70
+ the working tree, so a tree matching origin is strictly safer to publish from than a stale
71
+ one. Blast radius also excludes personal state by construction — a pull touches tracked
72
+ files only, and session/memory/companion state is gitignored or outside the repo.
73
+ Envelope (enforced in code, not prose): applies ONLY when the default branch is already
74
+ checked out, never switches branches, and --ff-only refuses a divergence rather than
75
+ absorbing it. Lanes: scripts/test_node_check_lanes.sh lane10-a..e + CONTROL.
76
+
47
77
  # ---- promotion-eligible example -------------------------------------------------
48
78
  - name: dispatch-readonly-sim-local-artifact
49
79
  owner: fh-meta:sim-conductor # the gate/skill that does the asking