Skip to content

Commit feaf2d3

Browse files
hyperpolymathclaude
andcommitted
feat(hcg): add surface-drift check script (standards#100 §1.5)
The HCG live policy header records a manual re-verification stamp ("Re-verified 2026-05-28 against `BojRest.Router`") confirming that every wired BoJ HTTP route is covered by a Verb Governance Spec rule. The ADR's largest declared risk is "policy lagging the surface" — a new wired route landing without a matching policy rule would default- deny in production (an outage on a route that should be live), silently exposing the §1.5 manual-check failure-mode this stamp is meant to gate. This script makes the same check machine-checkable: - Extract `(verb, path-template)` tuples from `elixir/lib/boj_rest/router.ex` (Plug.Router `get "/..."` / `post "/..."` lines). - Extract `(verb, path-pattern, ...)` tuples from `config/gateway-policy-boj.yaml` (the live policy promoted in Phase E §1.5). - For each wired route, concretise its `:name`-style placeholders with a known probe segment and assert at least one policy rule matches (literal equality for non-regex paths; ERE `grep -E` against the concrete URL for `^...` regex paths). - Exit 0 on no drift, 1 on drift, 64 on bad usage. Sits alongside `scripts/hcg-policy-smoke.sh`: the smoke script runs *against a live gateway* to confirm the policy enforces as declared (deny/allow paths, stealth-status canaries); this script runs *against the source files* to confirm the policy still covers the wired surface. Together they bracket the two halves of the §1.5 pre- rollout verification — surface→policy coverage (this) and policy→ gateway enforcement (smoke). Hardens the §1.5 pre-rollout posture without changing any policy content, runbook procedure, or gateway code. The runbook can adopt this script as the §1.5 surface-drift check in a follow-up PR; this PR lands the artefact only. Per single-lane HCG channel discipline (pattern set in http-capability-gateway PRs #14, #22, #26, #30, #38 and boj-server PRs #168, #173, #224, #226): joint-close is owner-only. This commit refs but does not close standards#100. Refs hyperpolymath/standards#91 Refs hyperpolymath/standards#100 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 711201d commit feaf2d3

1 file changed

Lines changed: 259 additions & 0 deletions

File tree

scripts/hcg-surface-drift-check.sh

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: MPL-2.0
3+
# Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
4+
#
5+
# hcg-surface-drift-check.sh — Asserts that every wired BoJ HTTP route
6+
# is covered by at least one rule in the HCG live Verb Governance Spec.
7+
#
8+
# The HCG live policy file (`config/gateway-policy-boj.yaml`) carries a
9+
# manual re-verification stamp ("Re-verified DATE against BojRest.Router
10+
# for HCG tier-2 rollout"). The ADR's largest declared risk is "policy
11+
# lagging the surface": a wired route landing without a matching policy
12+
# rule would default-deny in production (an outage on a route that
13+
# should be live), and the manual stamp is the only check today that
14+
# catches this.
15+
#
16+
# This script automates the same check so the stamp becomes machine-
17+
# checkable. Algorithm:
18+
#
19+
# 1. Extract (verb, path-template) tuples from
20+
# `elixir/lib/boj_rest/router.ex` — every `get "/..."`,
21+
# `post "/..."`, etc. at the top level of the router module.
22+
# 2. Extract (verb, path-pattern) tuples from
23+
# `config/gateway-policy-boj.yaml` — every `- path: "..."` /
24+
# `verbs: [...]` pair under `governance.routes`.
25+
# 3. For each wired route, concretise its `:name`-style placeholders
26+
# with a known probe segment (`probe`, matching the smoke
27+
# script) and assert at least one policy rule matches:
28+
# * literal policy path → exact equality with the concrete URL
29+
# * regex policy path (leading `^`) → `grep -E` match against
30+
# the concrete URL
31+
# The router method must be in the policy rule's verb list.
32+
# 4. Report any wired-but-ungoverned routes (drift) and exit 1; or
33+
# exit 0 if every wired route is covered.
34+
#
35+
# Bracket-style relationship with `scripts/hcg-policy-smoke.sh`:
36+
# * Smoke script runs against a *live gateway* to confirm the policy
37+
# enforces as declared (deny path / allow path / stealth status).
38+
# * Drift check runs against the *source files* to confirm the policy
39+
# still covers the wired surface.
40+
# Together they cover both halves of the §1.5 pre-rollout verification:
41+
# surface→policy coverage (this script) and policy→gateway enforcement
42+
# (the smoke script).
43+
#
44+
# Usage:
45+
# ./scripts/hcg-surface-drift-check.sh # uses repo defaults
46+
# ./scripts/hcg-surface-drift-check.sh -v # verbose; list matches
47+
#
48+
# Exit codes:
49+
# 0 — no drift; every wired route is covered.
50+
# 1 — drift detected; at least one wired route has no matching rule.
51+
# 64 — bad usage.
52+
#
53+
# Limitations (called out so the operator does not over-trust the
54+
# OK result):
55+
# * Parses the router with regex, not the Elixir AST. Routes declared
56+
# via macros, list comprehensions, or runtime registration are NOT
57+
# seen. The current router declares every route at the top level
58+
# with the Plug.Router DSL, which the regex handles correctly; a
59+
# future indirection would require this script to evolve.
60+
# * The "concretise `:name` with a fixed probe" step assumes the
61+
# policy regex character class accepts the probe segment. The
62+
# current policy uses `[A-Za-z0-9_.-]+`, which accepts `probe`;
63+
# a tightened class might not. Change the probe via `PROBE=` env
64+
# var if needed.
65+
# * Does NOT report orphan policy rules (rules with no matching
66+
# router route). This is intentional: the policy governs declared-
67+
# not-yet-wired routes per contract §8 (`http-capability-gateway-
68+
# boj-contract.md` §8). An orphan-rule check would have to also
69+
# consult `docs/specification/openapi.yaml` to suppress those
70+
# intentional cases; out of scope for this script.
71+
#
72+
# Cross-refs:
73+
# docs/integration/hcg-tier2-rollout-runbook.md §1.5
74+
# docs/integration/http-capability-gateway-boj-contract.md §8 ("Surface drift caveat")
75+
# docs/integration/http-capability-gateway-policy-authoring.md §5 ("Review & versioning discipline")
76+
# config/gateway-policy-boj.yaml source of truth
77+
# scripts/hcg-policy-smoke.sh companion enforcement check
78+
# standards#100 tracking issue
79+
80+
set -euo pipefail
81+
82+
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
83+
ROUTER_FILE="${ROUTER_FILE:-${REPO_ROOT}/elixir/lib/boj_rest/router.ex}"
84+
POLICY_FILE="${POLICY_FILE:-${REPO_ROOT}/config/gateway-policy-boj.yaml}"
85+
PROBE="${PROBE:-probe}"
86+
VERBOSE=0
87+
88+
usage() {
89+
cat >&2 <<'EOF'
90+
hcg-surface-drift-check.sh — assert wired-router-route ⊆ policy-rules.
91+
92+
USAGE:
93+
hcg-surface-drift-check.sh [-v] [-h]
94+
95+
OPTIONS:
96+
-v Verbose; print each wired route and the policy rule
97+
that matches it.
98+
-h Show this help.
99+
100+
ENV:
101+
ROUTER_FILE Override router path (default elixir/lib/boj_rest/router.ex).
102+
POLICY_FILE Override policy path (default config/gateway-policy-boj.yaml).
103+
PROBE Placeholder segment substituted for `:name`-style
104+
router parameters (default "probe").
105+
106+
EXIT CODES:
107+
0 no drift; every wired route covered by a policy rule.
108+
1 drift detected (at least one wired route has no match).
109+
64 bad usage.
110+
111+
Cross-refs:
112+
docs/integration/hcg-tier2-rollout-runbook.md §1.5
113+
scripts/hcg-policy-smoke.sh companion check
114+
EOF
115+
exit 64
116+
}
117+
118+
while [ $# -gt 0 ]; do
119+
case "$1" in
120+
-v) VERBOSE=1; shift ;;
121+
-h|--help) usage ;;
122+
*) echo "unknown arg: $1" >&2; usage ;;
123+
esac
124+
done
125+
126+
[ -f "$ROUTER_FILE" ] || { echo "router file not found: $ROUTER_FILE" >&2; exit 1; }
127+
[ -f "$POLICY_FILE" ] || { echo "policy file not found: $POLICY_FILE" >&2; exit 1; }
128+
129+
# 1. Wired routes from router.ex.
130+
#
131+
# Plug.Router DSL: top-level `get "/foo"` / `post "/foo"` etc. Output
132+
# format: VERB<TAB>/path/template, one route per line. The router's
133+
# `match _` catch-all is intentionally not captured.
134+
wired=$(
135+
grep -E '^[[:space:]]*(get|post|put|delete|patch|head|options)[[:space:]]+"[^"]+"' "$ROUTER_FILE" \
136+
| awk '{
137+
verb_lc = $1
138+
# POSIX-portable upper-case via tr in a subshell.
139+
cmd = "printf %s " verb_lc " | tr a-z A-Z"
140+
cmd | getline VERB
141+
close(cmd)
142+
# Extract the path inside the first pair of double quotes.
143+
line = $0
144+
sub(/^[^"]*"/, "", line)
145+
sub(/".*/, "", line)
146+
print VERB "\t" line
147+
}'
148+
)
149+
150+
# 2. Policy rules from gateway-policy-boj.yaml.
151+
#
152+
# Each rule is a `- path: "..."` line under `governance.routes:`,
153+
# followed (on a later indented line) by `verbs: [GET, POST, ...]`.
154+
# We expand the verb list to one row per (verb, path) so the matching
155+
# loop below stays a single nested for-loop. Comments outside the
156+
# routes block are skipped because the regexes do not match them.
157+
policy=$(
158+
awk '
159+
/^[[:space:]]*-[[:space:]]+path:[[:space:]]*"/ {
160+
line = $0
161+
sub(/^[^"]*"/, "", line)
162+
sub(/".*/, "", line)
163+
cur_path = line
164+
next
165+
}
166+
/^[[:space:]]+verbs:[[:space:]]*\[/ {
167+
line = $0
168+
sub(/^[^\[]*\[/, "", line)
169+
sub(/\].*/, "", line)
170+
gsub(/[[:space:]]/, "", line)
171+
n = split(line, vs, ",")
172+
for (i = 1; i <= n; i++) {
173+
print vs[i] "\t" cur_path
174+
}
175+
}
176+
' "$POLICY_FILE"
177+
)
178+
179+
# 3. For each wired route, find a covering policy rule.
180+
#
181+
# Concretise `:name`-style segments with $PROBE so regex policy paths
182+
# (`^/cartridge/[A-Za-z0-9_.-]+/invoke$` etc.) can be tested against
183+
# a real URL string. The PROBE default ("probe") is shared with the
184+
# smoke script so a future tightening of the regex character class
185+
# fails both checks in lock-step instead of one silently.
186+
drift=0
187+
drift_msgs=()
188+
match_msgs=()
189+
while IFS=$'\t' read -r verb tmpl; do
190+
[ -z "${verb:-}" ] && continue
191+
# Substitute `:identifier` segments with the probe placeholder.
192+
# `:name` → `probe`, `:cartridge_id` → `probe`, etc.
193+
concrete=$(printf '%s' "$tmpl" | sed -E "s|:[a-zA-Z_][a-zA-Z0-9_]*|${PROBE}|g")
194+
195+
matched_rule=""
196+
while IFS=$'\t' read -r p_verb p_path; do
197+
[ -z "${p_verb:-}" ] && continue
198+
[ "$verb" = "$p_verb" ] || continue
199+
case "$p_path" in
200+
\^*)
201+
# Regex pattern — ERE match against the concrete URL.
202+
if printf '%s' "$concrete" | grep -qE "$p_path"; then
203+
matched_rule="$p_verb $p_path"
204+
break
205+
fi
206+
;;
207+
*)
208+
# Literal pattern — exact string equality.
209+
if [ "$concrete" = "$p_path" ]; then
210+
matched_rule="$p_verb $p_path"
211+
break
212+
fi
213+
;;
214+
esac
215+
done <<< "$policy"
216+
217+
if [ -z "$matched_rule" ]; then
218+
drift_msgs+=("$verb $tmpl (concrete: $concrete)")
219+
drift=$((drift + 1))
220+
else
221+
match_msgs+=("$verb $tmpl$matched_rule")
222+
fi
223+
done <<< "$wired"
224+
225+
echo "==> HCG surface drift check"
226+
echo " Router file: $ROUTER_FILE"
227+
echo " Policy file: $POLICY_FILE"
228+
echo " Probe placeholder: '$PROBE'"
229+
wired_count=$(printf '%s\n' "$wired" | grep -c . || true)
230+
policy_count=$(printf '%s\n' "$policy" | grep -c . || true)
231+
echo " Wired (router) routes: $wired_count"
232+
echo " Policy (verb,path) rules: $policy_count"
233+
echo
234+
235+
if [ "$VERBOSE" = "1" ] && [ ${#match_msgs[@]} -gt 0 ]; then
236+
echo "Matched:"
237+
for m in "${match_msgs[@]}"; do
238+
printf ' %s\n' "$m"
239+
done
240+
echo
241+
fi
242+
243+
if [ "$drift" -eq 0 ]; then
244+
echo "OK: every wired router route is covered by at least one policy rule."
245+
echo "The §1.5 re-verification stamp in config/gateway-policy-boj.yaml"
246+
echo "can be advanced safely after manual review of any policy edits."
247+
exit 0
248+
fi
249+
250+
echo "DRIFT: $drift wired router route(s) are not covered by any policy rule:"
251+
for m in "${drift_msgs[@]}"; do
252+
printf ' - %s\n' "$m"
253+
done
254+
echo
255+
echo "Resolution: add a matching rule to config/gateway-policy-boj.yaml"
256+
echo "(and config/gateway-policy-boj-example.yaml if the route is part of"
257+
echo "the pedagogical surface). See docs/integration/http-capability-"
258+
echo "gateway-policy-authoring.md §5 for the co-change discipline."
259+
exit 1

0 commit comments

Comments
 (0)