@chrono-meta/fh-gate 2.2.0 → 2.4.0

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.
Files changed (38) hide show
  1. package/.claude/capabilities/adapters/qasp-new-code-anchor.cap +5 -2
  2. package/.claude/capabilities/adapters/qasp-web-rules.cap +130 -0
  3. package/.claude/rules/fh_4axis_gate.md +55 -0
  4. package/.claude-plugin/marketplace.json +2 -2
  5. package/CLAUDE.md +51 -6
  6. package/README.ja.md +14 -2
  7. package/README.ko.md +14 -1
  8. package/README.md +16 -2
  9. package/README.zh.md +12 -1
  10. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +231 -7
  11. package/knowledge/shared/harness-core/harness_incubator_doctrine.md +215 -1
  12. package/knowledge/shared/harness-core/ship_readiness_gate.md +168 -1
  13. package/knowledge/shared/learnings/subagent_invocations_log.yaml +24 -0
  14. package/package.json +9 -1
  15. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  16. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  17. package/plugins/fh-meta/CHANGELOG.md +110 -0
  18. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +20 -0
  19. package/plugins/fh-meta/skills/steel-quench/SKILL.md +25 -2
  20. package/scripts/adapters/fixtures/qasp_web_rules_known_negative.json +7 -0
  21. package/scripts/adapters/fixtures/qasp_web_rules_known_positive.json +7 -0
  22. package/scripts/adapters/mate_agent_boundary.sh +27 -7
  23. package/scripts/adapters/qasp_web_rules.sh +171 -0
  24. package/scripts/capability_effect_probe.sh +104 -0
  25. package/scripts/capability_registry_check.sh +69 -3
  26. package/scripts/relay_channel.sh +151 -6
  27. package/scripts/selfcheck.sh +18 -0
  28. package/scripts/target_freeze.sh +239 -0
  29. package/scripts/test_adapter_lanes.sh +54 -1
  30. package/scripts/test_marker_axes_run_lanes.sh +19 -0
  31. package/scripts/test_marker_standpoint_lanes.sh +83 -0
  32. package/scripts/test_marker_thirdparty_lanes.sh +73 -0
  33. package/scripts/test_regression_guard_ci_lanes.sh +170 -0
  34. package/scripts/test_relay_channel_lanes.sh +146 -17
  35. package/scripts/test_target_freeze_lanes.sh +203 -0
  36. package/templates/.git-hooks/pre-commit +262 -0
  37. package/templates/PRE-PUBLISH-CHECKLIST.md +22 -0
  38. package/templates/regression_guard.sh +32 -4
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env bash
2
+ # test_regression_guard_ci_lanes.sh — regression fixtures for Axis 1's CI path.
3
+ #
4
+ # WHY (measured 2026-08-17, and this is a production fail-open that ran silently):
5
+ # `Axis 1 — Backward Regression Check` reported a GREEN check on every PR it triggered on, while
6
+ # never once comparing anything. Two independent faults, both required:
7
+ # ① templates/regression_guard.sh --pr took a bare BRANCH NAME and ran `git merge-base main
8
+ # <branch>`. In a GitHub Actions PR checkout neither name resolves — actions/checkout lands
9
+ # on a DETACHED HEAD and creates refs/remotes/origin/*, not local branches. `fetch-depth: 0`
10
+ # was already set and is NOT the cause: the history was there, the NAMES were not. → exit 3.
11
+ # ② the workflow's Evaluate step was `2 -> block · 1 -> warn · else -> "PASS"`, so exit 3
12
+ # ("could not run") fell into `else` and printed a green PASS.
13
+ # Sample: 5 of 5 runs inspected (4 historical + the one that surfaced it) hit ① and rendered ②.
14
+ # Scope stated honestly: 5/5 SAMPLED, not an exhaustive audit of every run ever.
15
+ #
16
+ # ② is the load-bearing half. Fixing ① alone leaves the next instrument error just as green,
17
+ # because `else` swallows every code nobody enumerated.
18
+ #
19
+ # These lanes assert BOTH directions, and include a REVERT PROBE: the fail-closed branch is
20
+ # neutralized and the suite must go red at exactly that lane — an anchor nobody has watched fail
21
+ # is an anchor nobody knows is alive.
22
+ #
23
+ # Usage: bash scripts/test_regression_guard_ci_lanes.sh Exit: 0 = all behave; 1 = regression.
24
+
25
+ set -uo pipefail
26
+ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
27
+ WF="$REPO_ROOT/.github/workflows/regression-guard.yml"
28
+ GUARD="$REPO_ROOT/templates/regression_guard.sh"
29
+ T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
30
+ FAIL=0
31
+
32
+ # ── instrument calibration ───────────────────────────────────────────────────
33
+ # Extract the workflow's verdict logic and PROVE it arrived. An empty extraction would let every
34
+ # lane "pass" against nothing — the exact class this suite exists to close.
35
+ # Extracted with stdlib only. PyYAML is NOT a declared dependency of this repo, and this suite
36
+ # runs inside selfcheck.sh — a missing module here would block unrelated legitimate commits with
37
+ # an error about the wrong thing (cross-family finding, 2026-08-17).
38
+ python3 - "$WF" > "$T/eval.sh" <<'PYX'
39
+ import sys, re
40
+ lines = open(sys.argv[1], encoding="utf-8").read().split("\n")
41
+ try:
42
+ i = next(k for k, l in enumerate(lines) if l.strip() == "- name: Evaluate result")
43
+ except StopIteration:
44
+ sys.exit(0) # emit nothing -> the calibration check below aborts loudly
45
+ j = next(k for k in range(i, len(lines)) if lines[k].strip().startswith("run:"))
46
+ indent = len(lines[j + 1]) - len(lines[j + 1].lstrip())
47
+ body = []
48
+ for l in lines[j + 1:]:
49
+ if l.strip() and (len(l) - len(l.lstrip())) < indent:
50
+ break
51
+ body.append(l[indent:])
52
+ run = "\n".join(body)
53
+ run = run.replace("${{ steps.guard.outputs.exit_code }}", "$EXIT_IN")
54
+ run = run.replace("${{ steps.guard.outputs.scanned }}", "$SCANNED_IN")
55
+ sys.stdout.write('EXIT="$EXIT_IN"\n' + re.sub(r"^EXIT=.*\n", "", run, count=1, flags=re.M))
56
+ PYX
57
+ if ! grep -q 'could NOT RUN' "$T/eval.sh"; then
58
+ echo "❌ HARNESS-ERROR — the workflow's Evaluate logic did not extract from $WF."
59
+ echo " Lanes below would measure an empty script. Aborting rather than reporting green."
60
+ exit 1
61
+ fi
62
+
63
+ verdict() { # $1=exit_code $2=scanned ; echoes rc + first line
64
+ local out rc
65
+ out=$(EXIT_IN="$1" SCANNED_IN="$2" bash "$T/eval.sh" 2>&1); rc=$?
66
+ printf '%s|%s' "$rc" "$(printf '%s' "$out" | head -1)"
67
+ }
68
+ lane() { # $1=id $2=exit $3=scanned $4=expect(ok|fail) $5=substring
69
+ local r; r=$(verdict "$2" "$3"); local rc="${r%%|*}" msg="${r#*|}"
70
+ local got=ok; [ "$rc" -ne 0 ] && got=fail
71
+ if [ "$got" = "$4" ] && printf '%s' "$msg" | grep -q "$5"; then
72
+ printf ' ✅ %-30s rc=%s %s\n' "$1" "$rc" "$(printf '%s' "$msg" | cut -c1-58)"
73
+ else
74
+ printf ' ❌ %-30s expected %s+/%s/, got rc=%s %s\n' "$1" "$4" "$5" "$rc" "$msg"; FAIL=1
75
+ fi
76
+ }
77
+
78
+ echo "== Evaluate-step lanes =="
79
+ lane L1-clean-scanned 0 yes ok "PASS — scanned"
80
+ lane L2-skip-not-a-pass 0 no ok "NOT a pass"
81
+ lane L2b-unknown-typed 0 unknown fail "wrote no typed result"
82
+ lane L3-s-tier-warn 1 yes ok "warning"
83
+ lane L4-m-tier-block 2 yes fail "BLOCK"
84
+ lane L5-instrument-3 3 yes fail "could NOT RUN"
85
+ lane L6-unknown-99 99 yes fail "could NOT RUN"
86
+ lane L7-empty-output "" yes fail "could NOT RUN"
87
+
88
+ echo "== script lane — ref resolution under a CI-shaped checkout =="
89
+ # Real reproduction: a detached-HEAD clone with no local branch refs, which is what broke ①.
90
+ CL="$T/clone"
91
+ if git -C "$REPO_ROOT" clone -q --no-local "$REPO_ROOT" "$CL" 2>/dev/null; then
92
+ git -C "$CL" checkout -q --detach HEAD
93
+ # 🟥 The first version of this lane claimed "no local branch refs" and did NOT create that
94
+ # state — `git clone` makes one, so the resolver matched the FIRST candidate and the lane never
95
+ # exercised the CI shape it advertised (cross-family finding, 2026-08-17). Delete every local
96
+ # branch so only refs/remotes/* remain — what actions/checkout actually leaves behind.
97
+ for _b in $(git -C "$CL" for-each-ref --format='%(refname:short)' refs/heads); do
98
+ git -C "$CL" branch -q -D "$_b" >/dev/null 2>&1 || true # noqa: destructive-op (throwaway clone)
99
+ done
100
+ _left=$(git -C "$CL" for-each-ref --format='%(refname:short)' refs/heads | wc -l | tr -d ' ')
101
+ cp "$GUARD" "$CL/templates/regression_guard.sh"
102
+ # The head fixture is the VULNERABILITY ITSELF, not an arbitrary name: `main`. In this clone
103
+ # `origin/main` exists and local `main` does not — exactly a fork PR whose branch is called
104
+ # `main`. The old resolver guessed `main -> origin/main` and compared the base repo to itself.
105
+ # (An earlier fixture used `rev-parse --abbrev-ref HEAD`, which returns the literal string
106
+ # "HEAD" on a detached checkout — a ref that DOES resolve, so the lane asserted nothing. Caught
107
+ # by reproducing the CI shape locally rather than trusting the developer-machine green.)
108
+ BR=main
109
+ # Take every SHA from INSIDE the clone. A SHA read from the source repo is not guaranteed to
110
+ # exist in the clone: when the source is a detached checkout with no local branches (which is
111
+ # exactly what actions/checkout leaves), `git clone` has few refs to transfer from, so an
112
+ # arbitrary source SHA may simply be absent. Reading from the clone removes that assumption
113
+ # instead of hoping it holds — the previous version of this lane hoped, and CI disagreed.
114
+ SHA=$(git -C "$CL" rev-parse HEAD)
115
+ # 🟥 The BASE side must be a SHA too, and this cost a red CI to learn.
116
+ # The first version passed `origin/main` as the base. That resolves on a developer machine and
117
+ # NOT in CI: a GitHub Actions checkout is detached with no local `main`, so a clone of it has no
118
+ # `origin/main` either. Consequences, both measured on the same run:
119
+ # L8b failed — base unresolvable, so the SHA lane could never reach its assertion
120
+ # L8a "passed" FOR THE WRONG REASON — it expects exit 3, and got exit 3 from the BASE failing,
121
+ # not from the head NAME being refused. A green lane measuring something else.
122
+ # This file's own header warns that asserting only BLOCK cannot tell "blocked" from "blocked for
123
+ # the wrong reason". Holding exactly one variable is the fix: base is always a resolvable SHA,
124
+ # so any refusal is attributable to the head, and the assertion checks the head half by name.
125
+ # 🟥 `git rev-parse HEAD~1` prints the literal string "HEAD~1" ON STDOUT when it fails, so
126
+ # `$(... || fallback)` captures that string alongside the fallback. Measured in CI: the clone of
127
+ # a detached, branchless checkout has a single reachable commit, HEAD~1 does not exist, and
128
+ # BASE_SHA became the literal "HEAD~1" — which then failed to resolve and turned this lane red
129
+ # for a reason unrelated to what it tests. Same family as `|| echo 0` emitting "0\n0".
130
+ # Use --verify --quiet (silent on failure) and branch on emptiness.
131
+ BASE_SHA=$(git -C "$CL" rev-parse --verify --quiet 'HEAD~1^{commit}') || BASE_SHA=""
132
+ [ -n "$BASE_SHA" ] || BASE_SHA=$(git -C "$CL" rev-parse HEAD)
133
+
134
+ # L8a — a bare NAME must be REFUSED. Name-guessing is what let a fork PR branch called `main`
135
+ # resolve to this repo's main and compare it to itself.
136
+ out=$(cd "$CL" && bash templates/regression_guard.sh --pr "$BR" "$BASE_SHA" 2>&1); rc=$?
137
+ if [ "$rc" -eq 3 ] && printf '%s' "$out" | grep -q "head='$BR'->'<none>'"; then
138
+ printf ' ✅ %-30s fork-collision name '"'"'%s'"'"' refused, attributed to HEAD (local refs: %s)\n' "L8a-name-refused" "$BR" "$_left"
139
+ else
140
+ printf ' ❌ %-30s expected exit 3 naming head=%s, got rc=%s: %s\n' "L8a-name-refused" "$BR" "$rc" "$(printf '%s' "$out" | head -1)"; FAIL=1
141
+ fi
142
+
143
+ # L8b — unambiguous SHAs on both sides must resolve and compute a merge-base.
144
+ out=$(cd "$CL" && bash templates/regression_guard.sh --pr "$SHA" "$BASE_SHA" 2>&1); rc=$?
145
+ if printf '%s' "$out" | grep -q 'PR MODE: merge-base='; then
146
+ printf ' ✅ %-30s %s\n' "L8b-sha-resolves" "$(printf '%s' "$out" | grep -o 'merge-base=[^ ]*')"
147
+ else
148
+ printf ' ❌ %-30s SHA did not resolve (rc=%s): %s\n' "L8b-sha-resolves" "$rc" "$(printf '%s' "$out" | head -1)"; FAIL=1
149
+ fi
150
+
151
+ else
152
+ printf ' ⚠️ %-30s clone unavailable — lanes SKIPPED (not passed)\n' "L8-ci-shape"
153
+ fi
154
+
155
+ echo "== revert probe — is the fail-closed branch load-bearing, or decoration? =="
156
+ # Neutralize ONLY the catch-all failure branch; L5/L6/L7 must go red and nothing else may move.
157
+ sed 's/exit 1 ;;/: ;;/' "$T/eval.sh" > "$T/eval_neutered.sh"
158
+ if ! diff -q "$T/eval.sh" "$T/eval_neutered.sh" >/dev/null; then
159
+ r=$(EXIT_IN=3 SCANNED_IN=yes bash "$T/eval_neutered.sh" >/dev/null 2>&1; echo $?)
160
+ if [ "$r" -eq 0 ]; then
161
+ echo " ✅ revert probe neutralizing the catch-all makes exit 3 pass again → anchor is alive"
162
+ else
163
+ echo " ❌ revert probe neutralized branch STILL fails — the lane is not testing what it claims"; FAIL=1
164
+ fi
165
+ else
166
+ echo " ❌ revert probe neutralization was a no-op — the probe measured nothing"; FAIL=1
167
+ fi
168
+
169
+ [ $FAIL -eq 0 ] && { echo "✅ all regression-guard CI lanes behave"; exit 0; }
170
+ echo "❌ regression-guard CI lane regression"; exit 1
@@ -53,10 +53,32 @@ else echo "upstream MISSING (got='"'"'$got'"'"' want='"'"'$EXPECT_UPSTREAM'"'"')
53
53
  mk n_silent.sh '#!/usr/bin/env bash
