Skip to content

Commit b733860

Browse files
Phase A — http-capability-gateway↔BoJ contract + policy authoring workflow + example spec (#78)
## Phase A — http-capability-gateway tier-2 integration (ADR-0004) Single-channel work item: **standards#91** parent, **standards#96** Phase A (the first of the strictly-sequential A–E). Specification-only — **no gateway or BoJ HTTP code is changed** (plan §Phase A acceptance criterion). ### Deliverables (per `docs/integration/http-capability-gateway-plan.md` §Phase A) - **A1** `docs/integration/http-capability-gateway-boj-contract.md` — transport (TCP loopback staging / Unix socket prod, committed to resolve the plan's port/transport indecision risk), forwarded headers, gateway/BoJ trust vocabulary mapping, error semantics, stealth, deployment trust topology (Phase B input), and surface-drift handling (A3 input). - **A2** `docs/integration/http-capability-gateway-policy-authoring.md` — where the spec lives (BoJ repo, option 1), who writes it, `POLICY_PATH` load sequence, hot-reload triggers, and the co-change anti-drift discipline. - **A3** `config/gateway-policy-boj-example.yaml` — 27 rules grounded in `docs/specification/openapi.yaml` cross-checked against `elixir/lib/boj_rest/router.ex`; conforms to DSL v1 (audit §2); validated (YAML well-formed, regexes compile, all invariants hold, names unique). ### Load-bearing finding `BojRest.Router` **bypasses trust enforcement for loopback callers**. The gateway forwards from loopback, so BoJ will accept the gateway's `X-Trust-Level` verbatim. Inbound `X-Trust-Level` stripping + back-side network isolation are therefore **mandatory security invariants** (contract §3), not advisory — tested in Phase C, hardened in Phase B (mTLS). ### Honesty notes (not papered over) - The plan's "passes `PolicyLoader`+`PolicyValidator`" criterion is a *manual* check against a running gateway; no gateway runs in Phase A. The example is authored to conform exactly to the documented DSL v1 schema and validated structurally; the running-gateway check is a recorded carry-over (authoring-workflow §6), not a code deliverable, and does not block Phase A. - Global-fallback exposure semantics flagged inline as a default-deny caveat to confirm in that manual step. - `.md` (not `.adoc`) retained for the two docs deliberately — the plan normatively prescribes the contract path and the Phase C Trustfile `[SEAMS]` entry references it verbatim; rationale stated in-document. ### Scope discipline Phase A only. Phases B–E (#97#100) are **not** started. This PR uses `Refs` and **does not close** standards#91/#96 — they are `major,requirements-target` (joint-close only, on explicit user agreement). Refs hyperpolymath/standards#91 Refs hyperpolymath/standards#96 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9a6e326 commit b733860

3 files changed

Lines changed: 593 additions & 0 deletions

File tree

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
#
4+
# ===========================================================================
5+
# Verb Governance Spec — WORKED EXAMPLE for BoJ's HTTP surface
6+
# ===========================================================================
7+
# Phase A deliverable A3 (standards#96 / standards#91).
8+
#
9+
# This is the *example* policy. The live policy will be config/gateway-policy-boj.yaml,
10+
# added in a later phase. See:
11+
# - docs/integration/http-capability-gateway-boj-contract.md (the seam contract)
12+
# - docs/integration/http-capability-gateway-policy-authoring.md (authoring workflow)
13+
#
14+
# Conforms to DSL v1 (gateway audit §2). Validator invariants honoured:
15+
# * dsl_version is exactly the string "1"
16+
# * governance.global_verbs is a non-empty list of all-caps HTTP verbs
17+
# * every route has a non-empty `verbs` list
18+
# * exposure ∈ {public, authenticated, internal}
19+
# * stealth has boolean `enabled` and integer `status_code` in 100..599
20+
#
21+
# Surface source: docs/specification/openapi.yaml, cross-checked against
22+
# elixir/lib/boj_rest/router.ex (per the plan's "surface drift" risk). Routes
23+
# declared in openapi.yaml but not yet wired in router.ex are governed anyway
24+
# (contract §8) so they are not silently exposed when implemented.
25+
#
26+
# ---------------------------------------------------------------------------
27+
# DEFAULT-DENY NOTE (must be confirmed in the Phase A manual verification)
28+
# ---------------------------------------------------------------------------
29+
# The gateway's three-tier lookup is exact → regex → global-fallback. A path
30+
# that matches NO route entry must be DENIED, not served as public via the
31+
# global-fallback rule. `global_verbs` below is the set of verbs the gateway
32+
# will route AT ALL (DELETE/PUT/PATCH/OPTIONS absent ⇒ globally denied — the
33+
# core verb-governance win of ADR-0004). When the gateway is first stood up,
34+
# the manual verification (authoring-workflow §6) MUST confirm that an
35+
# unlisted path does NOT fall through global-fallback to `public`. If the
36+
# gateway treats global-fallback as public-by-default, the live policy must
37+
# pin global exposure to deny / internal before go-live. This caveat is
38+
# recorded here deliberately rather than papered over.
39+
# ===========================================================================
40+
41+
dsl_version: "1"
42+
43+
governance:
44+
# Only GET and POST are used anywhere on BoJ's declared surface. Every other
45+
# verb (DELETE, PUT, PATCH, OPTIONS, HEAD, ...) is globally denied by omission.
46+
global_verbs:
47+
- GET
48+
- POST
49+
50+
routes:
51+
# ---- Public (reachable pre-auth) ------------------------------------
52+
- path: "/health"
53+
verbs: [GET]
54+
exposure: "public"
55+
name: "health-get"
56+
narrative: "Liveness/readiness probe. Must be reachable without trust by
57+
load balancers and Cloudflare health checks."
58+
59+
- path: "/.well-known/boj-node-pubkey"
60+
verbs: [GET]
61+
exposure: "public"
62+
name: "node-pubkey-get"
63+
narrative: "X25519 node public key. Callers MUST fetch this before they
64+
can encrypt per-invocation credentials (docs/AUTH-DESIGN.adoc Option
65+
A), so it is necessarily pre-auth and public."
66+
67+
# Deliberate public exception: external webhook ----------------------
68+
- path: "/cartridges/ssg-mcp/webhook"
69+
verbs: [POST]
70+
exposure: "public"
71+
name: "ssg-mcp-webhook-post"
72+
narrative: "GitHub push-event webhook. The sender (GitHub) cannot present
73+
a gateway trust level, so this route is intentionally `public` at the
74+
gateway. Real authentication is the GitHub HMAC signature verified by
75+
the ssg-mcp cartridge, with Cloudflare tier-1 WAF in front. Documented
76+
exception — do not generalise to other POST routes."
77+
78+
# ---- Authenticated (credentialed callers) ---------------------------
79+
- path: "/status"
80+
verbs: [GET]
81+
exposure: "authenticated"
82+
name: "status-get"
83+
narrative: "Detailed server status exposes internal topology; gate behind
84+
authentication. (Declared in openapi.yaml; not yet wired in router.ex —
85+
governed in advance, contract §8.)"
86+
87+
- path: "/menu"
88+
verbs: [GET]
89+
exposure: "authenticated"
90+
name: "menu-get"
91+
narrative: "Full cartridge catalogue (Teranga menu). Enumerates installed
92+
capabilities; requires authentication."
93+
94+
- path: "/matrix"
95+
verbs: [GET]
96+
exposure: "authenticated"
97+
name: "matrix-get"
98+
narrative: "Protocol-domain cartridge matrix. Capability enumeration;
99+
authenticated. (Declared-not-yet-wired, contract §8.)"
100+
101+
- path: "/cartridges"
102+
verbs: [GET]
103+
exposure: "authenticated"
104+
name: "cartridges-list-get"
105+
narrative: "Cartridge name list. Capability enumeration; authenticated."
106+
107+
- path: "^/cartridge/[A-Za-z0-9_.-]+$"
108+
verbs: [GET]
109+
exposure: "authenticated"
110+
name: "cartridge-detail-get"
111+
narrative: "Single cartridge metadata (cartridge.json). Regex matches one
112+
cartridge name segment; deliberately does NOT match the /invoke,
113+
/load, /unload, /reload sub-paths (separate rules)."
114+
115+
- path: "^/cartridge/[A-Za-z0-9_.-]+/invoke$"
116+
verbs: [POST]
117+
exposure: "authenticated"
118+
name: "cartridge-invoke-post"
119+
narrative: "Tool dispatch into a cartridge. Credentialed: BojRest.Router
120+
check_trust/3 requires internal/authenticated for cartridges whose
121+
auth.method is not `none`."
122+
123+
- path: "/graphql"
124+
verbs: [POST]
125+
exposure: "authenticated"
126+
name: "graphql-post"
127+
narrative: "GraphQL query endpoint. Arbitrary query surface; authenticated.
128+
(Declared-not-yet-wired, contract §8.)"
129+
130+
- path: "^/grpc/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"
131+
verbs: [POST]
132+
exposure: "authenticated"
133+
name: "grpc-method-post"
134+
narrative: "gRPC method endpoint. CAVEAT: gRPC is HTTP/2; the gateway
135+
governs the HTTP request line/path only. If gRPC traffic does not
136+
traverse the gateway's HTTP path it is out of this policy's reach —
137+
flagged for the Phase C seam test. (Declared-not-yet-wired.)"
138+
139+
- path: "/sse"
140+
verbs: [GET]
141+
exposure: "authenticated"
142+
name: "sse-get"
143+
narrative: "MCP/SSE long-lived event stream. CAVEAT: streaming/long-poll
144+
interacts with the proxy timeout and circuit breaker — Phase C/D must
145+
confirm the gateway does not prematurely break long-lived SSE
146+
connections. (Declared-not-yet-wired.)"
147+
148+
- path: "/order"
149+
verbs: [POST]
150+
exposure: "authenticated"
151+
name: "order-post"
152+
narrative: "Submit a cartridge order. State-changing; authenticated.
153+
(Declared-not-yet-wired, contract §8.)"
154+
155+
- path: "/order-ticket"
156+
verbs: [POST]
157+
exposure: "authenticated"
158+
name: "order-ticket-post"
159+
narrative: "Submit an order ticket. State-changing; authenticated.
160+
(Declared-not-yet-wired, contract §8.)"
161+
162+
- path: "/umoja/status"
163+
verbs: [GET]
164+
exposure: "authenticated"
165+
name: "umoja-status-get"
166+
narrative: "Umoja federation status. Federation topology; authenticated."
167+
168+
- path: "/umoja/transport"
169+
verbs: [GET]
170+
exposure: "authenticated"
171+
name: "umoja-transport-get"
172+
narrative: "Federation transport mode. Authenticated read."
173+
174+
- path: "/umoja/peers"
175+
verbs: [GET]
176+
exposure: "authenticated"
177+
name: "umoja-peers-get"
178+
narrative: "List federation peers. Authenticated read. Mutating POST to
179+
the same path is a separate internal rule below."
180+
181+
- path: "/coprocessor/status"
182+
verbs: [GET]
183+
exposure: "authenticated"
184+
name: "coprocessor-status-get"
185+
narrative: "Coprocessor dispatch status. Authenticated read."
186+
187+
- path: "/sla/status"
188+
verbs: [GET]
189+
exposure: "authenticated"
190+
name: "sla-status-get"
191+
narrative: "SLA monitoring status. Authenticated read."
192+
193+
- path: "/community/submissions"
194+
verbs: [GET]
195+
exposure: "authenticated"
196+
name: "community-submissions-get"
197+
narrative: "List community cartridge submissions. Authenticated read."
198+
199+
- path: "/community/submit"
200+
verbs: [POST]
201+
exposure: "authenticated"
202+
name: "community-submit-post"
203+
narrative: "Submit a community cartridge. Authenticated write; further
204+
review/quarantine is a downstream cartridge concern."
205+
206+
# ---- Internal-only (stealth: hidden from lesser trust) --------------
207+
- path: "^/cartridge/[A-Za-z0-9_.-]+/load$"
208+
verbs: [POST]
209+
exposure: "internal"
210+
stealth_profile: "default"
211+
name: "cartridge-load-post"
212+
narrative: "Mount a cartridge. Lifecycle mutation; internal-only and
213+
stealthed (404) so the capability is invisible to lesser trust."
214+
215+
- path: "^/cartridge/[A-Za-z0-9_.-]+/unload$"
216+
verbs: [POST]
217+
exposure: "internal"
218+
stealth_profile: "default"
219+
name: "cartridge-unload-post"
220+
narrative: "Unmount a cartridge. Lifecycle mutation; internal + stealth."
221+
222+
- path: "^/cartridge/[A-Za-z0-9_.-]+/reload$"
223+
verbs: [POST]
224+
exposure: "internal"
225+
stealth_profile: "default"
226+
name: "cartridge-reload-post"
227+
narrative: "Reload (unmount, verify, remount) a cartridge. Lifecycle
228+
mutation; internal + stealth."
229+
230+
- path: "/umoja/peers"
231+
verbs: [POST]
232+
exposure: "internal"
233+
stealth_profile: "default"
234+
name: "umoja-peers-post"
235+
narrative: "Mutate federation peer set. Membership control is internal +
236+
stealth. (Same path as the authenticated GET above; the gateway keys
237+
rules by {path, verb}.)"
238+
239+
- path: "/coprocessor/select"
240+
verbs: [POST]
241+
exposure: "internal"
242+
stealth_profile: "default"
243+
name: "coprocessor-select-post"
244+
narrative: "Select a coprocessor. Operational dispatch control; internal
245+
+ stealth. (Declared-not-yet-wired, contract §8.)"
246+
247+
- path: "/sdp/status"
248+
verbs: [GET]
249+
exposure: "internal"
250+
stealth_profile: "default"
251+
name: "sdp-status-get"
252+
narrative: "Auto-SDP (single-packet-authorisation) status. A recon target
253+
for an attacker probing the auth perimeter; internal-only and stealthed
254+
so its existence is not confirmable from outside."
255+
256+
stealth:
257+
enabled: true
258+
status_code: 404

0 commit comments

Comments
 (0)