Skip to content

Commit 93c589d

Browse files
hyperpolymathclaude
andcommitted
feat(local-coord-mcp): proper Nickel contracts with dependent constraints
Task #16. Upgrades from "JSON Schema syntax embedded in Nickel" to actual Nickel contracts for the four dependent rules JSON Schema can't cleanly express. New file schemas/coord-messages-contracts.ncl exports: - Primitive contracts: RiskTier, OpKind, MsgIdHex, HashHex (predicate- based, validate shape + constraints). - TierContextGate — Tier >= 2 envelopes MUST carry non-empty context_fetch_id. Enforces the adaptive-awareness invariant (DD-10). - TierAttestationGate — Tier >= 2 from role=supervised MUST carry at least one attestation_refs entry. Enforces M-of-N Byzantine safety (DD-8). - UrgentDirectRestriction — urgent_direct=true is forbidden for supervised peers (DD-12 user-interaction routing). - TierOverrideJustification — if declared risk_tier differs from op_kind.tier_default, tier_override_reason is required. Catches tier underclaiming (DD-14). - validate = a lazy function composing all four. Consumers call `envelope | contracts.validate` to run all checks. The sibling schemas/coord-messages.ncl stays as the portable JSON-Schema-exportable interface; the two files work together: JSON Schema (.json export) → shape validation for all clients Nickel contracts (this) → rich cross-field rules, server-side Test harness schemas/test-contracts.sh covers 6 cases: - tier 0 status envelope → accepted - tier 2 claim with context_fetch_id → accepted - tier 2 without context_fetch_id → rejected - tier 3 from supervised without attestation → rejected - urgent_direct from supervised → rejected - tier 3 declared on op_kind=status with no override_reason → rejected All 6 pass. Consumers (Task #17 Deno shim in mcp-bridge) will import this file on bridge startup and invoke `validate` on each coord_send / coord_send_gated envelope BEFORE forwarding to the Zig adapter. Early rejection, defense-in-depth. Roadmap: Task #17 = Deno shim wiring; Phase 2 = Idris2 session types for the choreography these rules imply. Design log: Desktop/COORD-MCP-DESIGN-LOG.md (Appendix K roadmap). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4e164a7 commit 93c589d

3 files changed