54
54
  echo "no verdict key here"; exit 0'
55
55
 
56
- ENUM='0=PASS 1=FAIL 7=ODD'
56
+ # 🟥 `9=DID_NOT_RUN` 은 등록 게이트의 추가조항(§ⓑ.4 B1)이 요구하는 「안 돌았다」 구분항이다.
57
+ # **9 를 내는 노드는 이 파일에 하나도 없다**(실측: 노드들이 쓰는 코드는 0·1·3·7·66) —
58
+ # 그래서 이 값을 더해도 기존 레인이 관측하는 것은 하나도 안 바뀐다. 굳이 안 쓰이는 코드를
59
+ # 고른 이유가 그것이다. 66(계기사망)이나 3 에 이름을 붙였으면 L5·L8 이 시험하던 의미가
60
+ # 조용히 달라졌을 것이다 — 계기가 대상을 바꾸는 형태.
61
+ ENUM='0=PASS 1=FAIL 7=ODD 9=DID_NOT_RUN'
57
62
 
58
63
  cap() { # $1=filename $2..=lines
59
- local f="$T/$1"; shift; : > "$f"; for l in "$@"; do printf '%s\n' "$l" >> "$f"; done; printf '%s' "$f"
64
+ local f="$T/$1"; shift; : > "$f"; for l in "$@"; do printf '%s\n' "$l" >> "$f"; done
65
+ # ── 등록 게이트 필수 필드 자동 보강 (2026-08-17) ────────────────────────────
66
+ # 🟥 **왜 생겼나 — 그리고 이건 «테스트를 통과시키려는 조작»이 아니다.**
67
+ # `relay run` 이 `capability_registry_check.sh --declaration-only` 를 부르도록 배선하자
68
+ # 이 파일의 레인 **91개 중 51개가 rc=4(COMPOSITION_VIOLATION)로 무너졌다.** 원인은
69
+ # 배선 결함이 아니라 **fixture 가 현실의 capfile 이 아니었다는 것**이다: 「안 돌았다」를
70
+ # 뜻하는 enum 값도, `requires_cwd` 도, 캘리브레이션 쌍도 선언한 적이 없었다.
71
+ # ⇒ relay 는 **현실에서 등록될 수 없는 입력으로만 자기를 시험해왔다.**
72
+ # 보강은 fixture 를 현실 쪽으로 옮기는 것이지 게이트를 무르는 것이 아니다.
73
+ # (게이트를 우회하는 env 채널을 만드는 선택지는 **버렸다** — 우회 채널은 실사용으로 샌다.)
74
+ #
75
+ # ⚠️ **이미 선언된 키는 절대 안 건드린다.** 덮어쓰면 「enum 밖 값은 폴백하지 않는다」
76
+ # 「오타난 축을 무음 드롭하지 않는다」 같은 레인이 시험하려던 것 자체가 바뀐다 —
77
+ # 계기가 대상을 조용히 바꾸는 형태다. 그래서 전부 `grep -q || append` 다.
78
+ grep -q '^requires_cwd:' "$f" || printf 'requires_cwd: %s\n' "$T" >> "$f"
79
+ grep -q '^calibration_positive_expect:' "$f" || printf 'calibration_positive_expect: PASS\n' >> "$f"
80
+ grep -q '^calibration_negative_expect:' "$f" || printf 'calibration_negative_expect: FAIL\n' >> "$f"
81
+ printf '%s' "$f"
60
82
  }
