Mandaton

Documentation

Quickstart

One call decides, and returns the receipt. Everything else is administration.

1. Store a policy

Policies are YAML and immutable per version: posting the same name again creates version 2, and the version that applied stays readable for every receipt that referenced it.

cat > policy.yaml <<'YAML'
policy: default
rules:
  - id: purchase
    when:
      intent: purchase
    require:
      agent_verified: true
      max_amount: "500.00 EUR"
    decision: allow
    otherwise: review
default: deny
YAML

curl -X POST https://api.mandaton.eu/v1/policies \
  -H "Authorization: Bearer $MANDATON_KEY" \
  -H "Content-Type: text/plain" \
  --data-binary @policy.yaml

JSON works too, with the YAML in a source field — which is what the dashboard's policy editor posts.

2. Ask for a decision

Pass the incoming request as you received it — method, URL and headers. The signature headers are what get verified, so they must arrive unmodified.

curl -X POST https://api.mandaton.eu/v1/verify \
  -H "Authorization: Bearer $MANDATON_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "request": {
      "method": "POST",
      "url": "https://shop.example.eu/api/orders",
      "headers": {
        "signature": "sig1=:…:",
        "signature-input": "sig1=(\"@method\" \"@authority\" \"@path\");created=…;keyid=\"…\";alg=\"ed25519\";tag=\"web-bot-auth\"",
        "signature-agent": "\"https://agents.example\""
      }
    },
    "policy": "default",
    "context": { "intent": "purchase", "amount": "129.00 EUR", "resource": "sku:1234" }
  }'
{
  "decision": "allow",
  "agent": { "verified": true, "method": "web-bot-auth",
             "key_id": "poqkLGiUqyqJZmZ…", "operator": "agents.example",
             "alg": "ed25519" },
  "policy": { "name": "default", "version": 1, "matched_rule": "purchase" },
  "reasons": ["agent signature verified", "amount within per-transaction limit"],
  "receipt": { "id": "rcpt_…", "hash": "9f2b…", "seal_status": "pending",
               "log_index": 41, "verify_url": "https://r.mandaton.eu/r/rcpt_…" },
  "latency_ms": 8
}

Act on decision: serve the request, route it to a human, or refuse. Keep receipt.verify_url — that link is what a counterparty checks later.

3. Register the agent keys you trust

curl -X POST https://api.mandaton.eu/v1/agents \
  -H "Authorization: Bearer $MANDATON_KEY" \
  -H "Content-Type: application/json" \
  -d '{"key_id": "poqkLGiUqyqJZmZ…", "operator": "agents.example",
       "jwk": { "kty": "OKP", "crv": "Ed25519", "x": "…" }}'

The key_id is the JWK thumbprint (RFC 8037/8785 style), which is what the signing agent puts in signature-input.

4. Verify a receipt without an account

GET /r/:id is public and unauthenticated: a receipt only its issuer can check is not evidence to anybody else. In a browser it renders as a page; with Accept: application/json or ?format=json it returns the proof material.

curl https://r.mandaton.eu/r/rcpt_… | jq '{hash_matches, log}'

You get the receipt body, the hash we stored, the hash recomputed from the body, the inclusion path into the Merkle tree, and the signed tree head. Recompute all three yourself: canonicalise the body per RFC 8785, SHA-256 it, and walk the inclusion path per RFC 6962.

Routes

RouteAuthPurpose
POST /v1/verifybearerthe decision; returns receipt and verify_url
GET /v1/receiptsbeareryour own receipts
GET /v1/receipts/:idbearerone receipt with its body
GET /r/:idnonepublic verification: hash, inclusion proof, tree head
GET /v1/log/headnonecurrent signed tree head
POST /v1/policiesbearerstore a policy version
GET /v1/policiesbearerlist policies
POST /v1/agentsbearerregister an agent key
GET /healthnoneliveness

Latency and failure behaviour

Median 8 ms in production, against a p99 budget of 50 ms. Sealing and tree-head signing happen off the request path. If the decision service is unreachable, the call that fails is ours — decide in your own code whether that means fail-open or fail-closed, and write that choice down before you need it.

Open the dashboard