Lines changed: 325 additions & 0 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
#
4+
# coord-messages-contracts.ncl — Nickel contracts for rich server-side
5+
# envelope validation. Sibling to coord-messages.ncl (portable JSON
6+
# Schema) — use this file for cross-field dependent constraints that
7+
# JSON Schema can't elegantly express.
8+
#
9+
# Wired into mcp-bridge via a Deno shim (Task #17): on each coord_send
10+
# or coord_send_gated call, the bridge imports this file, extracts
11+
# `EnvelopeV1`, and applies it to the message body. Invalid envelopes
12+
# are rejected with a descriptive error before they reach the Zig
13+
# adapter.
14+
#
15+
# Usage pattern:
16+
# let contracts = import "coord-messages-contracts.ncl" in
17+
# let ok_envelope = my_envelope | contracts.EnvelopeV1 in
18+
# ...
19+
#
20+
# If any constraint fails, Nickel emits a descriptive error pointing at
21+
# the offending field. The Deno shim translates that error into an MCP
22+
# error response.
23+
24+
let OpDefaults = {
25+
status = 0,
26+
query = 0,
27+
context_query = 0,
28+
context_reply = 0,
29+
supervise_req = 0,
30+
supervise_resp = 0,
31+
attest_req = 0,
32+
attest_resp = 0,
33+
fyi = 1,
34+
progress = 1,
35+
warn_drift = 1,
36+
release = 1,
37+
claim = 2,
38+
handoff = 2,
39+
clarify = 2,
40+
blocker = 3,
41+
gated_op = 3,
42+
} in
43+
44+
let OpKinds = std.record.fields OpDefaults in
45+
46+
{
47+
# ── Primitive contracts ─────────────────────────────────────────
48+
49+
RiskTier = std.contract.from_predicate (fun v =>
50+
std.is_number v
51+
&& v >= 0
52+
&& v <= 4
53+
&& v == (std.number.floor v)
54+
),
55+
56+
OpKind = std.contract.from_predicate (fun v =>
57+
std.is_string v && std.array.any (fun k => k == v) OpKinds
58+
),
59+
60+
MsgIdHex = std.contract.from_predicate (fun v =>
61+
std.is_string v
62+
&& std.string.length v == 12
63+
&& std.string.is_match "^[0-9a-f]{12}$" v
64+
),
65+
66+
HashHex = std.contract.from_predicate (fun v =>
67+
std.is_string v
68+
&& std.string.length v == 64
69+
&& std.string.is_match "^[0-9a-f]{64}$" v
70+
),
71+
72+
# ── Dependent constraints — the rules JSON Schema can't express ──
73+
74+
# Any Tier >= 2 envelope MUST carry a non-empty context_fetch_id.
75+
# Blast-radius scaled: drone at Tier 0/1, strategic at Tier 2+.
76+
TierContextGate = std.contract.from_predicate (fun e =>
77+
!(std.record.has_field "risk_tier" e)
78+
|| e.risk_tier < 2
79+
|| (std.record.has_field "context_fetch_id" e
80+
&& std.is_string e.context_fetch_id
81+
&& std.string.length e.context_fetch_id > 0)
82+
),
83+
84+
# Any Tier >= 2 envelope from a peer whose role is "supervised" MUST
85+
# carry at least one attestation_refs entry. M-of-N Byzantine safety.
86+
#
87+
# The sender's role is NOT in the envelope itself (server-attached at
88+
# dispatch). Validator caller passes role via e._meta.sender_role when
89+
# running this contract server-side.
90+
TierAttestationGate = std.contract.from_predicate (fun e =>
91+
!(std.record.has_field "risk_tier" e)
92+
|| e.risk_tier < 2
93+
|| !(std.record.has_field "_meta" e)
94+
|| !(std.record.has_field "sender_role" e._meta)
95+
|| e._meta.sender_role != "supervised"
96+
|| (std.record.has_field "attestation_refs" e
97+
&& std.is_array e.attestation_refs
98+
&& std.array.length e.attestation_refs >= 1)
99+
),
100+
101+
# urgent_direct=true is only permitted for supervisor + executor.
102+
# supervised peers never interrupt the user directly.
103+
UrgentDirectRestriction = std.contract.from_predicate (fun e =>
104+
!(std.record.has_field "urgent_direct" e)
105+
|| !e.urgent_direct
106+
|| !(std.record.has_field "_meta" e)
107+
|| !(std.record.has_field "sender_role" e._meta)
108+
|| e._meta.sender_role != "supervised"
109+
),
110+
111+
# If declared risk_tier differs from the op_kind's default, a
112+
# non-empty tier_override_reason MUST be present. Catches tier
113+
# underclaiming ("I said it's Tier 1 but it's a git push").
114+
TierOverrideJustification = std.contract.from_predicate (fun e =>
115+
!(std.record.has_field "op_kind" e)
116+
|| !(std.record.has_field "risk_tier" e)
117+
|| (
118+
let default_tier =
119+
if std.record.has_field e.op_kind OpDefaults then
120+
std.record.get e.op_kind OpDefaults
121+
else 0 in
122+
e.risk_tier == default_tier
123+
|| (std.record.has_field "tier_override_reason" e
124+
&& std.is_string e.tier_override_reason
125+
&& std.string.length e.tier_override_reason > 0)
126+
)
127+
),
128+
129+
# ── Combined validator ─────────────────────────────────────────
130+
#
131+
# Lazy function that applies all four dependent-constraint contracts
132+
# in sequence. Shape validation stays in the JSON Schema sibling
133+
# (coord-messages.ncl / .json) — this validator runs AFTER shape has
134+
# been confirmed by a JSON Schema validator.
135+
#
136+
# Usage from the Deno shim (Task #17):
137+
#
138+
# const contracts = await loadNickel("coord-messages-contracts.ncl");
139+
# // First: JSON Schema validates shape.
140+
# // Then: this validator applies dependent constraints.
141+
# const result = applyNickelContract(contracts.validate, envelope);
142+
#
143+
# Wrapped in a function so Nickel doesn't eagerly try to realise it
144+
# when the file is imported — evaluation happens when validate is
145+
# called on actual envelope data.
146+
147+
validate = fun envelope =>
148+
envelope
149+
| TierContextGate
150+
| TierAttestationGate
151+
| UrgentDirectRestriction
152+
| TierOverrideJustification,
153+
}