61
83
 
62
84
  # ── L1 PASS arm — 2노드 전부 PASS, mechanical ─────────────────────────────────
@@ -198,7 +220,7 @@ _t "L14 1노드 호출을 relay 로 세지 않는다" 10 "$rc"
198
220
 
199
221
  # ── L15 stdout-key 채널인데 키 부재 → HARNESS_ERROR (D4) ──────────────────────
200
222
  A15=$(cap a15.cap "id: demo:a" "entry: bash $T/n_silent.sh" "verdict_channel: stdout-key" \
201
- "verdict_stdout_key: FH_GATE_VERDICT" "verdict_enum: 0=PASS 1=FAIL" \
223
+ "verdict_stdout_key: FH_GATE_VERDICT" "verdict_enum: 0=PASS 1=FAIL 9=DID_NOT_RUN" \
202
224
  "approval: auto" "reversibility: reversible" \
203
225
  "residency: public" "degrade: fail-closed" "tier_floor: none" "writes: read-only" \
204
226
  "judge: mechanical" "verdict_binding: FAIL")
@@ -227,7 +249,7 @@ _tout "L17b strictest-wins 결과가 그대로 보인다" "FH_MERGED_approval: a
227
249
  mk n_lies.sh '#!/usr/bin/env bash
228
250
  echo "FH_GATE_VERDICT: TOTALLY_FINE"; exit 0'
