1 · Matrix

Status is the contract switch.

A client can branch on the HTTP status before decoding the body. The extra columns below show which credential is required, which proof row should exist, and whether the upstream tool was allowed to run.

Path Auth Status Body contract Side effect Proof row
Green allow mTLS SVID or lite bearer 200 OK Upstream JSON-RPC result passes through, plus response headers. Tool executes. proxy.verdict and upstream.outcome
Red deny mTLS SVID or lite bearer 403 Forbidden {error:"policy_denied", intent_category, reasoning, correlation_id} Tool never executes. proxy.verdict with authorized=false
Yellow review mTLS SVID or lite bearer 202 Accepted {status:"pending", correlation_id, ttl_secs, review_reasons} Tool is parked until an operator decides. PendingReview, then PendingApproved or PendingDenied
Replay proof Audit/console read auth 200 OK Correlation trace or chain verification result. No new tool side effect. Reads canonical hash-chain rows.
2 · Request

POST /mcp is JSON-RPC 2.0 over HTTPS.

params.name is the tool type evaluated by policy. method rides through into the ledger row. Unknown extra fields are preserved for upstream compatibility, so richer MCP clients can keep their native envelope.

// POST /mcp - JSON-RPC body
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "call_tool",
  "params": {
    "name": "wire_transfer",
    "arguments": {
      "to": "acct-1",
      "amount": 100
    }
  }
}
Client rule

Send Content-Type: application/json, preserve the JSON-RPC id, and treat the response status as the verdict.

Lite auth

Use Authorization: Bearer <token> when lite is configured with an agent token.

Full auth

Use the enrolled workload SVID on the mTLS data-plane path; operator decisions stay on a separate credential.

3 · Verdicts

Three statuses. Three client branches.

The 403 and 202 examples below match the published API schemas. A 200 is intentionally boring: Clavenar lets the upstream JSON-RPC response pass through and adds the correlation headers.

200 OK Green

Allowed. The upstream result is returned to the agent. Capture X-Clavenar-Correlation-Id if you need to replay the proof trail later.

// 200 response
HTTP/1.1 200 OK
content-type: application/json
x-clavenar-correlation-id: 8f1d3a40-7c0e-4d2b-9c9a-3b1d0e6a2c5a
x-clavenar-mode: enforce

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": { "content": [{ "type": "text", "text": "sent" }] }
}
403 Forbidden Red

Denied. Raise a typed denial in the SDK and point operators at the correlation id.

// 403 body
{
  "error": "policy_denied",
  "intent_category": "Privileged Action",
  "reasoning": "wire_transfer over threshold requires approval",
  "correlation_id": "8f1d3a40-7c0e-4d2b-9c9a-3b1d0e6a2c5a"
}
202 Accepted Yellow - parked

Parked for human review. Return control to the caller, persist the id, and poll /pending/<id>.

// 202 body
{
  "status": "pending",
  "correlation_id": "8f1d3a40-7c0e-4d2b-9c9a-3b1d0e6a2c5a",
  "ttl_secs": 900,
  "review_reasons": [
    "Review: Wire transfers require human approval before execution."
  ]
}
4 · Copy, run, verify

Exercise the contract from a laptop.

These examples target clavenar-lite on localhost. The full edition uses the same request and verdict shapes; swap bearer auth for the enrolled mTLS client certificate.

Copy

Park a reviewable tool call.

export CLAVENAR_LITE_URL=http://localhost:8088
export CLAVENAR_LITE_TOKEN=agent-token

curl -sS -i "$CLAVENAR_LITE_URL/mcp" \
  -H "Authorization: Bearer $CLAVENAR_LITE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc":"2.0",
    "id":1,
    "method":"call_tool",
    "params":{"name":"wire_transfer","arguments":{"to":"acct-1","amount":100}}
  }'
Run

Poll and decide with the operator token.

export CID=8f1d3a40-7c0e-4d2b-9c9a-3b1d0e6a2c5a
export CLAVENAR_LITE_DECIDE_TOKEN=op-token

curl -sS "$CLAVENAR_LITE_URL/pending/$CID" \
  -H "Authorization: Bearer $CLAVENAR_LITE_TOKEN"

curl -sS -X POST "$CLAVENAR_LITE_URL/pending/$CID/decide" \
  -H "Authorization: Bearer $CLAVENAR_LITE_DECIDE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"decision":"allow","note":"ok by sec"}'
Verify

Replay the proof trail.

curl -sS "$CLAVENAR_LITE_URL/audit/correlation/$CID" \
  -H "Authorization: Bearer $CLAVENAR_LITE_TOKEN"

curl -sS "$CLAVENAR_LITE_URL/verify" \
  -H "Authorization: Bearer $CLAVENAR_LITE_TOKEN"