cartridges/local-coord-mcp/schemas/coord-messages.ncl

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,4 +371,12 @@
371371
{ pattern = "SET secret", min_tier = 3, reason = "Secret modification" },
372372
{ pattern = "--no-verify", min_tier = 3, reason = "Hook bypass" },
373373
],
374+
375+
# Note on validation layers:
376+
# - This file (JSON-Schema-style) is the PORTABLE interface. Exports
377+
# to coord-messages.json for any JSON Schema validator.
378+
# - Rich dependent-constraint validation lives in sibling file
379+
# schemas/coord-messages-contracts.ncl — use that for server-side
380+
# checks via the Deno shim (Task #17). It expresses rules that JSON
381+
# Schema can't cleanly say (Tier 2+ requires context_fetch_id, etc.)
374382
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: PMPL-1.0-or-later
3+
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
4+
#
5+
# test-contracts.sh — Smoke-test the Nickel contracts in
6+
# coord-messages-contracts.ncl. Each case either passes (envelope
7+
# accepted by validator) or fails (validator rejects with a specific
8+
# contract violation).
9+
#
10+
# Usage: bash test-contracts.sh
11+
12+
set -u
13+
cd "$(dirname "$0")"
14+
15+
PASS=0
16+
FAIL=0
17+
EXPECTED_FAILS=0
18+
19+
# Run Nickel with an inline envelope piped through the validator.
20+
# Exit 0 = accepted; non-zero = contract violation.
21+
run_case() {
22+
local name="$1"
23+
local envelope="$2"
24+
local expect="$3" # "pass" or "fail"
25+
26+
# Create temp file in schemas/ so relative import works.
27+
local out
28+
local tmp="./_test_case_$$.ncl"
29+
cat > "$tmp" <<NICKEL_EOF
30+
let c = import "coord-messages-contracts.ncl" in
31+
let e = $envelope in
32+
c.validate e
33+
NICKEL_EOF
34+
out=$(nickel eval "$tmp" 2>&1)
35+
local rc=$?
36+
rm -f "$tmp"
37+
38+
if [ "$expect" = "pass" ]; then
39+
if [ $rc -eq 0 ]; then
40+
echo " PASS: $name"
41+
PASS=$((PASS + 1))
42+
else
43+
echo " FAIL: $name (expected accept, got reject)"
44+
echo " $out" | head -3
45+
FAIL=$((FAIL + 1))
46+
fi
47+
else
48+
if [ $rc -ne 0 ]; then
49+
echo " PASS: $name (expected reject — good)"
50+
EXPECTED_FAILS=$((EXPECTED_FAILS + 1))
51+
else
52+
echo " FAIL: $name (expected reject, got accept)"
53+
FAIL=$((FAIL + 1))
54+
fi
55+
fi
56+
}
57+
58+
echo "=== Nickel contract tests ==="
59+
60+
# ── Valid cases ────────────────────────────────────────────────
61+
62+
run_case "tier 0 status envelope" \
63+
'{
64+
version = 1,
65+
msg_id = "abcdef012345",
66+
prev_msg_hash = "0000000000000000000000000000000000000000000000000000000000000000",
67+
sender = "claude-7f3a",
68+
recipient = "gemini-b2c1",
69+
timestamp = "2026-04-20T10:00:00Z",
70+
op_kind = "status",
71+
risk_tier = 0,
72+
payload = { status = "ok" },
73+
}' \
74+
"pass"
75+
76+
run_case "tier 2 claim with context_fetch_id" \
77+
'{
78+
version = 1,
79+
msg_id = "abcdef012346",
80+
prev_msg_hash = "0000000000000000000000000000000000000000000000000000000000000000",
81+
sender = "claude-7f3a",
82+
recipient = "*",
83+
timestamp = "2026-04-20T10:00:00Z",
84+
op_kind = "claim",
85+
risk_tier = 2,
86+
payload = { task = "audit-X", scope = "repo" },
87+
context_fetch_id = "ctx-abc123",
88+
}' \
89+
"pass"
90+
91+
# ── Rejections ─────────────────────────────────────────────────
92+
93+
run_case "tier 2 claim WITHOUT context_fetch_id (TierContextGate)" \
94+
'{
95+
version = 1,
96+
msg_id = "abcdef012347",
97+
prev_msg_hash = "0000000000000000000000000000000000000000000000000000000000000000",
98+
sender = "claude-7f3a",
99+
recipient = "*",
100+
timestamp = "2026-04-20T10:00:00Z",
101+
op_kind = "claim",
102+
risk_tier = 2,
103+
payload = { task = "audit-X", scope = "repo" },
104+
}' \
105+
"fail"
106+
107+
run_case "tier 3 from supervised WITHOUT attestation (TierAttestationGate)" \
108+
'{
109+
version = 1,
110+
msg_id = "abcdef012348",
111+
prev_msg_hash = "0000000000000000000000000000000000000000000000000000000000000000",
112+
sender = "gemini-b2c1",
113+
recipient = "*",
114+
timestamp = "2026-04-20T10:00:00Z",
115+
op_kind = "gated_op",
116+
risk_tier = 3,
117+
payload = { action = "push" },
118+
context_fetch_id = "ctx-abc123",
119+
_meta = { sender_role = "supervised" },
120+
}' \
121+
"fail"
122+
123+
run_case "urgent_direct from supervised (UrgentDirectRestriction)" \
124+
'{
125+
version = 1,
126+
msg_id = "abcdef012349",
127+
prev_msg_hash = "0000000000000000000000000000000000000000000000000000000000000000",
128+
sender = "gemini-b2c1",
129+
recipient = "claude-7f3a",
130+
timestamp = "2026-04-20T10:00:00Z",
131+
op_kind = "clarify",
132+
risk_tier = 0,
133+
payload = { question = "hey" },
134+
urgent_direct = true,
135+
_meta = { sender_role = "supervised" },
136+
}' \
137+
"fail"
138+
139+
run_case "status declared as tier 3 WITHOUT tier_override_reason (TierOverrideJustification)" \
140+
'{
141+
version = 1,
142+
msg_id = "abcdef01234a",
143+
prev_msg_hash = "0000000000000000000000000000000000000000000000000000000000000000",
144+
sender = "claude-7f3a",
145+
recipient = "gemini-b2c1",
146+
timestamp = "2026-04-20T10:00:00Z",
147+
op_kind = "status",
148+
risk_tier = 3,
149+
payload = { status = "ok" },
150+
context_fetch_id = "ctx-abc",
151+
}' \
152+
"fail"
153+
154+
echo
155+
echo "=== Summary ==="
156+
echo "Accepted (pass expected): $PASS"
157+
echo "Rejected (fail expected): $EXPECTED_FAILS"
158+
echo "Unexpected: $FAIL"
159+
160+
if [ $FAIL -eq 0 ]; then
161+
exit 0
162+
else
163+
exit 1
164+
fi

0 commit comments

Comments
 (0)