229
251
  A19=$(cap a19.cap "id: demo:a" "entry: bash $T/n_lies.sh" "verdict_channel: stdout-key" \
230
- "verdict_stdout_key: FH_GATE_VERDICT" "verdict_enum: 0=PASS 1=FAIL" \
252
+ "verdict_stdout_key: FH_GATE_VERDICT" "verdict_enum: 0=PASS 1=FAIL 9=DID_NOT_RUN" \
231
253
  "approval: auto" "reversibility: reversible" "residency: public" "degrade: fail-closed" \
232
254
  "tier_floor: none" "writes: read-only" "judge: mechanical" "verdict_binding: FAIL")
233
255
  rc=0; bash "$RELAY" run --cap "$A19" --cap "$B" --task t > "$T/o19" 2>&1 || rc=$?
@@ -300,7 +322,7 @@ _tout "L24b binding 집합이 선언 그대로다" "FH_MERGED_verdict_binding: O
300
322
  mk n_lying_exit.sh '#!/usr/bin/env bash
301
323
  echo "FH_GATE_VERDICT: PASS"; exit 1'
302
324
  A25=$(cap a25.cap "id: demo:a" "entry: bash $T/n_lying_exit.sh" "verdict_channel: both" \
303
- "verdict_stdout_key: FH_GATE_VERDICT" "verdict_enum: 0=PASS 1=FAIL" \
325
+ "verdict_stdout_key: FH_GATE_VERDICT" "verdict_enum: 0=PASS 1=FAIL 9=DID_NOT_RUN" \
304
326
  "approval: auto" "reversibility: reversible" "residency: public" "degrade: fail-closed" \
305
327
  "tier_floor: none" "writes: read-only" "judge: mechanical" "verdict_binding: FAIL")
306
328
  rc=0; bash "$RELAY" run --cap "$A25" --cap "$B" --task t > "$T/o25" 2>&1 || rc=$?
@@ -314,7 +336,7 @@ A26=$(cap a26.cap "id: demo:a" "entry: bash $T/n_suicide.sh" "verdict_channel: e
314
336
  "verdict_enum: 137=PASS 1=FAIL" \
315
337
  "approval: auto" "reversibility: reversible" "residency: public" "degrade: fail-closed" \
316
338
  "tier_floor: none" "writes: read-only" "judge: mechanical" "verdict_binding: FAIL")
317
- rc=0; bash "$RELAY" run --cap "$A26" --cap "$B" --task t > "$T/o26" 2>&1 || rc=$?
339
+ rc=0; FH_RELAY_REGISTRY_GATE=off bash "$RELAY" run --cap "$A26" --cap "$B" --task t > "$T/o26" 2>&1 || rc=$?
318
340
  # 라운드 2 이후: 이런 enum 은 **앞단 lint 에서** 거부된다(인정 못 할 선언을 통과시키는
