Skip to content

Commit 2f8cd93

Browse files
rfc: ADR-0007 trust-tier policy DSL + ADR-0008 cartridge marketplace (epic #87 items 4 + 10) (#92)
## Summary Two coupled RFCs for epic #87 Tier B. Coupled because they share trust-tier vocabulary — ADR-0008's submission protocol requires every cartridge to ship a default policy bundle in the schema ADR-0007 defines. **No code in this PR.** Pure design docs. Implementation tracked separately in epic #87 (items 4 and 10). ## ADR-0007 — Trust-tier policy DSL Adopt **Nickel** as the policy DSL (extends the existing \`coord-messages.ncl\` investment), with a clean **PEP/PDP split**: - **PEP** stays in \`mcp-bridge/main.js\` (where the existing \`hardeningGate\` lives) - **PDP** is a new \`policy-mcp\` cartridge that the bridge consults per \`tools/call\` Schema: per-cartridge × per-tool × \`{tier, rate_limit, required_role, master_approval, allowed_args, side_effect}\`. Verdicts: \`allow\` / \`deny\` / \`rate_limit\` / \`require_approval\` (the last routes through \`coord_send_gated\`-style quarantine). Default policy bundle covers all 41 bridge tools out of the box. Migration sequence covered explicitly in the ADR's "Open questions" section. ## ADR-0008 — Cartridge marketplace **Activates the Ayo tier**, which currently has exactly one member and no protocol. New repo \`hyperpolymath/cartridge-index\` holds signed entries (ML-DSA-87 per EXHIBIT-B) pointing to source repos at content-pinned revs. Promotion path: outside → Ayo → Shield → Teranga. Promotion is recorded in the index, not the source repo, so cartridges don't need to "know" their tier. Critically: respects ADR-0002 (BoJ-only MCP). Community capabilities flow into BoJ as cartridges, **never as standalone MCPs**. Default-safe posture: Ayo cartridges are visible via \`boj_marketplace_search\` but never auto-loaded; user must explicitly \`boj_marketplace_install --accept-ayo-tier\`. ## Why this pair, this order The epic specifies they ship together: "Items 4 + 10 RFCs together (they share the trust-tier vocabulary)." 0007 defines what a policy bundle looks like; 0008 requires every submission to ship one. Either alone leaves a hole. ## Review focus The ADRs are formatted for substantive engagement, not rubber-stamp. Each has explicit: - **Consequences (positive + negative)** — not just "this is good" - **Non-goals** — what we deliberately won't do - **Open questions** — decisions explicitly deferred to implementation Recommend reading the **Open questions** sections — that's where the load-bearing choices that need your call live. ## What this doesn't change - No code touched. - No existing cartridges affected. - ADR-0002 stands; this RFC pair operates strictly within its constraints. ## Sequencing Per epic #87: this is Tier B step 1 of 3. Next pair (items 2 + 3: sandbox cartridge + cross-machine federation) follows in a sibling PR; the third pair (items 5 + 6: webhooks + sampling) after that. All three pairs are independent of each other and of the code PRs (#88/#89/#91). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3457dd5 commit 2f8cd93

2 files changed

Lines changed: 333 additions & 0 deletions

File tree

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
<!-- SPDX-License-Identifier: MPL-2.0 -->
2+
<!-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> -->
3+
4+
# 7. Trust-tier policy DSL — Nickel-encoded PEP at the bridge, PDP as a cartridge
5+
6+
Date: 2026-05-20
7+
8+
## Status
9+
10+
Proposed (RFC — implementation tracked in epic #87 item 4)
11+
12+
## Context
13+
14+
BoJ defines three trust tiers — **Teranga** (formally verified, full ABI discharge), **Shield** (operationally hardened, explicit security review), **Ayo** (community contributions, master-approval-gated). The tier vocabulary appears in:
15+
16+
- README ("Cartridges — 115 pluggable cartridges across Teranga / Shield / Ayo trust tiers")
17+
- `mcp-bridge/lib/offline-menu.js` (cartridges grouped into `tier_teranga` / `tier_shield` / `tier_ayo`)
18+
- `boj://server/info` (the `trust_tiers` field, added in PR #89)
19+
- Cartridge manifests (declared per-cartridge)
20+
21+
But **the tiers are not load-bearing at runtime**. The bridge today applies one rate limit, one prompt-injection filter, and one input-size cap to every call regardless of which cartridge is invoked or what tier it sits in. A `boj_github_merge_pr` (destructive, tier-3-equivalent) and a `boj_cartridge_info` (read-only, tier-0) are gated identically.
22+
23+
ADR-0002 ("BoJ-only MCP") establishes BoJ as the single MCP gateway for the estate. By construction there is one **policy enforcement point** (the bridge). It follows that there should be one **policy decision point** that the bridge consults — separating *what is allowed* from *how it's enforced*. Without that separation, every new policy rule means touching `mcp-bridge/main.js`, which doesn't scale and conflicts with the BoJ-only-MCP rule (you can't push policy out to per-cartridge code without breaking the rule).
24+
25+
The coord layer already uses **Nickel** for typed-envelope contracts (`cartridges/local-coord-mcp/coord-messages.ncl`, gated by `COORD_REQUIRE_NICKEL=1`). Extending Nickel to the broader policy surface keeps the verification story coherent: one schema language, one validator, two surfaces (coord envelopes + bridge dispatch).
26+
27+
## Decision
28+
29+
Adopt **Nickel as the policy DSL**, with a clean **PEP / PDP split**:
30+
31+
- **PEP (Policy Enforcement Point)** — the bridge's `hardeningGate` in `mcp-bridge/main.js`. Stays where it is.
32+
- **PDP (Policy Decision Point)** — a new `policy-mcp` cartridge that holds the active policy bundle, evaluates queries, and returns allow / deny / require-approval / rate-limit verdicts. Lives at `cartridges/policy-mcp/`.
33+
34+
### Policy schema (Nickel)
35+
36+
```nickel
37+
# policies/boj-default.ncl
38+
let CartridgePolicy = {
39+
tier | std.number.Number | std.contract.in [0, 1, 2, 3, 4],
40+
rate_limit | { per_minute | Number, per_hour | Number },
41+
required_role | [| 'apprentice, 'journeyman, 'master |],
42+
master_approval | Bool,
43+
allowed_args | { _ : { type | String, validator | String | default = "" } },
44+
side_effect | [| 'read, 'small_write, 'major_write, 'destructive |],
45+
} in
46+
47+
{
48+
# Teranga (tier 0-1) — formally verified, broadly available
49+
cartridge."github-api-mcp".tools.boj_github_get_repo = {
50+
tier = 0,
51+
rate_limit = { per_minute = 60, per_hour = 1000 },
52+
required_role = 'apprentice,
53+
master_approval = false,
54+
allowed_args = {
55+
owner = { type = "string", validator = "^[A-Za-z0-9-]+$" },
56+
repo = { type = "string", validator = "^[A-Za-z0-9._-]+$" },
57+
},
58+
side_effect = 'read,
59+
},
60+
61+
# Shield (tier 2) — write operations gated on journeyman role
62+
cartridge."github-api-mcp".tools.boj_github_create_issue = {
63+
tier = 2,
64+
rate_limit = { per_minute = 10, per_hour = 50 },
65+
required_role = 'journeyman,
66+
master_approval = false,
67+
side_effect = 'small_write,
68+
},
69+
70+
# Ayo (tier 3-4) — destructive operations require master approval
71+
cartridge."github-api-mcp".tools.boj_github_merge_pr = {
72+
tier = 3,
73+
rate_limit = { per_minute = 2, per_hour = 5 },
74+
required_role = 'journeyman,
75+
master_approval = true,
76+
side_effect = 'major_write,
77+
},
78+
}
79+
```
80+
81+
### Wire protocol (PEP → PDP)
82+
83+
The bridge already loads Nickel locally (`mcp-bridge/lib/nickel-validator.js`). For policy queries, the bridge calls into `policy-mcp` over the same REST surface every cartridge uses:
84+
85+
```
86+
POST /cartridge/policy-mcp/evaluate
87+
{
88+
"cartridge": "github-api-mcp",
89+
"tool": "boj_github_merge_pr",
90+
"args": { "owner": "...", "repo": "...", "pull_number": 42 },
91+
"peer": { "role": "journeyman", "client_kind": "claude", "variant": "opus-4.7" }
92+
}
93+
94+
{
95+
"verdict": "require_approval" | "allow" | "deny" | "rate_limit",
96+
"tier": 3,
97+
"reason": "boj_github_merge_pr is tier-3; journeyman role + master approval required",
98+
"approval_token": "<opaque>", // only when verdict=require_approval
99+
"rate_limit_window_ms": 60000, // only when verdict=rate_limit
100+
"audit_id": "<uuid>"
101+
}
102+
```
103+
104+
### PEP behaviour matrix
105+
106+
| Verdict | Bridge action |
107+
|---|---|
108+
| `allow` | Proceed to dispatch |
109+
| `deny` | Return MCP error `-32000` "policy denied: <reason>"; emit audit log; do not dispatch |
110+
| `rate_limit` | Return MCP error `-32001` "rate limit; retry after <window>"; do not dispatch |
111+
| `require_approval` | If caller's role is master, proceed. Otherwise enqueue in `coord_send_gated`-style quarantine and return `-32002` "pending master approval, request_id=<n>" |
112+
113+
### Default policy bundle
114+
115+
Ship `policies/boj-default.ncl` covering all 41 bridge tools. Derived from each tool's existing annotations (read-only vs side-effectful), which were classified during the v0.4.6 AAA-tier description pass. No new metadata work required for the default bundle.
116+
117+
### Configuration
118+
119+
- `BOJ_POLICY_MODE=enforce` (default) | `audit` (log verdicts but always allow) | `off` (skip PDP)
120+
- `BOJ_POLICY_BUNDLE` — path to active `.ncl` file (default `policies/boj-default.ncl`)
121+
- New tool `boj_policy_reload` for hot-reloading the bundle without restart
122+
123+
## Consequences
124+
125+
### Positive
126+
127+
- **Tier names become enforceable**`tier: 3` in policy = `master_approval: true` at runtime. The taxonomy is no longer documentation.
128+
- **Per-cartridge / per-tool granularity** — different tools in the same cartridge can have different policies (e.g. `boj_github_list_issues` open, `boj_github_merge_pr` master-gated).
129+
- **One DSL, two surfaces** — extends the existing Nickel investment from coord envelopes to bridge dispatch. Reuses `nickel-validator.js` infrastructure.
130+
- **PEP/PDP separation matches NIST RBAC 4.0 reference architecture** — policy auditing tools (OPA, Rego, etc.) generalise to this shape; future migration is mechanical.
131+
- **Audit trail by construction** — every decision yields an `audit_id` linkable to a span (pairs with epic #87 item 13's OTel exporter).
132+
- **Cleans up `hardeningGate`** — the bridge's current 5-stage validation collapses into "ask the PDP". Each rule becomes a Nickel contract, not JS.
133+
134+
### Negative
135+
136+
- **Latency** — every `tools/call` now incurs a PDP round-trip. Mitigation: in-process Nickel evaluation (no HTTP) when policy-mcp is loaded as a same-process cartridge. Out-of-process only for federated multi-machine setups (see ADR-0010).
137+
- **Cold-start failure mode** — what does the bridge do if policy-mcp is unreachable? Three modes: `fail-closed` (deny all), `fail-open` (allow all), `fail-static` (use cached last-good bundle). Default: `fail-static` with `BOJ_POLICY_FAIL_MODE` override.
138+
- **Policy authorship burden** — writing 41-tool policies once is fine; the marginal cost per new cartridge isn't trivial. Mitigation: ADR-0008 (cartridge marketplace) requires every submitted cartridge to ship a default policy, just as it ships a manifest.
139+
- **Existing 5-stage hardening must coexist** — rate limit, size cap, injection scan, name validation, required-args all stay in the PEP; the PDP layers *on top*. Don't fold them in until they're proven equivalent under Nickel contracts.
140+
141+
## Non-goals
142+
143+
- Not building a generic RBAC system. The roles are master/journeyman/apprentice as already defined in coord-mcp; new roles require ADR.
144+
- Not adopting OPA/Rego — Nickel is already the estate's contract language; importing another would split the surface.
145+
- Not creating "user accounts" — peer identity is via `coord_register` token, which is the canonical identity. Multi-tenancy is out of scope for v1.
146+
- Not enforcing policy on `resources/read` or `prompts/get` (added in PR #89) initially — those are read-only and the cost/benefit doesn't justify it. Revisit if abuse surfaces.
147+
148+
## Open questions
149+
150+
1. **PDP-as-cartridge vs PDP-in-bridge** — does the PDP genuinely need to be a separate cartridge, or should it be an in-process module like `nickel-validator.js`? Arguments both ways. Recommend cartridge for separation of concerns and to enable per-deployment policy customisation.
151+
152+
2. **Policy versioning** — when the bundle changes, in-flight quarantined approvals could have been written against a different version. Need a `policy_version` field on quarantined envelopes; reject approval if version doesn't match.
153+
154+
3. **Cross-cartridge composition policies**`boj_cartridge_invoke` against an arbitrary cartridge currently bypasses tool-level policy. Either (a) require every cartridge to declare its full tool surface in its manifest so the PDP knows the per-tool tier, or (b) apply the cartridge-level worst-case tier to all unrecognised tools. Recommend (a); it's already partially true for verified cartridges.
155+
156+
4. **Migration sequence** — should the existing `hardeningGate` rate-limit logic be ported into the PDP (so all policy lives in one place) or left in the PEP for performance? Recommend PEP keeps rate-limit + injection-scan as fast-path guards, PDP handles the tier-aware logic.
157+
158+
## Implementation sketch
159+
160+
When this RFC is accepted:
161+
162+
1. New cartridge `cartridges/policy-mcp/` with Idris2 ABI + Zig FFI + Deno adapter (standard triple). Manifest declares one tool `policy_evaluate` plus internal `policy_reload`.
163+
2. `policies/boj-default.ncl` shipped at the repo root. 41 tools covered.
164+
3. `mcp-bridge/lib/policy-client.js` — minimal Nickel-backed PDP client (in-process when local, REST when federated).
165+
4. `mcp-bridge/main.js``hardeningGate` adds a step 6 ("PDP evaluation") and respects the verdict.
166+
5. New env vars in `glama.json`: `BOJ_POLICY_MODE`, `BOJ_POLICY_BUNDLE`, `BOJ_POLICY_FAIL_MODE`.
167+
6. Test suite: 1 test per verdict outcome (allow / deny / rate_limit / require_approval); fixture policy bundles for each case.
168+
7. Documentation: a new `docs/operations/POLICY-AUTHORING.md` showing how operators customise the default bundle.
169+
170+
## Linked
171+
172+
- ADR-0002 (BoJ-only MCP) — establishes the single-gateway invariant this depends on.
173+
- ADR-0008 (cartridge marketplace) — submission flow requires a default policy per cartridge.
174+
- Epic #87 item 4 (this).
175+
- `cartridges/local-coord-mcp/coord-messages.ncl` — vocabulary precedent.

0 commit comments

Comments
 (0)