# Expected signals
HTTP/1.1 202 Accepted
{"status":"pending","correlation_id":"8f1d...","ttl_secs":900,"review_reasons":[...]}

ok: pending 8f1d... decided allow
{"valid":true,"rows_checked":...}
5 · Human-in-the-loop

Yellow tier is a state machine, not a special error.

A 202 means policy allowed the shape of the request but required a human decision before the upstream side effect can happen. The pending row is outside HashableEntryV1; the ledger writes stay byte-compatible with the full verifier.

1 Park

POST /mcp returns 202 and writes PendingReview.

2 Poll

GET /pending/{id} returns decision:null until review closes.

3 Decide

POST /pending/{id}/decide requires --decide-token.

4 Replay

Correlation replay joins park, decision, and final upstream outcome.

5 Verify

/verify proves the hash chain still covers the decision rows.

  1. Park

    POST /mcp returns 202 with {status, correlation_id, ttl_secs, review_reasons}. SDK callers should treat this like an async work order: persist the id, return control, and poll.

  2. Poll

    GET /pending/<correlation_id> returns the current state. Auth reuses the agent token because this is the same identity that issued the /mcp call.

    // GET /pending/<id> - 200
    {
      "correlation_id": "8f1d3a40-7c0e-4d2b-9c9a-3b1d0e6a2c5a",
      "agent_id": "bearer-agent",
      "tool_type": "wire_transfer",
      "method": "call_tool",
      "review_reasons": ["Review: Wire transfers require human approval before execution."],
      "requested_at": "2026-05-12T10:14:03Z",
      "decided_at": null,
      "decision": null,
      "decider_note": null
    }
  3. Decide

    POST /pending/<correlation_id>/decide accepts {decision, note?}. A second decide returns 409 instead of silently overwriting the operator record. Auth uses --decide-token so an agent bearer cannot approve its own pending request.

    # Operator-side decision
    curl -sS -X POST "$CLAVENAR_LITE_URL/pending/$CID/decide" \
      -H "Authorization: Bearer $CLAVENAR_LITE_DECIDE_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"decision":"deny","note":"customer limit exceeded"}'
    
    # A second decide fails closed
    HTTP/1.1 409 Conflict
    {"error":"pending_already_decided","correlation_id":"8f1d..."}
6 · Headers

Every response carries a correlation handle.

X-Clavenar-Correlation-Id is a UUID v4 minted per request, returned on the response, and persisted to the ledger. Treat it as a forensic handle, not as a hash-chain primitive.

Header Set by Returned on Ledger use Client rule
Authorization Agent or operator Request only Never hash credentials. Use agent token for /mcp and poll; use decide token for decisions.
X-Clavenar-Correlation-Id Proxy All verdicts and errors Stored as a pivot field, not inside HashableEntryV1. Capture the response value; do not assume a caller-supplied value wins.
X-Clavenar-Mode Proxy Verdict responses Explains observe vs enforce in audit rows. In observe mode, show the warning but keep upstream behavior unchanged.
X-Clavenar-Callback-URL Client Request only Stored with pending review metadata when async callbacks are enabled. Use HTTPS and verify the operator decision payload before resuming work.
Content-Type Client/proxy Request and response No proof semantics. Send and expect application/json for all JSON-RPC and HIL calls.
// Response headers - any verdict, any error
HTTP/1.1 403 Forbidden
content-type: application/json
x-clavenar-correlation-id: 8f1d3a40-7c0e-4d2b-9c9a-3b1d0e6a2c5a
x-clavenar-mode: enforce

Because the correlation id is not part of HashableEntryV1, lite and full can verify the same canonical hash-chain rows while still giving operators a fast lookup handle.

8 · Notifications

Slack alert payload.

Pass --slack-webhook-url or CLAVENAR_LITE_SLACK_WEBHOOK_URL to fire a one-way alert each time a tool call parks. Slow or unreachable webhooks never block the agent's 202.

# Slack webhook payload - text rendered in the channel
:warning: Clavenar parked a tool call for review

*Tool:* `wire_transfer`
*Agent:* `bearer-agent`
*Correlation ID:* `8f1d3a40-7c0e-4d2b-9c9a-3b1d0e6a2c5a`
*Reasons:*
  - Review: Wire transfers require human approval before execution.

Approve: `clavenar-lite pending decide 8f1d... --allow`
Deny:    `clavenar-lite pending decide 8f1d... --deny --note "..."`

The lite alert is one-way: operators decide with the CLI or curl. The full edition adds SSO-gated web approvals, Slack button flows, and Teams Adaptive Card rendering on top of the same pending/decision contract.