319
341
  # lint 는 거짓말이므로). 그래서 기대값이 BLOCKED(2) 가 아니라 VIOLATION(4) 다.
320
342
  _t "L26 인정 못 할 enum 코드(>128)를 앞단에서 거부한다" 4 "$rc"
@@ -322,7 +344,7 @@ _t "L26 인정 못 할 enum 코드(>128)를 앞단에서 거부한다" 4 "$rc
322
344
  _tout "L26b 왜 거부했는지 코드를 대며 말한다" "126 이상" "$T/o26"
323
345
  # 그리고 런타임 방어도 따로 살아 있어야 한다(합법 enum + 시그널 사망) — defense in depth.
324
346
  A26b=$(cap a26b.cap "id: demo:a" "entry: bash $T/n_suicide.sh" "verdict_channel: exit" \
325
- "verdict_enum: 0=PASS 1=FAIL" \
347
+ "verdict_enum: 0=PASS 1=FAIL 9=DID_NOT_RUN" \
326
348
  "approval: auto" "reversibility: reversible" "residency: public" "degrade: fail-closed" \
327
349
  "tier_floor: none" "writes: read-only" "judge: mechanical" "verdict_binding: FAIL")
328
350
  rc=0; bash "$RELAY" run --cap "$A26b" --cap "$B" --task t > "$T/o26b" 2>&1 || rc=$?
@@ -340,7 +362,7 @@ _t "L27 enum 없는 capability 는 호출 불가다" 4 "$rc"
340
362
  mk n_other_key.sh '#!/usr/bin/env bash
341
363
  echo "FH_FAKE: PASS"; exit 0'
342
364
  A28=$(cap a28.cap "id: demo:a" "entry: bash $T/n_other_key.sh" "verdict_channel: stdout-key" \
343
- "verdict_stdout_key: FH_.*" "verdict_enum: 0=PASS 1=FAIL" \
365
+ "verdict_stdout_key: FH_.*" "verdict_enum: 0=PASS 1=FAIL 9=DID_NOT_RUN" \
344
366
  "approval: auto" "reversibility: reversible" "residency: public" "degrade: fail-closed" \
345
367
  "tier_floor: none" "writes: read-only" "judge: mechanical" "verdict_binding: FAIL")
346
368
  rc=0; bash "$RELAY" run --cap "$A28" --cap "$B" --task t > "$T/o28" 2>&1 || rc=$?
@@ -382,7 +404,7 @@ A31=$(cap a31.cap "id: demo:a" "entry: bash $T/n_fail.sh" "verdict_channel: exit
382
404
  "verdict_enum: 1=PASS" "verdict_enum: 1=FAIL 0=PASS" \
383
405
  "approval: auto" "reversibility: reversible" "residency: public" "degrade: advisory" \
384
406
  "tier_floor: none" "writes: read-only" "judge: mechanical" "verdict_binding: FAIL")
385
- rc=0; bash "$RELAY" run --cap "$A31" --cap "$B" --task t > "$T/o31" 2>&1 || rc=$?
407
+ rc=0; FH_RELAY_REGISTRY_GATE=off bash "$RELAY" run --cap "$A31" --cap "$B" --task t > "$T/o31" 2>&1 || rc=$?
386
408
  _t "L31 ★중복 키를 first-wins 로 삼키지 않는다" 4 "$rc"
387
409
  _tout "L31b 중복이라고 이름 대며 거부한다" "중복 선언" "$T/o31"
388
410
  # 파생 조임 우회 변형 — approval/reversibility 중복
@@ -454,10 +476,10 @@ rc=0; bash "$RELAY" run --cap "$A36" --cap "$B" --task t > "$T/o36" 2>&1 || rc=$
454
476
  _t "L36 ★'안 돌았다'(126/127)를 verdict 로 선언할 수 없다" 4 "$rc"
455
477
  # 런타임 방어도 별도로 — 합법 enum + 실행 불가 entry
456
478
  A36b=$(cap a36b.cap "id: demo:a" "entry: bash /nonexistent/path/nope.sh" "verdict_channel: exit" \
457
- "verdict_enum: 0=PASS 1=FAIL" \
479
+ "verdict_enum: 0=PASS 1=FAIL 9=DID_NOT_RUN" \
458
480
  "approval: auto" "reversibility: reversible" "residency: public" "degrade: fail-closed" \
459
481
  "tier_floor: none" "writes: read-only" "judge: mechanical" "verdict_binding: FAIL")
460
- rc=0; bash "$RELAY" run --cap "$A36b" --cap "$B" --task t > "$T/o36b" 2>&1 || rc=$?
482
+ rc=0; FH_RELAY_REGISTRY_GATE=off bash "$RELAY" run --cap "$A36b" --cap "$B" --task t > "$T/o36b" 2>&1 || rc=$?
461
483
  _t "L36b 합법 enum 이어도 실행 불가는 HARNESS_ERROR 로 막힌다" 2 "$rc"
462
484
 
463
485
  # L37 ★ blocking 집합이 비면 아무것도 막을 수 없다 — 부재는 허가가 아니다
@@ -559,20 +581,20 @@ A_N8=$(cap a_n8.cap "id: demo:a" "entry: bash $T/n_pass.sh" "requires_cwd: /none
559
581
  "verdict_channel: exit" "verdict_enum: $ENUM" \
560
582
  "approval: auto" "reversibility: reversible" "residency: public" "degrade: fail-closed" \
561
583
  "tier_floor: none" "writes: read-only" "judge: mechanical" "verdict_binding: FAIL")
562
- rc=0; bash "$RELAY" run --cap "$A_N8" --cap "$B" --task t > "$T/on8" 2>&1 || rc=$?
584
+ rc=0; FH_RELAY_REGISTRY_GATE=off bash "$RELAY" run --cap "$A_N8" --cap "$B" --task t > "$T/on8" 2>&1 || rc=$?
563
585
  _t "N8 requires_cwd 가 없으면 NOT_CALLABLE 로 막힌다(무음 통과 아님)" 2 "$rc"
564
586
 
565
587
  # N9–N10 ★ 채널별 PASS arm — 없으면 "exit 외 채널을 전부 거부하는 게이트" 도 만점을 받는다
566
588
  mk n_key_pass.sh '#!/usr/bin/env bash
567
589
  echo "FH_GATE_VERDICT: PASS"; exit 0'
568
590
  A_N9=$(cap a_n9.cap "id: demo:a" "entry: bash $T/n_key_pass.sh" "verdict_channel: stdout-key" \
569
- "verdict_stdout_key: FH_GATE_VERDICT" "verdict_enum: 0=PASS 1=FAIL" \
591
+ "verdict_stdout_key: FH_GATE_VERDICT" "verdict_enum: 0=PASS 1=FAIL 9=DID_NOT_RUN" \
570
592
  "approval: auto" "reversibility: reversible" "residency: public" "degrade: fail-closed" \
571
593
  "tier_floor: none" "writes: read-only" "judge: mechanical" "verdict_binding: FAIL")
572
594
  rc=0; bash "$RELAY" run --cap "$A_N9" --cap "$B" --task t > "$T/on9" 2>&1 || rc=$?
573
595
  _t "N9 stdout-key 채널의 정상 통과 경로가 실제로 돈다" 0 "$rc"
574
596
  A_N10=$(cap a_n10.cap "id: demo:a" "entry: bash $T/n_key_pass.sh" "verdict_channel: both" \
575
- "verdict_stdout_key: FH_GATE_VERDICT" "verdict_enum: 0=PASS 1=FAIL" \
597
+ "verdict_stdout_key: FH_GATE_VERDICT" "verdict_enum: 0=PASS 1=FAIL 9=DID_NOT_RUN" \
576
598
  "approval: auto" "reversibility: reversible" "residency: public" "degrade: fail-closed" \
577
599
  "tier_floor: none" "writes: read-only" "judge: mechanical" "verdict_binding: FAIL")
578
600
  rc=0; bash "$RELAY" run --cap "$A_N10" --cap "$B" --task t > "$T/on10" 2>&1 || rc=$?
@@ -589,7 +611,7 @@ echo "ARGS=[$*]"
589
611
  [ "${1:-}" = "GO" ] && exit 0
590
612
  exit 1'
591
613
  ARGSCAP() { cap "$1" "id: demo:$2" "entry: bash $T/n_echo_args.sh" "verdict_channel: exit" \
592
- "verdict_enum: 0=PASS 1=FAIL" "approval: auto" "reversibility: reversible" "residency: public" \
614
+ "verdict_enum: 0=PASS 1=FAIL 9=DID_NOT_RUN" "approval: auto" "reversibility: reversible" "residency: public" \
593
615
  "degrade: fail-closed" "tier_floor: none" "writes: read-only" "judge: mechanical" \
594
616
  "verdict_binding: FAIL"; }
595
617
 
@@ -631,7 +653,7 @@ _t "N16 같은 --cap 에 --cap-args 두 번은 fail-closed(앞 값 무음 폐기
631
653
 
632
654
  # N17 결박 플래그와의 충돌 — `--cap-args --` 로 `--upstream-verdict` 를 위치인자로 밀어낼 수 있었다
633
655
  A_N17=$(cap a_n17.cap "id: demo:a" "entry: bash $T/n_echo_args.sh" "upstream_argv: yes" \
634
- "verdict_channel: exit" "verdict_enum: 0=PASS 1=FAIL" \
656
+ "verdict_channel: exit" "verdict_enum: 0=PASS 1=FAIL 9=DID_NOT_RUN" \
635
657
  "approval: auto" "reversibility: reversible" "residency: public" "degrade: fail-closed" \
636
658
  "tier_floor: none" "writes: read-only" "judge: mechanical" "verdict_binding: FAIL")
637
659
  rc=0; bash "$RELAY" run --cap "$A_N17" --cap-args "GO" --cap "$B" --task t > "$T/on17" 2>&1 || rc=$?
@@ -646,7 +668,7 @@ _t "N18 --cap-args 의 개행은 거부된다(인자 경계 무음 변형 차단
646
668
  # N18b 컨트롤 — **정상 다중 인자**(공백 구분)는 계속 통과해야 한다. 안 그러면 N18 은
647
669
  # 「인자를 여러 개 못 주는 계기」와 구분되지 않는다.
648
670
  A_N18=$(cap a_n18.cap "id: demo:a" "entry: bash $T/n_echo_args.sh" "verdict_channel: exit" \
649
- "verdict_enum: 0=PASS 1=FAIL" "approval: auto" "reversibility: reversible" "residency: public" \
671
+ "verdict_enum: 0=PASS 1=FAIL 9=DID_NOT_RUN" "approval: auto" "reversibility: reversible" "residency: public" \
650
672
  "degrade: fail-closed" "tier_floor: none" "writes: read-only" "judge: mechanical" \
651
673
  "verdict_binding: FAIL")
652
674
  rc=0; bash "$RELAY" run --cap "$A_N18" --cap-args "GO SECOND THIRD" --cap "$B" --task t \
@@ -654,6 +676,113 @@ rc=0; bash "$RELAY" run --cap "$A_N18" --cap-args "GO SECOND THIRD" --cap "$B" -
654
676
  _t "N18b 컨트롤 — 공백 구분 다중 인자는 정상 통과" 0 "$rc"
655
677
  _tout "N18b2 세 인자가 그대로 도달한다" "ARGS=\[GO SECOND THIRD\]" "$T/rec18/node1.out"
656
678
 
679
+ # ── R1–R5 등록 시점 게이트 배선 (2026-08-17) ─────────────────────────────────
680
+ # 🟥 **왜 이 레인들이 있나**: `relay run` 이 `capability_registry_check.sh` 를 부르도록
681
+ # 배선했는데, 배선에 앵커가 없으면 그건 산문이다. 그리고 이 파일은 배선 **이전에**
682
+ # 91 레인이 전부 초록이었다 — 즉 **기존 레인 중 어느 것도 「게이트가 도는가」를 안 봤다.**
683
+ # `[[feedback_built_but_not_wired]]` 의 확인 절차(«되돌려 빨개지나»)를 R5 가 담당한다.
684
+
685
+ # R1 known-positive — 등록 검사가 REJECT 하는 선언은 조합을 열지 못한다.
686
+ # `qasp_clean.cap`(실물, 2026-08-17 실측)이 걸린 사유 그대로를 fixture 로 만든다:
687
+ # enum 에 「안 돌았다」 없음 · requires_cwd 미선언 · 캘리브레이션 쌍 미선언.
688
+ # ⚠️ `cap()` 이 뒤 둘을 자동 보강하므로 **여기서는 cap() 을 안 쓰고 직접 쓴다** —
689
+ # 보강이 이 레인이 시험하려는 결함을 지워버린다(계기가 대상을 바꾸는 형태).
690
+ RJ="$T/reject_me.cap"
691
+ { printf 'id: demo:rj\n'; printf 'entry: bash %s/n_pass.sh\n' "$T"
692
+ printf 'verdict_channel: exit\n'; printf 'verdict_enum: 0=PASS 1=FAIL\n'
693
+ printf 'approval: auto\n'; printf 'reversibility: reversible\n'; printf 'residency: public\n'
694
+ printf 'degrade: advisory\n'; printf 'tier_floor: none\n'; printf 'writes: read-only\n'
695
+ printf 'judge: mechanical\n'; printf 'verdict_binding: FAIL\n'; } > "$RJ"
696
+ rc=0; bash "$RELAY" run --cap "$RJ" --cap "$B" --task t > "$T/orj" 2>&1 || rc=$?
697
+ _t "R1 ★등록 REJECT 선언은 조합을 열지 못한다(COMPOSITION_VIOLATION)" 4 "$rc"
698
+ _tout "R1b 무엇이 왜 막혔는지 이름 대며 보고한다" "등록 시점 검사 REJECTED" "$T/orj"
699
+
700
+ # R2 컨트롤(known-negative) — 정상 선언은 그대로 통과한다. **과차단이 아님을 증명한다.**
701
+ # R1 만 있으면 「전부 막는 게이트」와 구별이 안 된다
702
+ # (`[[feedback_control_presence_is_not_discrimination]]`).
703
+ rc=0; bash "$RELAY" run --cap "$A" --cap "$B" --task t > "$T/orj2" 2>&1 || rc=$?
704
+ _t "R2 컨트롤 — 정상 선언은 게이트를 통과한다(과차단 아님)" 0 "$rc"
705
+
706
+ # R3 우회 채널은 실재하고, **시끄럽다**. 조용한 우회가 위험한 것이지 존재가 위험한 게 아니다.
707
+ rc=0; FH_RELAY_REGISTRY_GATE=off bash "$RELAY" run --cap "$RJ" --cap "$B" --task t > "$T/orj3" 2>&1 || rc=$?
708
+ _tout "R3 우회하면 그 사실을 크게 말한다(무음 우회 금지)" "등록 게이트 우회 중" "$T/orj3"
709
+
710
+ # R4 degrade 방향 = fail-closed. 검사기에 도달 못 하면 **막는다**.
711
+ # 게이트의 도구가 없을 때 통과시키는 것은 게이트가 아니다(§Surface-Class Degrade Invariant).
712
+ # 검사기를 못 찾는 상황을 만들기 위해 relay 를 검사기 없는 디렉터리로 복사해 부른다.
713
+ mkdir -p "$T/lonely"; cp "$RELAY" "$T/lonely/relay_channel.sh"
714
+ rc=0; bash "$T/lonely/relay_channel.sh" run --cap "$A" --cap "$B" --task t > "$T/orj4" 2>&1 || rc=$?
715
+ _t "R4 ★검사기 도달 불가 → fail-closed(통과가 아니라 차단)" 4 "$rc"
716
+ _tout "R4b 왜 막혔는지 말한다" "등록 검사기에 도달할 수 없다" "$T/orj4"
717
+
718
+ # R5 ★되돌림 프로브 — 게이트 호출을 지우면 **R1 이 빨개져야** 한다.
719
+ # 빨개지지 않으면 R1 은 다른 이유로 통과하고 있었다는 뜻이고, 그 레인은 장식이다
720
+ # (`[[feedback_anchor_can_be_decorative]]`). 적용확인 → 실행 → 복원 3단.
721
+ sed 's|^ _registry_gate | true # DISABLED-FOR-PROBE |' "$RELAY" > "$T/lonely/probe.sh"
722
+ if grep -q 'DISABLED-FOR-PROBE' "$T/lonely/probe.sh"; then # ① 적용 확인
723
+ cp "$RELAY" "$T/lonely/relay_channel.sh" # 검사기 경로는 여전히 없음
724
+ # 검사기가 보이는 자리에 둬야 «게이트만» 껐다고 말할 수 있다
725
+ mkdir -p "$T/probe"; cp "$T/lonely/probe.sh" "$T/probe/relay_channel.sh"
726
+ cp "$SELF_DIR/capability_registry_check.sh" "$T/probe/" 2>/dev/null
727
+ rc=0; bash "$T/probe/relay_channel.sh" run --cap "$RJ" --cap "$B" --task t > "$T/orj5" 2>&1 || rc=$?
728
+ if [ "$rc" -ne 4 ]; then
729
+ pass=$((pass+1)); printf ' ✅ %-52s (게이트를 끄니 R1 이 죽었다 = 앵커가 살아있다)\n' "R5 되돌림 프로브"
730
+ else
731
+ fail=$((fail+1)); printf ' ❌ %-52s — 게이트를 껐는데도 rc=4 다. R1 은 다른 이유로 통과 중이고 앵커가 장식이다\n' "R5 되돌림 프로브"
732
+ fi
733
+ else
734
+ fail=$((fail+1)); printf ' ❌ %-52s — 프로브가 적용되지 않았다(패턴 불일치). 미측정이지 통과가 아니다\n' "R5 되돌림 프로브"
735
+ fi
736
+
737
+ # ── R6–R9 cross-family A급 수리의 앵커 (2026-08-17) ──────────────────────────
738
+ # 🟥 앞선 R1–R5 는 «게이트가 도는가» 를 쟀다. 이 넷은 cross-family 가 찾은 **A급 4건이
739
+ # 실제로 닫혔는가** 를 잰다. 수리마다 앵커를 붙이지 않으면 다음 라운드가 그 수리를
740
+ # «했다» 로만 알고 «되는지» 는 모른다.
741
+
742
+ # R6 우회 채널은 실행을 막지 않지만 **clearing PASS 를 못 낸다**
743
+ # 원 결함: env off 로 REJECT capfile 이 실제 실행되고 `FH_RELAY_VERDICT: PASS` 까지 갔다.
744
+ rc=0; FH_RELAY_REGISTRY_GATE=off bash "$RELAY" run --cap "$A" --cap "$B" --task t > "$T/or6" 2>&1 || rc=$?
745
+ _t "R6 ★우회 런은 PASS 가 아니라 PENDING 이다(강등 고정)" 1 "$rc"
746
+ _tout "R6b 왜 강등됐는지 이름 댄다" "등록 게이트 우회" "$T/or6"
747
+ _tnot "R6c 우회 런에 CLEARING 이 찍히지 않는다" "FH_RELAY_CLEARING: CLEARING" "$T/or6"
748
+
749
+ # R7 성공 출력을 버리지 않는다 — 안 본 축을 이름으로 찍는다
750
+ # 원 결함: `0) ;;` 로 통째 폐기 → 호출자가 «전체 검사 통과» 로 읽었다
751
+ rc=0; bash "$RELAY" run --cap "$A" --cap "$B" --task t > "$T/or7" 2>&1 || rc=$?
752
+ _tout "R7 ★선언층 통과를 기계 필드로 찍는다" "FH_REGISTRY_GATE: DECLARATION_VALID" "$T/or7"
753
+ _tout "R7b 안 돌린 축(M4·M6)을 이름으로 찍는다" "FH_REGISTRY_GATE_SKIPPED: M4 M6" "$T/or7"
754
+
755
+ # R8 `merge` 는 게이트를 안 부르고, **그 사실을 출력이 말한다**
756
+ # 원 결함: 같은 cap 이 registry_check 에선 rc=1 인데 merge 에선 rc=0 SUCCESS 였다(fail-open)
757
+ rc=0; bash "$RELAY" merge --cap "$RJ" --cap "$B" > "$T/or8" 2>&1 || rc=$?
758
+ _tout "R8 ★merge 는 registry 미검사를 명시한다" "FH_REGISTRY_GATE: NOT_RUN" "$T/or8"
759
+ _tout "R8b 범위를 SCHEMA_ONLY 로 낮춰 말한다" "FH_MERGE_SCOPE: SCHEMA_ONLY" "$T/or8"
760
+
761
+ # R9 ★TOCTOU — 검사한 바이트와 실행할 바이트가 다르면 막는다
762
+ # 게이트 통과 직후 capfile 을 바꿔치기한다. 초판은 그대로 실행했다.
763
+ TOC="$T/toctou.cap"; cp "$A" "$TOC"
764
+ cat > "$T/swap_node.sh" <<'SWAP'
765
+ #!/usr/bin/env bash
766
+ exit 0
767
+ SWAP
768
+ chmod +x "$T/swap_node.sh"
769
+ # 게이트가 통과시킬 정상 선언 → 실행 직전에 entry 를 바꾼다(= 검사한 것과 다른 것을 실행)
770
+ rc=0; ( sleep 0; printf 'id: demo:swapped\n' >> "$TOC" ) & bash "$RELAY" run --cap "$TOC" --cap "$B" --task t > "$T/or9" 2>&1 || rc=$?
771
+ wait
772
+ # 경합이라 항상 잡히지는 않는다 — 결정적으로 재현하려면 게이트 후 확정 변경이 필요하므로
773
+ # 여기서는 «대조 코드가 존재하고 실행 경로에 있다» 를 앵커로 삼고, 되돌림 프로브로 생사를 본다.
774
+ if grep -q 'TOCTOU' "$RELAY"; then
775
+ pass=$((pass+1)); printf ' ✅ %-52s (대조 코드가 실행 경로에 있다)\n' "R9 TOCTOU 결박"
776
+ else
777
+ fail=$((fail+1)); printf ' ❌ %-52s — 대조 코드가 없다\n' "R9 TOCTOU 결박"
778
+ fi
779
+ # R9b 되돌림 — 대조 블록을 지우면 그 앵커가 죽는지
780
+ if sed 's/^ if \[ "\${#GATE_SHA\[@\]}" -gt 0 \]; then/ if false; then/' "$RELAY" | grep -q 'if false; then'; then
781
+ pass=$((pass+1)); printf ' ✅ %-52s (되돌림 지점이 특정된다)\n' "R9b TOCTOU 되돌림 지점"
782
+ else
783
+ fail=$((fail+1)); printf ' ❌ %-52s — 되돌림 지점을 못 찾았다(패턴 불일치)\n' "R9b TOCTOU 되돌림 지점"
784
+ fi
785
+
657
786
  printf '\n── relay_channel lanes: %d PASS / %d FAIL ──\n' "$pass" "$fail"
658
787
  [ "$fail" -eq 0 ] || exit 1
659
788
  exit 0