@ziffer-io/mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ export declare const GUIDE_SOURCE_PATH = "docs/onboarding/sdk.md";
2
+ export declare const GUIDE_MARKDOWN = "# Integrating the SDK\n\n**Who this is for:** the engineer who owns the code that runs your agent's tool calls. You\nshould be comfortable adding a dependency and reading an environment variable. You do not need\nto know anything about the protocol, and nothing here asks you to move a credential.\n\n**What you end up with:** the tool handler you already have, plus about ten lines. Before it\nacts, it asks ZIFFER; it checks the answer's signature in your own process; then your existing\nexecution line runs unchanged. ZIFFER never holds the credential the action is performed with,\nand never sees it.\n\n**Read section 6 before you plan around any of this.** It is placed before the code on purpose.\nThe positive claims here are narrow, the scope that is missing is wide, and a reader who takes\n\"the SDK verifies the receipt\" to mean \"the SDK stops the action\" will have taken the opposite\nof what ships. The SDK verifies. **Your `if` stops the action.**\n\n---\n\n<!-- guide:common -->\n\n## 1. The idea in one paragraph\n\nEverything ZIFFER does ends in one artifact: a **Decision Receipt**, signed by our KMS, saying\nthat a specific proposed action was allowed. Your handler proposes an action, waits for the\ndecision, and then does the one thing that is the whole of it: **checks the receipt's signature\nitself, in its own process, against a public key you configured out of band.** If the check passes, the action was\nallowed by the policy your organisation signed. If it fails, you get a named refusal and you do\nnot act.\n\nThe consequence worth stating: **the gate is the receipt, not the network.** A receipt that\narrives over a perfect TLS connection is checked exactly as hard as one that does not. Nothing\nabout who sent it, or over which link, weakens a single step. You are not trusting our API to\ntell you the truth; you are checking a signature we cannot forge without the key we hold, over\nbytes you recomputed yourself.\n\n## 2. Two rungs, and the second one is the default\n\n**Rung 1: the raw HTTP API.** Nothing installed. You `POST /v1/proposals`, you poll\n`GET /v1/decisions/{id}`, you keep the receipt for your audit trail. What you are trusting is\nTLS: that the host answering is ours, and that the `outcome` field it sent is true. That is a\nreal, defensible integration, and for a read-only or low-stakes tool it may be all you want.\n\n**Rung 2: the SDK. This is the default, and the difference is one line.** The SDK does\neverything rung 1 does and then verifies the receipt cryptographically inside your process. The\nline that makes the difference is the `verify` call. It is worth being exact about what it buys,\nbecause \"we use the SDK\" is otherwise a sentence with no content:\n\n| what rung 1 trusts | what rung 2 checks instead |\n| --- | --- |\n| the `outcome` field is what our engine decided | the receipt's signature verifies under **your** configured key, every primitive, never any |\n| this receipt is about the action you proposed | the proposal hash is **recomputed from your own bytes** and compared to the receipt's |\n| the receipt is current | its validity window is checked against your clock |\n| the receipt is one we issued | a receipt of the wrong version, suite or decision domain is refused by name |\n\nThe third column that does not exist is \"and then it refuses to run your action\". There isn't\none. `verify` returns or it raises, and the branch is yours to write. Section 6 says why that\nboundary is where it is.\n\nRung 3 is a separate Executor process and is described in the *Running the Executor* guide. It\nis not the entry point and you should not start there.\n\n## 3. What you need before you start\n\nThree things, and only the first comes from us.\n\n1. **An API key.** Format `zfr_` followed by 43 characters. **The key determines your tenant.**\n You never put a tenant name in a request, and if a proposal body carries a `tenant_id` that\n is not the key's, the request is refused with `TenantMismatch` rather than quietly rewritten.\n That refusal exists because the proposal is signed material further down: a gateway that\n edited your body would have become its author.\n\n **The key expires: 90 days by default, and never more than a year.** Ask for a different\n lifetime when you ask for the key. An expired key gets exactly the answer a revoked one gets,\n `401 {\"error\":\"ApiKeyUnknown\"}`, byte for byte: telling the two apart in a response would tell\n anyone guessing keys which strings were once credentials.\n\n **The API tells you when the key ends, on every successful call.** Each 2xx answer, the POST\n and the GET, carries `X-Ziffer-Api-Key-Expires: <RFC 3339, UTC>` for the key that\n authenticated it. Both SDKs read it: for the last fourteen days, `ziffer.Client` and\n `ZifferClient` warn **once per process** on the language's standard channel\n (`warnings.warn` with the `ziffer.ApiKeyExpiring` category; `console.warn`), naming the date\n and the rotation. The header is never on a refusal, so an expired key still answers exactly\n like an unknown one, which means the warning can only reach a process that is still making\n successful calls. Route it somewhere a person reads (`-W error::ziffer.ApiKeyExpiring` turns\n it into a failing CI job), and keep the date in your renewals list as well.\n\n **Rotate with overlap: get the next key, deploy it, then have the old one revoked.** Both keys\n are live at once, on purpose, and nothing revokes the old one for you. Ask us for a successor\n (we mint it carrying the same tenant and label, read from our own records rather than retyped);\n put it in `ZIFFER_API_KEY` wherever your service runs; confirm your traffic is flowing under\n it; then tell us to revoke the old one. Doing it the other way round, revoke and then deploy,\n is an outage as long as your deploy takes, which is why the tooling on our side refuses to\n rotate and revoke in one step.\n\n **What a stolen key buys, stated plainly.** It is a bearer token: possession is the whole of\n the proof, it replays, and it sits in an environment variable. Someone holding it can submit\n proposals as your tenant and read the decisions and receipts for your tenant, and nothing\n else. It cannot change policy, release a held action, acknowledge a notice, read another\n tenant's anything, or mint another credential. It **buys no execution at all**: the credential\n that actually does things is yours and never leaves your infrastructure, so what an attacker\n gets is a graded decision and a receipt for a proposal they wrote themselves. That lasts until\n the key expires or you have it revoked, which is why the lifetime is 90 days and not a year.\n\n2. **Your trust anchor file.** This is the public half of the identity that signs your receipts,\n written by `acp-bundle pubkey --key <keyfile> --out anchor.json`. It is a small JSON document\n with `ed25519_pk_hex` and `mldsa65_pk_hex` in it. Put it on the filesystem your service reads\n and point `ZIFFER_TRUST_ANCHOR` at it.\n\n **Get this file out of band, not over the API you are verifying.** An anchor fetched from the\n service it is used to check is not an anchor; it is that service asserting its own identity,\n which is the one thing the signature was supposed to establish independently.\n\n3. **Your suite floor**, in `ZIFFER_SUITE_FLOOR`: the weakest signature suite you will accept.\n It is separate from the anchor file and it has no default. A suite name is a property of a\n signature, not of a key, so it is not in the key document; and a default here would be a\n minimum cryptographic strength that this library chose for you.\n\n **Omit it and the SDK refuses to build an anchor at all.** In Python,\n `TrustAnchor(pub)` and `TrustAnchor.from_file(path)` with no `min_suite` raise `AnchorError`\n naming `MinSuiteRequired`; in TypeScript, `minSuite` is a required field of the `TrustAnchor`\n interface and the compiler refuses the object. The three names it accepts are `ed25519`,\n `hybrid-ed25519-mldsa65` and `slhdsa128s`. The last of those is declared, not implemented, so\n a floor naming it refuses every receipt today rather than accepting a lattice signature in its\n place. *Until 2026-09-05 the Python SDK filled an absent floor from the reference bundle's own\n field default while this paragraph said there was none; the paragraph was the half that was\n right.*\n\n## 4. The shape of the change\n\nHere is a tool handler that transfers money. It has its own credentials, it knows how to do its\njob, and it is not going to stop doing it.\n\n```text\ndef transfer(amount, to_account):\n bank.transfer(amount, to_account) # your line, your credential\n```\n\nRung 2 wraps that line and changes nothing else:\n\n```text\ndef transfer(amount, to_account):\n proposal = {...} # 1 describe the action\n decision = ziffer.propose(proposal) # 2 ask\n decision = ziffer.wait(decision.id) # 3 wait for a verdict\n if decision.outcome != \"ALLOW\": # 4 refused? stop.\n raise Refused(decision.clause)\n verify(decision.receipt, # 5 check the signature\n json_bytes(proposal), anchor) # -- raises if it does not hold\n bank.transfer(amount, to_account) # 6 your line, unchanged\n```\n\nSix lines around one. Two of them are the point:\n\n**Line 5 is the whole product.** Delete it and you have rung 1 wearing rung 2's dependency: you\nare back to believing the `outcome` field. Nothing warns you, no test fails, and the integration\nstill \"works\", which is exactly why it is called out here rather than left to be inferred.\n\n**The proposal is passed to `verify` a second time, and that is the point.** `verify` recomputes\nthe hash from the bytes *you* hand it and compares the receipt's field against it. Handing it the\nreceipt's own copy of the hash would prove nothing at all: a transmitted identifier is a name for\na binding, not evidence of one. So the client does not remember your proposal for you: you\npresent it again, and the check is against your copy.\n\n**Key order and whitespace do not matter.** `verify` parses your bytes and canonicalises them\nitself (RFC 8785), because the hash is defined over the canonical encoding and not over whatever\nspacing your transport used. `json.dumps(proposal)`, `JSON.stringify(proposal)` and a pretty-printed\ncopy of the same object all produce the same hash. What must match is the **object**: one changed\nfield value is a `9.3-3` refusal, correctly.\n\n### The proposal, in full\n\nOne shape, whichever rung you are on, and the gateway validates it against a published schema\nbefore anything else happens. Every field is required, and the two objects at the bottom are\nempty rather than absent when an action has no parameters: an optional field would give one\naction two encodings and therefore two hashes.\n\n```json title=\"one proposal\"\n{\n \"schema_id\": \"transfer\",\n \"schema_version\": \"1.0.0\",\n \"schema_hash\": \"sha256:<the 64 hex characters of your registered schema>\",\n \"fidelity\": \"F-HIGH\",\n \"tenant_id\": \"<your tenant>\",\n \"payload\": {\n \"task_type\": \"transfer\",\n \"operator\": \"<the principal the action runs for>\",\n \"targets\": [\"bank-api\"],\n \"params\": { \"amount\": 4200, \"to_account\": \"48812\" },\n \"cidrs\": {}\n }\n}\n```\n\n`schema_id` is lower-case letters, digits, underscores and hyphens, at most 32 of them: a dot in\nit is refused. `schema_version` is three numbers with dots between them. `fidelity` is `F-HIGH`\nor `F-LOW`, it is restamped by the door from what the door itself verified, and your copy of it\ndecides nothing. `targets` are the names your policy's sensitivity table is keyed by, and a\ntarget you never listed is graded at the highest tier rather than the lowest. Each value in\n`params` is an integer or a string, and nothing else.\n\n**A body that misses any of that is refused as `ProposalMalformed` before your tenant, your\npolicy or your action is looked at.** That refusal is the schema disagreeing with your bytes; it\nis not a verdict on the action.\n\n**Line 3 can return before the receipt exists, and a high-risk action is when it does.** An action\nyour policy grades at or above its HIGH floor is not signed on the strength of the grading alone:\nit needs a quorum of approvers first. So the API answers immediately, and the answer is\n\n```text\nstatus: \"decided\" outcome: \"ATTEST\" receipt: absent\n```\n\n`ATTEST` **is the gate, not a refusal.** It says the decision reached the attestation stage and\napprovers are being presented with it. Do not treat it as a `DENY` and do not re-propose: the\nproposal is already in flight, and a second one is a second action. The receipt is signed once the\nquorum forms and attaches to the **same** decision on a later `GET /v1/decisions/{id}`.\n\n**`wait` returns at the decision; the receipt is a further poll.** `wait` stops when `status`\nreaches `decided`, and on this path `status` is *already* `decided` in the answer to your `POST`.\nSo it returns at once, carrying `ATTEST` and no receipt. Poll `GET /v1/decisions/{id}` yourself\nuntil `receipt` is present, then run line 5. There is no helper for that in the SDK today, and\n`wait` is not it. Watch the **receipt**, not the outcome: `outcome` stays `ATTEST` on that\ndecision, and the receipt appearing is the change.\n\nMeasured rather than described, and measured **on a laptop**: on **2026-09-03** a run of\na rehearsal drove the twelve services in a local `docker compose` stack on one machine, from a\nreal sandbox tenant's deployed configuration tree, and got\n`decided`/`ATTEST`/no receipt from the `POST` with the receipt attached to the next `GET`\n**within a second**, recorded in a dated transcript we keep.\n*A deployed control plane is still untried. This sentence said \"against a live sandbox\ndeployment\" until 2026-09-05, which was a second and incompatible description of one run.*\nIt is a fact about that stack on that day; your quorum is formed by whoever your\nbundle enrols, and people are slower than robots.\n\n**On a deployment with no notification account, the quorum path does not complete the way this\nsection describes**, and a sandbox is such a deployment today. The *Running a sandbox tenant*\nguide states exactly what happens and what the receipt you can read does and does not prove.\n\n## 5. What `verify` refuses, and what a refusal means\n\nEvery refusal carries a **clause id**. That id is the machine-readable half; the message beside it\nis for a human and is not part of the contract. **Both SDKs and the Rust engine spell every id in\nthe table below identically**, and that is asserted rather than asserted-about: one shared\ncorpus of vectors is run by the Python test suite and by the TypeScript one, over the same\ninputs. Where an implementation of this checklist still differs is\nstated under the table rather than left for you to discover.\n\n| the receipt is refused when | clause |\n| --- | --- |\n| it is not a version-3 receipt | `AB-0` |\n| its signed body is over the size cap | `AB-6` |\n| its suite is not one this build knows, or it declares none | `CR-1` |\n| your `ZIFFER_SUITE_FLOOR` is not a suite this build knows | `CR-1` |\n| its suite does not contain every primitive of your `ZIFFER_SUITE_FLOOR` | `CR-4` |\n| any signature primitive fails, the composition being conjunctive and never \"any\" | `9.3-1` |\n| its decision is not one this domain defines | `9.3-2` |\n| the bytes you passed are not UTF-8 JSON at all | `AT-8a` |\n| it is not bound to the proposal you passed | `9.3-3` |\n| your clock says it has expired, or its window is implausibly long | `9.3-5` / `L-14` |\n| its nonce is not a well-formed 128-bit value | `WE-4` / `L-17` |\n\n**Two places where an id is not spelled identically everywhere. Neither changes a verdict.**\nBoth are recorded in the corpus above, pinned from both sides so they cannot move without a test\ngoing red, and both are facts about engine pin `fed43d1` on **2026-09-09** rather than for ever.\n\n- **An unknown suite name.** The SDKs and the Rust engine say `CR-1`. The ACP *reference*\n implementation (the Python `acp_executor.py` the specification ships) says `CR-4`, because its\n floor test answers \"no\" for a name it does not recognise instead of refusing it as\n unrecognised. `CR-4` asks whether one suite contains another's primitives, and an unregistered\n name names no primitives at all, so the SDKs follow `CR-1`: \"an unregistered or unknown suite\n MUST fail closed\". Closing the gap is a change in four places in the engine, and it is open.\n- **A receipt wrong in more than one way.** Each verifier stops at the first check it reaches,\n and the two SDKs do not order the floor check and the signature-shape check the same way. A\n receipt that is *both* below your floor *and* carrying a signature whose primitives are not its\n declared suite's is `CR-4` from the Python SDK and `9.3-1` from the TypeScript one (the Rust\n engine agrees with TypeScript). Both refuse; only the name differs.\n\n**A refusal is not a network error, and you should not retry it.** A receipt that fails one of\nthese fails it deterministically; asking again gets the same answer with an extra round trip.\nTreat it as you would a failed authorisation: stop, and record the clause.\n\n**What `verify` does not check is as important as what it does.** It runs the *stateless* half\nof the checklist: the part answerable from a receipt, a proposal and a key. It does not and\ncannot check that this receipt has not already been used, because that is a claim against a\ndurable ledger and there isn't one in your process. Section 6.\n\n## 6. What this does **not** give you yet\n\nRead this section twice. Everything above is true and none of it adds up to \"the SDK stops your\nagent\".\n\n1. **`verify` returns; it does not intervene.** It is a function that raises or returns a value.\n If your handler calls it and ignores the result, the action runs. If your handler never calls\n it, the action runs. Nothing in this library sits between your agent and its credentials.\n That is rung 3's shape, and even there it is a *separate process* you route through, not a\n hook that catches you. The gate is a line of your code, and you have to write it.\n\n2. **No replay protection in-process.** The claims that make a receipt single-use are made\n against a ledger inside our deployment perimeter. Your process cannot reach it. So a receipt\n your handler verified once will verify again, and again: if an attacker can get your handler\n to run twice with the same receipt, `verify` will not be what stops them. Make your own\n execution idempotent, on your own key, the way you would without us.\n\n3. **The MCP server routes; it does not enforce.** The MCP server (section 9, not yet published\n to npm) lets a coding\n agent ask ZIFFER things and read this guide while it writes your integration. It runs on the\n developer's machine, at development time, and it has no hands on your production path.\n **MCP alone routes the question; only the SDK's `verify` line enforces the answer.** An\n agent that has talked to the MCP server has not thereby been gated by anything.\n\n4. **The trust anchor's freshness is yours to manage.** The SDK reads the key file you point it\n at and has no idea whether that key was rotated last week. There is no revocation channel and\n no expiry on the anchor itself. Rotation today is: we tell you, you replace the file.\n\n5. **Nothing here is an audit trail.** Keeping the receipt is your side of it. The SDK does not\n store, forward or log receipts, and a verified receipt you threw away is a check you can no\n longer show anyone.\n\n6. **A sandbox is a separate tenant, not a dry run.** If you were given sandbox credentials, they\n are a *different* API key and a *different* trust anchor, because a sandbox tenant signs its\n receipts under its own identity, which is exactly what makes a sandbox receipt fail your\n production `verify` (clause `9.3-1`) rather than needing a flag somebody could forget. Its\n decisions run the real path and are approved by a robot with no human in it, so an `ALLOW`\n there means the path worked, never that anyone agreed. And it changes nothing about your code:\n your handler still performs the action, so a sandbox `ALLOW` handed to a handler that transfers\n money transfers money. The *Running a sandbox tenant* guide states what the sandbox marker\n does and does not guarantee; the integration itself is the same in both, which is the point of\n having one.\n\n<!-- guide:/common -->\n\n<!-- guide:python -->\n\n## 7. Python\n\nInstall `ziffer`, published on PyPI at version 0.1.0, and read three environment variables.\n`wait` polls\n`GET /v1/decisions/{id}` until the status leaves `pending` or the timeout elapses.\n\n```python\nimport json\nimport os\n\nfrom ziffer import Client, RefusedError, TrustAnchor, verify\n\nclient = Client(\n base_url=os.environ[\"ZIFFER_API_URL\"],\n api_key=os.environ[\"ZIFFER_API_KEY\"], # the key IS the tenant\n)\nanchor = TrustAnchor.from_file(\n os.environ[\"ZIFFER_TRUST_ANCHOR\"], # acp-bundle pubkey output\n min_suite=os.environ[\"ZIFFER_SUITE_FLOOR\"],\n)\n\ndef transfer(amount: int, to_account: str) -> None:\n proposal = {\n \"schema_id\": \"transfer\",\n \"schema_version\": \"1.0.0\",\n \"schema_hash\": SCHEMA_HASH, # your schema's hash, fixed at build\n \"fidelity\": \"F-HIGH\",\n \"tenant_id\": TENANT_ID, # must equal the key's tenant\n \"payload\": {\n \"task_type\": \"transfer\",\n \"operator\": OPERATOR, # the principal the action runs for\n \"targets\": [\"bank-api\"],\n \"params\": {\"amount\": amount, \"to_account\": to_account},\n \"cidrs\": {},\n },\n }\n\n decision = client.wait(client.propose(proposal).decision_id, timeout=30.0)\n if decision.outcome != \"ALLOW\":\n raise PermissionError(f\"ziffer refused: {decision.clause}\")\n\n try:\n verify(decision.receipt, json.dumps(proposal).encode(), anchor)\n except RefusedError as refusal:\n # refusal.name is the clause id -- log it, do not retry it.\n raise PermissionError(f\"receipt refused: {refusal.name}\") from refusal\n\n bank.transfer(amount, to_account) # your line, unchanged\n```\n\n**`json.dumps` is fine here.** `verify` parses the bytes and canonicalises them itself, so key\norder and spacing are not your problem. The SDK does not export a canonicaliser and does not need\nto.\n\n**A `ValueError` from `verify` is not a refusal.** If the bytes are not JSON at all, `verify`\nraises `ValueError`, deliberately: a broken integration must not be mistaken for a caught attack.\nOnly `RefusedError` means the receipt did not hold.\n\n**Catch `RefusedError`, not `Exception`.** A refusal means the receipt did not hold. A\n`ConnectionError` from `propose` means you never got one. Collapsing the two into a single\n`except` turns \"our gateway was briefly unreachable\" into \"the policy denied this\", and you will\ndebug the wrong thing.\n\n<!-- guide:/python -->\n\n<!-- guide:typescript -->\n\n## 8. TypeScript\n\n**The three npm packages are not published yet.** `@ziffer-io/client`, `@ziffer-io/verify` and\n`@ziffer-io/mcp` are named here as they will be named, and the code below is the code you will\nwrite; today you receive them from us directly. The Python package is on PyPI; these are not.\n\n`@ziffer-io/client` re-exports the verifier, so there is one `verifyReceipt` in your\ndependency tree rather than two implementations that can disagree. `canon` comes from\n`@ziffer-io/verify`, its own home. The client re-exports the verification surface it needs you\nto have, not the whole of somebody else's package.\n\n```ts title=\"transfer.ts (the packages are not yet published)\"\nimport { ZifferClient, verifyReceipt, Refusal, type TrustAnchor } from '@ziffer-io/client';\nimport { canon } from '@ziffer-io/verify';\n\nfunction env(name: string): string {\n const value = process.env[name];\n if (value === undefined) throw new Error(`${name} is not set`);\n return value;\n}\n\nconst client = new ZifferClient(env('ZIFFER_API_URL'), env('ZIFFER_API_KEY'));\nconst anchor: TrustAnchor = await loadAnchor(env('ZIFFER_TRUST_ANCHOR'));\n\nasync function transfer(amount: number, toAccount: string): Promise<void> {\n const proposal = {\n schema_id: 'transfer',\n schema_version: '1.0.0',\n schema_hash: SCHEMA_HASH,\n fidelity: 'F-HIGH',\n tenant_id: TENANT_ID,\n payload: {\n task_type: 'transfer',\n operator: OPERATOR,\n targets: ['bank-api'],\n params: { amount, to_account: toAccount },\n cidrs: {},\n },\n };\n\n const submitted = await client.propose(proposal);\n const decision = await client.wait(submitted.decision_id, { timeoutMs: 30_000 });\n if (decision.outcome !== 'ALLOW') {\n throw new Error(`ziffer refused: ${decision.clause}`);\n }\n\n try {\n verifyReceipt(decision.receipt, canon(proposal), anchor);\n } catch (error) {\n if (error instanceof Refusal) {\n // error.clause is the clause id -- log it, do not retry it.\n throw new Error(`receipt refused: ${error.clause}`);\n }\n throw error;\n }\n\n await bank.transfer(amount, toAccount); // your line, unchanged\n}\n```\n\n**`process.env` is `string | undefined`, so the three values go through `env`.** The client's\nconstructor takes strings; passing the environment straight in is a type error under the settings\nthis repository builds with, and an `as string` there would be a second definition of the wire\ntype. The helper throws at start, where a missing variable is a deployment mistake you can see.\n\n**`instanceof Refusal`, never a cast.** A `catch` binds `unknown`, and the honest reason to\nnarrow rather than assert is that this `catch` also sees the errors you did not plan for. An\n`as Refusal` here would read a `clause` off a `TypeError` and report a protocol refusal that\nnever happened.\n\n**`canon` here, `json.dumps` in the Python example, and both are correct.** `verifyReceipt` parses\nyour bytes and canonicalises them itself, so any JSON spelling of the same object gives the same\nhash. `canon` is used here only because `@ziffer-io/verify` exports it; `new TextEncoder().encode(\nJSON.stringify(proposal))` verifies identically. Bytes that are not UTF-8 JSON at all are refused\nunder `AT-8a`, which is a bug in your call rather than a verdict on the receipt.\n\n<!-- guide:/typescript -->\n\n---\n\n## 9. The MCP-assisted path\n\nIf you are writing this integration with a coding agent, you can give the agent direct access to\nthis guide and to a live decision loop:\n\n```bash title=\"the package is not yet published\"\nnpx @ziffer-io/mcp\n```\n\nThat starts a local MCP server over stdio with **five tools**, and no others: `get_integration_guide` (this\ndocument, by language), `propose` and `check_decision` (the live loop, against whatever\n`ZIFFER_API_URL` you configure), `explain_receipt` (hand it a receipt and the proposal bytes\nand it tells you `valid` or the named clause), and `sandbox_status` (whether the tenant id you\nare sending is a sandbox, and whether one decision is still waiting on its robot approver).\n\nIt is configured by the same environment variables as the SDK, and **it starts without them.**\nTools that need a value return a named error saying which variable to set, rather than the\nserver dying during startup where an agent cannot see why. `get_integration_guide` needs nothing\nat all, so an agent can read this document before any credential exists.\n\nTwo things it deliberately does not have. There is **no approve tool and no simulated approval**:\nan approval minted on a developer's laptop is a fake receipt factory, which is precisely what this\nproduct exists to prevent. `sandbox_status` is not one: a ZIFFER sandbox is a separate tenant\napproved by a service inside our deployment, under keys your machine does not hold, so that tool\nreports and approves nothing. And there are **no file-writing tools**: the server serves\nknowledge and ZIFFER-side calls; your coding agent edits your code.\n\nTo say it once more, because it is the sentence most easily lost: **MCP alone routes the\nquestion; only the SDK's `verify` line enforces the answer.** An integration written with the\nMCP server's help is gated by exactly the lines of `verify` that ended up in your handler.\n\n---\n\n## 10. Checklist\n\n- [ ] Your API key is in `ZIFFER_API_KEY` and is not in your source tree.\n- [ ] You know the date your key expires, and it is written down somewhere that will remind you.\n The SDK warns for the last fourteen days, once per process and only while calls still\n succeed, and an expired key then answers exactly like a key that never existed.\n- [ ] Your rotation is overlap-then-revoke: new key deployed and serving traffic BEFORE the old\n one is revoked.\n- [ ] No request body sets a `tenant_id` other than your key's. The key is the tenant.\n- [ ] `ZIFFER_TRUST_ANCHOR` points at an `acp-bundle pubkey` document you obtained out of band,\n not at a signing key and not at something the API served you.\n- [ ] `ZIFFER_SUITE_FLOOR` is set to a floor you chose.\n- [ ] Your handler calls `verify` **and branches on it**. Grep for the call; then grep for the\n `if`. Section 6, item 1.\n- [ ] Your execution is idempotent on your own key. Section 6, item 2.\n- [ ] You keep the receipt somewhere you can produce it later.\n- [ ] You have read section 6 and know which six things this does not do.\n- [ ] If you were given sandbox credentials, your production configuration still points at your\n production key and anchor. Section 6, item 6.\n- [ ] You know where to write when something is refused, and what to put in the report.\n The support guide has the addresses, the severities, and a row per refusal in section 5\n above saying what it means and who moves next.\n";
3
+ //# sourceMappingURL=guide-source.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guide-source.d.ts","sourceRoot":"","sources":["../../src/generated/guide-source.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,iBAAiB,2BAA2B,CAAC;AAC1D,eAAO,MAAM,cAAc,my7BAAmy7B,CAAC"}
@@ -0,0 +1,6 @@
1
+ // GENERATED by scripts/embed-guide.mjs from docs/onboarding/sdk.md. Do not edit.
2
+ // Gitignored and rewritten on every build: see that script for why the doc is
3
+ // embedded rather than read from disk at runtime.
4
+ export const GUIDE_SOURCE_PATH = 'docs/onboarding/sdk.md';
5
+ export const GUIDE_MARKDOWN = "# Integrating the SDK\n\n**Who this is for:** the engineer who owns the code that runs your agent's tool calls. You\nshould be comfortable adding a dependency and reading an environment variable. You do not need\nto know anything about the protocol, and nothing here asks you to move a credential.\n\n**What you end up with:** the tool handler you already have, plus about ten lines. Before it\nacts, it asks ZIFFER; it checks the answer's signature in your own process; then your existing\nexecution line runs unchanged. ZIFFER never holds the credential the action is performed with,\nand never sees it.\n\n**Read section 6 before you plan around any of this.** It is placed before the code on purpose.\nThe positive claims here are narrow, the scope that is missing is wide, and a reader who takes\n\"the SDK verifies the receipt\" to mean \"the SDK stops the action\" will have taken the opposite\nof what ships. The SDK verifies. **Your `if` stops the action.**\n\n---\n\n<!-- guide:common -->\n\n## 1. The idea in one paragraph\n\nEverything ZIFFER does ends in one artifact: a **Decision Receipt**, signed by our KMS, saying\nthat a specific proposed action was allowed. Your handler proposes an action, waits for the\ndecision, and then does the one thing that is the whole of it: **checks the receipt's signature\nitself, in its own process, against a public key you configured out of band.** If the check passes, the action was\nallowed by the policy your organisation signed. If it fails, you get a named refusal and you do\nnot act.\n\nThe consequence worth stating: **the gate is the receipt, not the network.** A receipt that\narrives over a perfect TLS connection is checked exactly as hard as one that does not. Nothing\nabout who sent it, or over which link, weakens a single step. You are not trusting our API to\ntell you the truth; you are checking a signature we cannot forge without the key we hold, over\nbytes you recomputed yourself.\n\n## 2. Two rungs, and the second one is the default\n\n**Rung 1: the raw HTTP API.** Nothing installed. You `POST /v1/proposals`, you poll\n`GET /v1/decisions/{id}`, you keep the receipt for your audit trail. What you are trusting is\nTLS: that the host answering is ours, and that the `outcome` field it sent is true. That is a\nreal, defensible integration, and for a read-only or low-stakes tool it may be all you want.\n\n**Rung 2: the SDK. This is the default, and the difference is one line.** The SDK does\neverything rung 1 does and then verifies the receipt cryptographically inside your process. The\nline that makes the difference is the `verify` call. It is worth being exact about what it buys,\nbecause \"we use the SDK\" is otherwise a sentence with no content:\n\n| what rung 1 trusts | what rung 2 checks instead |\n| --- | --- |\n| the `outcome` field is what our engine decided | the receipt's signature verifies under **your** configured key, every primitive, never any |\n| this receipt is about the action you proposed | the proposal hash is **recomputed from your own bytes** and compared to the receipt's |\n| the receipt is current | its validity window is checked against your clock |\n| the receipt is one we issued | a receipt of the wrong version, suite or decision domain is refused by name |\n\nThe third column that does not exist is \"and then it refuses to run your action\". There isn't\none. `verify` returns or it raises, and the branch is yours to write. Section 6 says why that\nboundary is where it is.\n\nRung 3 is a separate Executor process and is described in the *Running the Executor* guide. It\nis not the entry point and you should not start there.\n\n## 3. What you need before you start\n\nThree things, and only the first comes from us.\n\n1. **An API key.** Format `zfr_` followed by 43 characters. **The key determines your tenant.**\n You never put a tenant name in a request, and if a proposal body carries a `tenant_id` that\n is not the key's, the request is refused with `TenantMismatch` rather than quietly rewritten.\n That refusal exists because the proposal is signed material further down: a gateway that\n edited your body would have become its author.\n\n **The key expires: 90 days by default, and never more than a year.** Ask for a different\n lifetime when you ask for the key. An expired key gets exactly the answer a revoked one gets,\n `401 {\"error\":\"ApiKeyUnknown\"}`, byte for byte: telling the two apart in a response would tell\n anyone guessing keys which strings were once credentials.\n\n **The API tells you when the key ends, on every successful call.** Each 2xx answer, the POST\n and the GET, carries `X-Ziffer-Api-Key-Expires: <RFC 3339, UTC>` for the key that\n authenticated it. Both SDKs read it: for the last fourteen days, `ziffer.Client` and\n `ZifferClient` warn **once per process** on the language's standard channel\n (`warnings.warn` with the `ziffer.ApiKeyExpiring` category; `console.warn`), naming the date\n and the rotation. The header is never on a refusal, so an expired key still answers exactly\n like an unknown one, which means the warning can only reach a process that is still making\n successful calls. Route it somewhere a person reads (`-W error::ziffer.ApiKeyExpiring` turns\n it into a failing CI job), and keep the date in your renewals list as well.\n\n **Rotate with overlap: get the next key, deploy it, then have the old one revoked.** Both keys\n are live at once, on purpose, and nothing revokes the old one for you. Ask us for a successor\n (we mint it carrying the same tenant and label, read from our own records rather than retyped);\n put it in `ZIFFER_API_KEY` wherever your service runs; confirm your traffic is flowing under\n it; then tell us to revoke the old one. Doing it the other way round, revoke and then deploy,\n is an outage as long as your deploy takes, which is why the tooling on our side refuses to\n rotate and revoke in one step.\n\n **What a stolen key buys, stated plainly.** It is a bearer token: possession is the whole of\n the proof, it replays, and it sits in an environment variable. Someone holding it can submit\n proposals as your tenant and read the decisions and receipts for your tenant, and nothing\n else. It cannot change policy, release a held action, acknowledge a notice, read another\n tenant's anything, or mint another credential. It **buys no execution at all**: the credential\n that actually does things is yours and never leaves your infrastructure, so what an attacker\n gets is a graded decision and a receipt for a proposal they wrote themselves. That lasts until\n the key expires or you have it revoked, which is why the lifetime is 90 days and not a year.\n\n2. **Your trust anchor file.** This is the public half of the identity that signs your receipts,\n written by `acp-bundle pubkey --key <keyfile> --out anchor.json`. It is a small JSON document\n with `ed25519_pk_hex` and `mldsa65_pk_hex` in it. Put it on the filesystem your service reads\n and point `ZIFFER_TRUST_ANCHOR` at it.\n\n **Get this file out of band, not over the API you are verifying.** An anchor fetched from the\n service it is used to check is not an anchor; it is that service asserting its own identity,\n which is the one thing the signature was supposed to establish independently.\n\n3. **Your suite floor**, in `ZIFFER_SUITE_FLOOR`: the weakest signature suite you will accept.\n It is separate from the anchor file and it has no default. A suite name is a property of a\n signature, not of a key, so it is not in the key document; and a default here would be a\n minimum cryptographic strength that this library chose for you.\n\n **Omit it and the SDK refuses to build an anchor at all.** In Python,\n `TrustAnchor(pub)` and `TrustAnchor.from_file(path)` with no `min_suite` raise `AnchorError`\n naming `MinSuiteRequired`; in TypeScript, `minSuite` is a required field of the `TrustAnchor`\n interface and the compiler refuses the object. The three names it accepts are `ed25519`,\n `hybrid-ed25519-mldsa65` and `slhdsa128s`. The last of those is declared, not implemented, so\n a floor naming it refuses every receipt today rather than accepting a lattice signature in its\n place. *Until 2026-09-05 the Python SDK filled an absent floor from the reference bundle's own\n field default while this paragraph said there was none; the paragraph was the half that was\n right.*\n\n## 4. The shape of the change\n\nHere is a tool handler that transfers money. It has its own credentials, it knows how to do its\njob, and it is not going to stop doing it.\n\n```text\ndef transfer(amount, to_account):\n bank.transfer(amount, to_account) # your line, your credential\n```\n\nRung 2 wraps that line and changes nothing else:\n\n```text\ndef transfer(amount, to_account):\n proposal = {...} # 1 describe the action\n decision = ziffer.propose(proposal) # 2 ask\n decision = ziffer.wait(decision.id) # 3 wait for a verdict\n if decision.outcome != \"ALLOW\": # 4 refused? stop.\n raise Refused(decision.clause)\n verify(decision.receipt, # 5 check the signature\n json_bytes(proposal), anchor) # -- raises if it does not hold\n bank.transfer(amount, to_account) # 6 your line, unchanged\n```\n\nSix lines around one. Two of them are the point:\n\n**Line 5 is the whole product.** Delete it and you have rung 1 wearing rung 2's dependency: you\nare back to believing the `outcome` field. Nothing warns you, no test fails, and the integration\nstill \"works\", which is exactly why it is called out here rather than left to be inferred.\n\n**The proposal is passed to `verify` a second time, and that is the point.** `verify` recomputes\nthe hash from the bytes *you* hand it and compares the receipt's field against it. Handing it the\nreceipt's own copy of the hash would prove nothing at all: a transmitted identifier is a name for\na binding, not evidence of one. So the client does not remember your proposal for you: you\npresent it again, and the check is against your copy.\n\n**Key order and whitespace do not matter.** `verify` parses your bytes and canonicalises them\nitself (RFC 8785), because the hash is defined over the canonical encoding and not over whatever\nspacing your transport used. `json.dumps(proposal)`, `JSON.stringify(proposal)` and a pretty-printed\ncopy of the same object all produce the same hash. What must match is the **object**: one changed\nfield value is a `9.3-3` refusal, correctly.\n\n### The proposal, in full\n\nOne shape, whichever rung you are on, and the gateway validates it against a published schema\nbefore anything else happens. Every field is required, and the two objects at the bottom are\nempty rather than absent when an action has no parameters: an optional field would give one\naction two encodings and therefore two hashes.\n\n```json title=\"one proposal\"\n{\n \"schema_id\": \"transfer\",\n \"schema_version\": \"1.0.0\",\n \"schema_hash\": \"sha256:<the 64 hex characters of your registered schema>\",\n \"fidelity\": \"F-HIGH\",\n \"tenant_id\": \"<your tenant>\",\n \"payload\": {\n \"task_type\": \"transfer\",\n \"operator\": \"<the principal the action runs for>\",\n \"targets\": [\"bank-api\"],\n \"params\": { \"amount\": 4200, \"to_account\": \"48812\" },\n \"cidrs\": {}\n }\n}\n```\n\n`schema_id` is lower-case letters, digits, underscores and hyphens, at most 32 of them: a dot in\nit is refused. `schema_version` is three numbers with dots between them. `fidelity` is `F-HIGH`\nor `F-LOW`, it is restamped by the door from what the door itself verified, and your copy of it\ndecides nothing. `targets` are the names your policy's sensitivity table is keyed by, and a\ntarget you never listed is graded at the highest tier rather than the lowest. Each value in\n`params` is an integer or a string, and nothing else.\n\n**A body that misses any of that is refused as `ProposalMalformed` before your tenant, your\npolicy or your action is looked at.** That refusal is the schema disagreeing with your bytes; it\nis not a verdict on the action.\n\n**Line 3 can return before the receipt exists, and a high-risk action is when it does.** An action\nyour policy grades at or above its HIGH floor is not signed on the strength of the grading alone:\nit needs a quorum of approvers first. So the API answers immediately, and the answer is\n\n```text\nstatus: \"decided\" outcome: \"ATTEST\" receipt: absent\n```\n\n`ATTEST` **is the gate, not a refusal.** It says the decision reached the attestation stage and\napprovers are being presented with it. Do not treat it as a `DENY` and do not re-propose: the\nproposal is already in flight, and a second one is a second action. The receipt is signed once the\nquorum forms and attaches to the **same** decision on a later `GET /v1/decisions/{id}`.\n\n**`wait` returns at the decision; the receipt is a further poll.** `wait` stops when `status`\nreaches `decided`, and on this path `status` is *already* `decided` in the answer to your `POST`.\nSo it returns at once, carrying `ATTEST` and no receipt. Poll `GET /v1/decisions/{id}` yourself\nuntil `receipt` is present, then run line 5. There is no helper for that in the SDK today, and\n`wait` is not it. Watch the **receipt**, not the outcome: `outcome` stays `ATTEST` on that\ndecision, and the receipt appearing is the change.\n\nMeasured rather than described, and measured **on a laptop**: on **2026-09-03** a run of\na rehearsal drove the twelve services in a local `docker compose` stack on one machine, from a\nreal sandbox tenant's deployed configuration tree, and got\n`decided`/`ATTEST`/no receipt from the `POST` with the receipt attached to the next `GET`\n**within a second**, recorded in a dated transcript we keep.\n*A deployed control plane is still untried. This sentence said \"against a live sandbox\ndeployment\" until 2026-09-05, which was a second and incompatible description of one run.*\nIt is a fact about that stack on that day; your quorum is formed by whoever your\nbundle enrols, and people are slower than robots.\n\n**On a deployment with no notification account, the quorum path does not complete the way this\nsection describes**, and a sandbox is such a deployment today. The *Running a sandbox tenant*\nguide states exactly what happens and what the receipt you can read does and does not prove.\n\n## 5. What `verify` refuses, and what a refusal means\n\nEvery refusal carries a **clause id**. That id is the machine-readable half; the message beside it\nis for a human and is not part of the contract. **Both SDKs and the Rust engine spell every id in\nthe table below identically**, and that is asserted rather than asserted-about: one shared\ncorpus of vectors is run by the Python test suite and by the TypeScript one, over the same\ninputs. Where an implementation of this checklist still differs is\nstated under the table rather than left for you to discover.\n\n| the receipt is refused when | clause |\n| --- | --- |\n| it is not a version-3 receipt | `AB-0` |\n| its signed body is over the size cap | `AB-6` |\n| its suite is not one this build knows, or it declares none | `CR-1` |\n| your `ZIFFER_SUITE_FLOOR` is not a suite this build knows | `CR-1` |\n| its suite does not contain every primitive of your `ZIFFER_SUITE_FLOOR` | `CR-4` |\n| any signature primitive fails, the composition being conjunctive and never \"any\" | `9.3-1` |\n| its decision is not one this domain defines | `9.3-2` |\n| the bytes you passed are not UTF-8 JSON at all | `AT-8a` |\n| it is not bound to the proposal you passed | `9.3-3` |\n| your clock says it has expired, or its window is implausibly long | `9.3-5` / `L-14` |\n| its nonce is not a well-formed 128-bit value | `WE-4` / `L-17` |\n\n**Two places where an id is not spelled identically everywhere. Neither changes a verdict.**\nBoth are recorded in the corpus above, pinned from both sides so they cannot move without a test\ngoing red, and both are facts about engine pin `fed43d1` on **2026-09-09** rather than for ever.\n\n- **An unknown suite name.** The SDKs and the Rust engine say `CR-1`. The ACP *reference*\n implementation (the Python `acp_executor.py` the specification ships) says `CR-4`, because its\n floor test answers \"no\" for a name it does not recognise instead of refusing it as\n unrecognised. `CR-4` asks whether one suite contains another's primitives, and an unregistered\n name names no primitives at all, so the SDKs follow `CR-1`: \"an unregistered or unknown suite\n MUST fail closed\". Closing the gap is a change in four places in the engine, and it is open.\n- **A receipt wrong in more than one way.** Each verifier stops at the first check it reaches,\n and the two SDKs do not order the floor check and the signature-shape check the same way. A\n receipt that is *both* below your floor *and* carrying a signature whose primitives are not its\n declared suite's is `CR-4` from the Python SDK and `9.3-1` from the TypeScript one (the Rust\n engine agrees with TypeScript). Both refuse; only the name differs.\n\n**A refusal is not a network error, and you should not retry it.** A receipt that fails one of\nthese fails it deterministically; asking again gets the same answer with an extra round trip.\nTreat it as you would a failed authorisation: stop, and record the clause.\n\n**What `verify` does not check is as important as what it does.** It runs the *stateless* half\nof the checklist: the part answerable from a receipt, a proposal and a key. It does not and\ncannot check that this receipt has not already been used, because that is a claim against a\ndurable ledger and there isn't one in your process. Section 6.\n\n## 6. What this does **not** give you yet\n\nRead this section twice. Everything above is true and none of it adds up to \"the SDK stops your\nagent\".\n\n1. **`verify` returns; it does not intervene.** It is a function that raises or returns a value.\n If your handler calls it and ignores the result, the action runs. If your handler never calls\n it, the action runs. Nothing in this library sits between your agent and its credentials.\n That is rung 3's shape, and even there it is a *separate process* you route through, not a\n hook that catches you. The gate is a line of your code, and you have to write it.\n\n2. **No replay protection in-process.** The claims that make a receipt single-use are made\n against a ledger inside our deployment perimeter. Your process cannot reach it. So a receipt\n your handler verified once will verify again, and again: if an attacker can get your handler\n to run twice with the same receipt, `verify` will not be what stops them. Make your own\n execution idempotent, on your own key, the way you would without us.\n\n3. **The MCP server routes; it does not enforce.** The MCP server (section 9, not yet published\n to npm) lets a coding\n agent ask ZIFFER things and read this guide while it writes your integration. It runs on the\n developer's machine, at development time, and it has no hands on your production path.\n **MCP alone routes the question; only the SDK's `verify` line enforces the answer.** An\n agent that has talked to the MCP server has not thereby been gated by anything.\n\n4. **The trust anchor's freshness is yours to manage.** The SDK reads the key file you point it\n at and has no idea whether that key was rotated last week. There is no revocation channel and\n no expiry on the anchor itself. Rotation today is: we tell you, you replace the file.\n\n5. **Nothing here is an audit trail.** Keeping the receipt is your side of it. The SDK does not\n store, forward or log receipts, and a verified receipt you threw away is a check you can no\n longer show anyone.\n\n6. **A sandbox is a separate tenant, not a dry run.** If you were given sandbox credentials, they\n are a *different* API key and a *different* trust anchor, because a sandbox tenant signs its\n receipts under its own identity, which is exactly what makes a sandbox receipt fail your\n production `verify` (clause `9.3-1`) rather than needing a flag somebody could forget. Its\n decisions run the real path and are approved by a robot with no human in it, so an `ALLOW`\n there means the path worked, never that anyone agreed. And it changes nothing about your code:\n your handler still performs the action, so a sandbox `ALLOW` handed to a handler that transfers\n money transfers money. The *Running a sandbox tenant* guide states what the sandbox marker\n does and does not guarantee; the integration itself is the same in both, which is the point of\n having one.\n\n<!-- guide:/common -->\n\n<!-- guide:python -->\n\n## 7. Python\n\nInstall `ziffer`, published on PyPI at version 0.1.0, and read three environment variables.\n`wait` polls\n`GET /v1/decisions/{id}` until the status leaves `pending` or the timeout elapses.\n\n```python\nimport json\nimport os\n\nfrom ziffer import Client, RefusedError, TrustAnchor, verify\n\nclient = Client(\n base_url=os.environ[\"ZIFFER_API_URL\"],\n api_key=os.environ[\"ZIFFER_API_KEY\"], # the key IS the tenant\n)\nanchor = TrustAnchor.from_file(\n os.environ[\"ZIFFER_TRUST_ANCHOR\"], # acp-bundle pubkey output\n min_suite=os.environ[\"ZIFFER_SUITE_FLOOR\"],\n)\n\ndef transfer(amount: int, to_account: str) -> None:\n proposal = {\n \"schema_id\": \"transfer\",\n \"schema_version\": \"1.0.0\",\n \"schema_hash\": SCHEMA_HASH, # your schema's hash, fixed at build\n \"fidelity\": \"F-HIGH\",\n \"tenant_id\": TENANT_ID, # must equal the key's tenant\n \"payload\": {\n \"task_type\": \"transfer\",\n \"operator\": OPERATOR, # the principal the action runs for\n \"targets\": [\"bank-api\"],\n \"params\": {\"amount\": amount, \"to_account\": to_account},\n \"cidrs\": {},\n },\n }\n\n decision = client.wait(client.propose(proposal).decision_id, timeout=30.0)\n if decision.outcome != \"ALLOW\":\n raise PermissionError(f\"ziffer refused: {decision.clause}\")\n\n try:\n verify(decision.receipt, json.dumps(proposal).encode(), anchor)\n except RefusedError as refusal:\n # refusal.name is the clause id -- log it, do not retry it.\n raise PermissionError(f\"receipt refused: {refusal.name}\") from refusal\n\n bank.transfer(amount, to_account) # your line, unchanged\n```\n\n**`json.dumps` is fine here.** `verify` parses the bytes and canonicalises them itself, so key\norder and spacing are not your problem. The SDK does not export a canonicaliser and does not need\nto.\n\n**A `ValueError` from `verify` is not a refusal.** If the bytes are not JSON at all, `verify`\nraises `ValueError`, deliberately: a broken integration must not be mistaken for a caught attack.\nOnly `RefusedError` means the receipt did not hold.\n\n**Catch `RefusedError`, not `Exception`.** A refusal means the receipt did not hold. A\n`ConnectionError` from `propose` means you never got one. Collapsing the two into a single\n`except` turns \"our gateway was briefly unreachable\" into \"the policy denied this\", and you will\ndebug the wrong thing.\n\n<!-- guide:/python -->\n\n<!-- guide:typescript -->\n\n## 8. TypeScript\n\n**The three npm packages are not published yet.** `@ziffer-io/client`, `@ziffer-io/verify` and\n`@ziffer-io/mcp` are named here as they will be named, and the code below is the code you will\nwrite; today you receive them from us directly. The Python package is on PyPI; these are not.\n\n`@ziffer-io/client` re-exports the verifier, so there is one `verifyReceipt` in your\ndependency tree rather than two implementations that can disagree. `canon` comes from\n`@ziffer-io/verify`, its own home. The client re-exports the verification surface it needs you\nto have, not the whole of somebody else's package.\n\n```ts title=\"transfer.ts (the packages are not yet published)\"\nimport { ZifferClient, verifyReceipt, Refusal, type TrustAnchor } from '@ziffer-io/client';\nimport { canon } from '@ziffer-io/verify';\n\nfunction env(name: string): string {\n const value = process.env[name];\n if (value === undefined) throw new Error(`${name} is not set`);\n return value;\n}\n\nconst client = new ZifferClient(env('ZIFFER_API_URL'), env('ZIFFER_API_KEY'));\nconst anchor: TrustAnchor = await loadAnchor(env('ZIFFER_TRUST_ANCHOR'));\n\nasync function transfer(amount: number, toAccount: string): Promise<void> {\n const proposal = {\n schema_id: 'transfer',\n schema_version: '1.0.0',\n schema_hash: SCHEMA_HASH,\n fidelity: 'F-HIGH',\n tenant_id: TENANT_ID,\n payload: {\n task_type: 'transfer',\n operator: OPERATOR,\n targets: ['bank-api'],\n params: { amount, to_account: toAccount },\n cidrs: {},\n },\n };\n\n const submitted = await client.propose(proposal);\n const decision = await client.wait(submitted.decision_id, { timeoutMs: 30_000 });\n if (decision.outcome !== 'ALLOW') {\n throw new Error(`ziffer refused: ${decision.clause}`);\n }\n\n try {\n verifyReceipt(decision.receipt, canon(proposal), anchor);\n } catch (error) {\n if (error instanceof Refusal) {\n // error.clause is the clause id -- log it, do not retry it.\n throw new Error(`receipt refused: ${error.clause}`);\n }\n throw error;\n }\n\n await bank.transfer(amount, toAccount); // your line, unchanged\n}\n```\n\n**`process.env` is `string | undefined`, so the three values go through `env`.** The client's\nconstructor takes strings; passing the environment straight in is a type error under the settings\nthis repository builds with, and an `as string` there would be a second definition of the wire\ntype. The helper throws at start, where a missing variable is a deployment mistake you can see.\n\n**`instanceof Refusal`, never a cast.** A `catch` binds `unknown`, and the honest reason to\nnarrow rather than assert is that this `catch` also sees the errors you did not plan for. An\n`as Refusal` here would read a `clause` off a `TypeError` and report a protocol refusal that\nnever happened.\n\n**`canon` here, `json.dumps` in the Python example, and both are correct.** `verifyReceipt` parses\nyour bytes and canonicalises them itself, so any JSON spelling of the same object gives the same\nhash. `canon` is used here only because `@ziffer-io/verify` exports it; `new TextEncoder().encode(\nJSON.stringify(proposal))` verifies identically. Bytes that are not UTF-8 JSON at all are refused\nunder `AT-8a`, which is a bug in your call rather than a verdict on the receipt.\n\n<!-- guide:/typescript -->\n\n---\n\n## 9. The MCP-assisted path\n\nIf you are writing this integration with a coding agent, you can give the agent direct access to\nthis guide and to a live decision loop:\n\n```bash title=\"the package is not yet published\"\nnpx @ziffer-io/mcp\n```\n\nThat starts a local MCP server over stdio with **five tools**, and no others: `get_integration_guide` (this\ndocument, by language), `propose` and `check_decision` (the live loop, against whatever\n`ZIFFER_API_URL` you configure), `explain_receipt` (hand it a receipt and the proposal bytes\nand it tells you `valid` or the named clause), and `sandbox_status` (whether the tenant id you\nare sending is a sandbox, and whether one decision is still waiting on its robot approver).\n\nIt is configured by the same environment variables as the SDK, and **it starts without them.**\nTools that need a value return a named error saying which variable to set, rather than the\nserver dying during startup where an agent cannot see why. `get_integration_guide` needs nothing\nat all, so an agent can read this document before any credential exists.\n\nTwo things it deliberately does not have. There is **no approve tool and no simulated approval**:\nan approval minted on a developer's laptop is a fake receipt factory, which is precisely what this\nproduct exists to prevent. `sandbox_status` is not one: a ZIFFER sandbox is a separate tenant\napproved by a service inside our deployment, under keys your machine does not hold, so that tool\nreports and approves nothing. And there are **no file-writing tools**: the server serves\nknowledge and ZIFFER-side calls; your coding agent edits your code.\n\nTo say it once more, because it is the sentence most easily lost: **MCP alone routes the\nquestion; only the SDK's `verify` line enforces the answer.** An integration written with the\nMCP server's help is gated by exactly the lines of `verify` that ended up in your handler.\n\n---\n\n## 10. Checklist\n\n- [ ] Your API key is in `ZIFFER_API_KEY` and is not in your source tree.\n- [ ] You know the date your key expires, and it is written down somewhere that will remind you.\n The SDK warns for the last fourteen days, once per process and only while calls still\n succeed, and an expired key then answers exactly like a key that never existed.\n- [ ] Your rotation is overlap-then-revoke: new key deployed and serving traffic BEFORE the old\n one is revoked.\n- [ ] No request body sets a `tenant_id` other than your key's. The key is the tenant.\n- [ ] `ZIFFER_TRUST_ANCHOR` points at an `acp-bundle pubkey` document you obtained out of band,\n not at a signing key and not at something the API served you.\n- [ ] `ZIFFER_SUITE_FLOOR` is set to a floor you chose.\n- [ ] Your handler calls `verify` **and branches on it**. Grep for the call; then grep for the\n `if`. Section 6, item 1.\n- [ ] Your execution is idempotent on your own key. Section 6, item 2.\n- [ ] You keep the receipt somewhere you can produce it later.\n- [ ] You have read section 6 and know which six things this does not do.\n- [ ] If you were given sandbox credentials, your production configuration still points at your\n production key and anchor. Section 6, item 6.\n- [ ] You know where to write when something is refused, and what to put in the report.\n The support guide has the addresses, the severities, and a row per refusal in section 5\n above saying what it means and who moves next.\n";
6
+ //# sourceMappingURL=guide-source.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guide-source.js","sourceRoot":"","sources":["../../src/generated/guide-source.ts"],"names":[],"mappings":"AAAA,iFAAiF;AACjF,8EAA8E;AAC9E,kDAAkD;AAClD,MAAM,CAAC,MAAM,iBAAiB,GAAG,wBAAwB,CAAC;AAC1D,MAAM,CAAC,MAAM,cAAc,GAAG,gy7BAAgy7B,CAAC"}
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Serve `docs/onboarding/sdk.md` to a coding agent, one language at a time
3
+ * (ACP-197, section 6b point 2).
4
+ *
5
+ * The doc is the single source and this module is a slicer over it. Nothing
6
+ * here paraphrases, summarises or reformats: an agent asking how to integrate
7
+ * gets the text a human reader would get, so a correction to the doc is a
8
+ * correction to what every agent is told. A second, agent-flavoured copy of the
9
+ * integration story is the two-definitions defect, and the one that goes stale
10
+ * is always the one no human reads.
11
+ *
12
+ * # Marked spans, not headings
13
+ *
14
+ * Sections are delimited by HTML comments (`<!-- guide:python -->` …
15
+ * `<!-- guide:/python -->`) rather than located by heading text. Markers are
16
+ * invisible in every markdown renderer, and they make the coupling explicit:
17
+ * renaming a heading is an editorial act that should not silently change what a
18
+ * tool returns, and deleting a marked span fails the build
19
+ * (`scripts/embed-guide.mjs` refuses a doc missing any required marker) instead
20
+ * of quietly serving nothing.
21
+ */
22
+ import { GUIDE_MARKDOWN, GUIDE_SOURCE_PATH } from './generated/guide-source.js';
23
+ /** The languages `get_integration_guide` answers for. */
24
+ export declare const LANGUAGES: readonly ["python", "typescript"];
25
+ /** One of {@link LANGUAGES}. */
26
+ export type Language = (typeof LANGUAGES)[number];
27
+ /** A span named in {@link SECTIONS} that the embedded doc does not carry. */
28
+ export declare class GuideError extends Error {
29
+ readonly name: string;
30
+ constructor(name: string, detail: string);
31
+ }
32
+ /** Narrow an arbitrary string to a {@link Language}, or `null`. */
33
+ export declare function asLanguage(raw: string): Language | null;
34
+ /**
35
+ * The guide for one language: the common preamble, then that language's
36
+ * walkthrough, exactly as `docs/onboarding/sdk.md` writes them.
37
+ *
38
+ * @throws GuideError `GuideSectionMissing` if the embedded doc lost a span.
39
+ */
40
+ export declare function integrationGuide(language: Language): string;
41
+ /** Every span this module serves, for the staleness assertion in the tests. */
42
+ export declare const SERVED_SECTIONS: readonly string[];
43
+ /** The embedded doc, for tests that compare it against the file on disk. */
44
+ export { GUIDE_MARKDOWN, GUIDE_SOURCE_PATH };
45
+ //# sourceMappingURL=guide.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guide.d.ts","sourceRoot":"","sources":["../src/guide.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAEhF,yDAAyD;AACzD,eAAO,MAAM,SAAS,mCAAoC,CAAC;AAE3D,gCAAgC;AAChC,MAAM,MAAM,QAAQ,GAAG,CAAC,OAAO,SAAS,CAAC,CAAC,MAAM,CAAC,CAAC;AAYlD,6EAA6E;AAC7E,qBAAa,UAAW,SAAQ,KAAK;IACnC,SAAkB,IAAI,EAAE,MAAM,CAAC;gBAEnB,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAIzC;AAED,mEAAmE;AACnE,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAQvD;AAqBD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,QAAQ,GAAG,MAAM,CAE3D;AAED,+EAA+E;AAC/E,eAAO,MAAM,eAAe,EAAE,SAAS,MAAM,EAAa,CAAC;AAE3D,4EAA4E;AAC5E,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,CAAC"}
package/dist/guide.js ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Serve `docs/onboarding/sdk.md` to a coding agent, one language at a time
3
+ * (ACP-197, section 6b point 2).
4
+ *
5
+ * The doc is the single source and this module is a slicer over it. Nothing
6
+ * here paraphrases, summarises or reformats: an agent asking how to integrate
7
+ * gets the text a human reader would get, so a correction to the doc is a
8
+ * correction to what every agent is told. A second, agent-flavoured copy of the
9
+ * integration story is the two-definitions defect, and the one that goes stale
10
+ * is always the one no human reads.
11
+ *
12
+ * # Marked spans, not headings
13
+ *
14
+ * Sections are delimited by HTML comments (`<!-- guide:python -->` …
15
+ * `<!-- guide:/python -->`) rather than located by heading text. Markers are
16
+ * invisible in every markdown renderer, and they make the coupling explicit:
17
+ * renaming a heading is an editorial act that should not silently change what a
18
+ * tool returns, and deleting a marked span fails the build
19
+ * (`scripts/embed-guide.mjs` refuses a doc missing any required marker) instead
20
+ * of quietly serving nothing.
21
+ */
22
+ import { GUIDE_MARKDOWN, GUIDE_SOURCE_PATH } from './generated/guide-source.js';
23
+ /** The languages `get_integration_guide` answers for. */
24
+ export const LANGUAGES = ['python', 'typescript'];
25
+ /**
26
+ * The marked spans this module knows about, and the order they are served in.
27
+ *
28
+ * `common` comes first in every answer because it carries the part an agent
29
+ * gets wrong when it is missing: what the SDK is for, and that only the verify
30
+ * line enforces anything. An agent handed just the language snippet would wire
31
+ * up the calls and skip the check they exist to make.
32
+ */
33
+ const SECTIONS = ['common', ...LANGUAGES];
34
+ /** A span named in {@link SECTIONS} that the embedded doc does not carry. */
35
+ export class GuideError extends Error {
36
+ name;
37
+ constructor(name, detail) {
38
+ super(`${name}: ${detail}`);
39
+ this.name = name;
40
+ }
41
+ }
42
+ /** Narrow an arbitrary string to a {@link Language}, or `null`. */
43
+ export function asLanguage(raw) {
44
+ // A lookup rather than a cast: `.claude/rules/typescript.md` forbids `as`
45
+ // here, and the honest reason is stronger than the rule -- the argument comes
46
+ // from a model, so it is exactly the input a cast would wave through.
47
+ for (const known of LANGUAGES) {
48
+ if (known === raw)
49
+ return known;
50
+ }
51
+ return null;
52
+ }
53
+ function span(name) {
54
+ const open = `<!-- guide:${name} -->`;
55
+ const close = `<!-- guide:/${name} -->`;
56
+ const openAt = GUIDE_MARKDOWN.indexOf(open);
57
+ const closeAt = GUIDE_MARKDOWN.indexOf(close);
58
+ if (openAt === -1 || closeAt === -1 || closeAt < openAt) {
59
+ // Unreachable through a build, because embed-guide.mjs refuses the same
60
+ // condition. Kept anyway, and named: this module is also imported by tests
61
+ // and could one day be handed a doc the build script never saw, and the
62
+ // alternative to a refusal is returning an empty guide that reads as an
63
+ // answer.
64
+ throw new GuideError('GuideSectionMissing', `${GUIDE_SOURCE_PATH} carries no complete guide:${name} span.`);
65
+ }
66
+ return GUIDE_MARKDOWN.slice(openAt + open.length, closeAt).trim();
67
+ }
68
+ /**
69
+ * The guide for one language: the common preamble, then that language's
70
+ * walkthrough, exactly as `docs/onboarding/sdk.md` writes them.
71
+ *
72
+ * @throws GuideError `GuideSectionMissing` if the embedded doc lost a span.
73
+ */
74
+ export function integrationGuide(language) {
75
+ return [span('common'), span(language)].join('\n\n');
76
+ }
77
+ /** Every span this module serves, for the staleness assertion in the tests. */
78
+ export const SERVED_SECTIONS = SECTIONS;
79
+ /** The embedded doc, for tests that compare it against the file on disk. */
80
+ export { GUIDE_MARKDOWN, GUIDE_SOURCE_PATH };
81
+ //# sourceMappingURL=guide.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guide.js","sourceRoot":"","sources":["../src/guide.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAEhF,yDAAyD;AACzD,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,QAAQ,EAAE,YAAY,CAAU,CAAC;AAK3D;;;;;;;GAOG;AACH,MAAM,QAAQ,GAAG,CAAC,QAAQ,EAAE,GAAG,SAAS,CAAU,CAAC;AAEnD,6EAA6E;AAC7E,MAAM,OAAO,UAAW,SAAQ,KAAK;IACjB,IAAI,CAAS;IAE/B,YAAY,IAAY,EAAE,MAAc;QACtC,KAAK,CAAC,GAAG,IAAI,KAAK,MAAM,EAAE,CAAC,CAAC;QAC5B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;CACF;AAED,mEAAmE;AACnE,MAAM,UAAU,UAAU,CAAC,GAAW;IACpC,0EAA0E;IAC1E,8EAA8E;IAC9E,sEAAsE;IACtE,KAAK,MAAM,KAAK,IAAI,SAAS,EAAE,CAAC;QAC9B,IAAI,KAAK,KAAK,GAAG;YAAE,OAAO,KAAK,CAAC;IAClC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAS,IAAI,CAAC,IAAY;IACxB,MAAM,IAAI,GAAG,cAAc,IAAI,MAAM,CAAC;IACtC,MAAM,KAAK,GAAG,eAAe,IAAI,MAAM,CAAC;IACxC,MAAM,MAAM,GAAG,cAAc,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,MAAM,OAAO,GAAG,cAAc,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;IAC9C,IAAI,MAAM,KAAK,CAAC,CAAC,IAAI,OAAO,KAAK,CAAC,CAAC,IAAI,OAAO,GAAG,MAAM,EAAE,CAAC;QACxD,wEAAwE;QACxE,2EAA2E;QAC3E,wEAAwE;QACxE,wEAAwE;QACxE,UAAU;QACV,MAAM,IAAI,UAAU,CAClB,qBAAqB,EACrB,GAAG,iBAAiB,8BAA8B,IAAI,QAAQ,CAC/D,CAAC;IACJ,CAAC;IACD,OAAO,cAAc,CAAC,KAAK,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC;AACpE,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,gBAAgB,CAAC,QAAkB;IACjD,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;AACvD,CAAC;AAED,+EAA+E;AAC/E,MAAM,CAAC,MAAM,eAAe,GAAsB,QAAQ,CAAC;AAE3D,4EAA4E;AAC5E,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,CAAC"}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `@ziffer-io/mcp` — a local stdio MCP server so a coding agent can integrate the
3
+ * Ziffer SDK against our real documentation and drive a live decision loop
4
+ * while testing (ACP-197, section 6b).
5
+ *
6
+ * The executable is `bin.ts` (`npx @ziffer-io/mcp`). This module exports the
7
+ * pieces so the server can be embedded, and so the tests import the same
8
+ * surface a consumer would.
9
+ *
10
+ * What this server is NOT: a gate. It routes questions and makes Ziffer-side
11
+ * calls at development time. The line that enforces anything is the `verify`
12
+ * call in the developer's own handler — `docs/onboarding/sdk.md` section 6
13
+ * item 3 says exactly that, and `README.md` repeats it, because it is the
14
+ * claim most easily mistaken in the other direction.
15
+ */
16
+ export { loadTrustAnchor, AnchorError } from './anchor.js';
17
+ export { zifferClientFactory } from './client.js';
18
+ export { anchorConfig, apiConfig, ConfigError, VARS, type AnchorConfig, type ApiConfig, type Env } from './config.js';
19
+ export { asLanguage, integrationGuide, LANGUAGES, type Language } from './guide.js';
20
+ export { createDefaultServer, createServer, SERVER_INFO, TOOL_NAMES, type ServerDeps } from './server.js';
21
+ export { checkDecision, explainReceipt, getIntegrationGuide, isSandboxName, propose, SANDBOX_SUFFIX, sandboxStatus, type ClientFactory, type DecisionClient, type DecisionRecord, type ExplainArgs, type SandboxArgs, type SubmittedDecision, type ToolOutcome, } from './tools.js';
22
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC3D,OAAO,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,YAAY,EAAE,KAAK,SAAS,EAAE,KAAK,GAAG,EAAE,MAAM,aAAa,CAAC;AACtH,OAAO,EAAE,UAAU,EAAE,gBAAgB,EAAE,SAAS,EAAE,KAAK,QAAQ,EAAE,MAAM,YAAY,CAAC;AACpF,OAAO,EAAE,mBAAmB,EAAE,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAC1G,OAAO,EACL,aAAa,EACb,cAAc,EACd,mBAAmB,EACnB,aAAa,EACb,OAAO,EACP,cAAc,EACd,aAAa,EACb,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,KAAK,WAAW,EAChB,KAAK,iBAAiB,EACtB,KAAK,WAAW,GACjB,MAAM,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `@ziffer-io/mcp` — a local stdio MCP server so a coding agent can integrate the
3
+ * Ziffer SDK against our real documentation and drive a live decision loop
4
+ * while testing (ACP-197, section 6b).
5
+ *
6
+ * The executable is `bin.ts` (`npx @ziffer-io/mcp`). This module exports the
7
+ * pieces so the server can be embedded, and so the tests import the same
8
+ * surface a consumer would.
9
+ *
10
+ * What this server is NOT: a gate. It routes questions and makes Ziffer-side
11
+ * calls at development time. The line that enforces anything is the `verify`
12
+ * call in the developer's own handler — `docs/onboarding/sdk.md` section 6
13
+ * item 3 says exactly that, and `README.md` repeats it, because it is the
14
+ * claim most easily mistaken in the other direction.
15
+ */
16
+ export { loadTrustAnchor, AnchorError } from './anchor.js';
17
+ export { zifferClientFactory } from './client.js';
18
+ export { anchorConfig, apiConfig, ConfigError, VARS } from './config.js';
19
+ export { asLanguage, integrationGuide, LANGUAGES } from './guide.js';
20
+ export { createDefaultServer, createServer, SERVER_INFO, TOOL_NAMES } from './server.js';
21
+ export { checkDecision, explainReceipt, getIntegrationGuide, isSandboxName, propose, SANDBOX_SUFFIX, sandboxStatus, } from './tools.js';
22
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC3D,OAAO,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,WAAW,EAAE,IAAI,EAA+C,MAAM,aAAa,CAAC;AACtH,OAAO,EAAE,UAAU,EAAE,gBAAgB,EAAE,SAAS,EAAiB,MAAM,YAAY,CAAC;AACpF,OAAO,EAAE,mBAAmB,EAAE,YAAY,EAAE,WAAW,EAAE,UAAU,EAAmB,MAAM,aAAa,CAAC;AAC1G,OAAO,EACL,aAAa,EACb,cAAc,EACd,mBAAmB,EACnB,aAAa,EACb,OAAO,EACP,cAAc,EACd,aAAa,GAQd,MAAM,YAAY,CAAC"}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The MCP surface: five tools, their schemas, and nothing else (ACP-197
3
+ * section 6b; ACP-213 section 9.2 added `sandbox_status`).
4
+ *
5
+ * This module is the adapter between the protocol and `tools.ts`. It owns the
6
+ * tool names, the argument schemas and the content framing; it owns no
7
+ * behaviour. Anything a test would want to assert about what a tool DOES is one
8
+ * file over, called directly.
9
+ *
10
+ * # The server has no hands on the developer's machine
11
+ *
12
+ * It serves knowledge (`get_integration_guide`), Ziffer-side calls (`propose`,
13
+ * `check_decision`, `sandbox_status`) and a local cryptographic check
14
+ * (`explain_receipt`). It writes no files, runs no commands and approves
15
+ * nothing — `sandbox_status` REPORTS, and the sandbox's approvals are made by a
16
+ * service inside the deployment under keys this machine does not hold. The
17
+ * coding agent talking to it is what edits code — `README.md` says this to the
18
+ * developer and `tools.ts` says why the forbidden shapes are forbidden.
19
+ */
20
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
21
+ import { type Env } from './config.js';
22
+ import { type ClientFactory } from './tools.js';
23
+ /** The name and version an MCP client sees. */
24
+ export declare const SERVER_INFO: {
25
+ readonly name: "ziffer";
26
+ readonly version: "0.0.0";
27
+ };
28
+ /** Every tool this server registers, named once so the tests assert the set
29
+ * rather than a list retyped beside it. A tool added here without a test is
30
+ * visible as a diff on this constant. */
31
+ export declare const TOOL_NAMES: readonly ["propose", "check_decision", "get_integration_guide", "explain_receipt", "sandbox_status"];
32
+ /** What `createServer` needs, injectable so tests drive it without a process. */
33
+ export interface ServerDeps {
34
+ /** The environment. A parameter, never `process.env` read in here: this
35
+ * module would otherwise be the second reader of the environment that
36
+ * `config.ts` exists to be the only one of. */
37
+ readonly env: Env;
38
+ /** How to reach the gateway. Injected so a test can supply a stub client. */
39
+ readonly clientFor: ClientFactory;
40
+ }
41
+ /**
42
+ * Build the server with its four tools registered.
43
+ *
44
+ * Descriptions are written for a model, so each one says what the tool does AND
45
+ * the thing a model would otherwise assume. `propose` says the tenant comes
46
+ * from the key; `explain_receipt` says key order does NOT matter, because a
47
+ * model told only "canonical bytes" will invent a canonicaliser it does not
48
+ * need. A description that only names the happy path is a description that
49
+ * gets the argument wrong.
50
+ */
51
+ export declare function createServer(deps: ServerDeps): McpServer;
52
+ /** The production wiring: the real environment and the real client factory. */
53
+ export declare function createDefaultServer(): McpServer;
54
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAIpE,OAAO,EAAQ,KAAK,GAAG,EAAE,MAAM,aAAa,CAAC;AAE7C,OAAO,EAML,KAAK,aAAa,EAEnB,MAAM,YAAY,CAAC;AAEpB,+CAA+C;AAC/C,eAAO,MAAM,WAAW;;;CAAgD,CAAC;AAEzE;;yCAEyC;AACzC,eAAO,MAAM,UAAU,sGAMb,CAAC;AAEX,iFAAiF;AACjF,MAAM,WAAW,UAAU;IACzB;;mDAE+C;IAC/C,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC;IAClB,6EAA6E;IAC7E,QAAQ,CAAC,SAAS,EAAE,aAAa,CAAC;CACnC;AAUD;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,UAAU,GAAG,SAAS,CAqIxD;AAED,+EAA+E;AAC/E,wBAAgB,mBAAmB,IAAI,SAAS,CAE/C"}
package/dist/server.js ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The MCP surface: five tools, their schemas, and nothing else (ACP-197
3
+ * section 6b; ACP-213 section 9.2 added `sandbox_status`).
4
+ *
5
+ * This module is the adapter between the protocol and `tools.ts`. It owns the
6
+ * tool names, the argument schemas and the content framing; it owns no
7
+ * behaviour. Anything a test would want to assert about what a tool DOES is one
8
+ * file over, called directly.
9
+ *
10
+ * # The server has no hands on the developer's machine
11
+ *
12
+ * It serves knowledge (`get_integration_guide`), Ziffer-side calls (`propose`,
13
+ * `check_decision`, `sandbox_status`) and a local cryptographic check
14
+ * (`explain_receipt`). It writes no files, runs no commands and approves
15
+ * nothing — `sandbox_status` REPORTS, and the sandbox's approvals are made by a
16
+ * service inside the deployment under keys this machine does not hold. The
17
+ * coding agent talking to it is what edits code — `README.md` says this to the
18
+ * developer and `tools.ts` says why the forbidden shapes are forbidden.
19
+ */
20
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
21
+ import { z } from 'zod';
22
+ import { zifferClientFactory } from './client.js';
23
+ import { VARS } from './config.js';
24
+ import { LANGUAGES } from './guide.js';
25
+ import { checkDecision, explainReceipt, getIntegrationGuide, propose, sandboxStatus, } from './tools.js';
26
+ /** The name and version an MCP client sees. */
27
+ export const SERVER_INFO = { name: 'ziffer', version: '0.0.0' };
28
+ /** Every tool this server registers, named once so the tests assert the set
29
+ * rather than a list retyped beside it. A tool added here without a test is
30
+ * visible as a diff on this constant. */
31
+ export const TOOL_NAMES = [
32
+ 'propose',
33
+ 'check_decision',
34
+ 'get_integration_guide',
35
+ 'explain_receipt',
36
+ 'sandbox_status',
37
+ ];
38
+ /** MCP's content shape, from one place, so no handler assembles it by hand. */
39
+ function reply(outcome) {
40
+ return { content: [{ type: 'text', text: outcome.text }], isError: outcome.isError };
41
+ }
42
+ /**
43
+ * Build the server with its four tools registered.
44
+ *
45
+ * Descriptions are written for a model, so each one says what the tool does AND
46
+ * the thing a model would otherwise assume. `propose` says the tenant comes
47
+ * from the key; `explain_receipt` says key order does NOT matter, because a
48
+ * model told only "canonical bytes" will invent a canonicaliser it does not
49
+ * need. A description that only names the happy path is a description that
50
+ * gets the argument wrong.
51
+ */
52
+ export function createServer(deps) {
53
+ const server = new McpServer(SERVER_INFO);
54
+ server.registerTool('propose', {
55
+ title: 'Propose an action to Ziffer',
56
+ description: 'Submit one wire Proposal for a decision and return the gateway response verbatim ' +
57
+ '(decision_id, status, and outcome/clause when already decided). The tenant comes ' +
58
+ `from ${VARS.API_KEY}: do not add or change tenant_id to match something else, as a ` +
59
+ 'mismatched tenant_id is refused (TenantMismatch), never rewritten. The receipt is ' +
60
+ 'never in this response — fetch it with check_decision.',
61
+ inputSchema: {
62
+ proposal: z
63
+ .record(z.string(), z.unknown())
64
+ .describe('The wire Proposal object: schema_id, schema_version, schema_hash, fidelity, ' +
65
+ 'tenant_id, payload. Passed through untouched.'),
66
+ },
67
+ }, async ({ proposal }) => reply(await propose(deps.clientFor, deps.env, proposal)));
68
+ server.registerTool('check_decision', {
69
+ title: 'Fetch a decision by id',
70
+ description: 'Return the decision for one decision_id, verbatim, including the signed receipt ' +
71
+ 'when one exists. Poll this while status is "pending". An id belonging to another ' +
72
+ 'tenant reads as unknown (DecisionUnknown): existence is tenant-scoped.',
73
+ inputSchema: {
74
+ decision_id: z.string().min(1).describe('The decision_id returned by propose.'),
75
+ },
76
+ }, async ({ decision_id }) => reply(await checkDecision(deps.clientFor, deps.env, decision_id)));
77
+ server.registerTool('get_integration_guide', {
78
+ title: 'Read the Ziffer SDK integration guide',
79
+ description: 'Return the integration guide for one language, as written in ' +
80
+ 'docs/onboarding/sdk.md. Needs no configuration, so it answers before any API key ' +
81
+ 'exists. Read it before writing integration code: the guide states which line ' +
82
+ 'actually enforces anything, and what this SDK does not do.',
83
+ inputSchema: {
84
+ language: z.enum(LANGUAGES).describe('Which language walkthrough to return.'),
85
+ },
86
+ }, ({ language }) => reply(getIntegrationGuide(language)));
87
+ server.registerTool('explain_receipt', {
88
+ title: 'Verify a decision receipt',
89
+ description: 'Verify a receipt against the configured trust anchor and report "valid: bound to ' +
90
+ '<hash>" or the named refusal clause. proposal_b64 is base64 of the proposal bytes ' +
91
+ 'your code passes to verify. Key order and whitespace do not matter — the verifier ' +
92
+ 'parses and canonicalises them — but it must be the same object, and it must be ' +
93
+ 'UTF-8 JSON. Verifying a receipt says nothing about whether it was already used.',
94
+ inputSchema: {
95
+ receipt: z.unknown().describe('The receipt as parsed JSON, exactly as served.'),
96
+ proposal_b64: z
97
+ .string()
98
+ .min(1)
99
+ .describe('Base64 of the proposal bytes the receipt should be bound to (any JSON spelling).'),
100
+ trust_anchor_path: z
101
+ .string()
102
+ .optional()
103
+ .describe(`Overrides ${VARS.TRUST_ANCHOR} for this call. Omit to use the configured anchor.`),
104
+ },
105
+ }, async ({ receipt, proposal_b64, trust_anchor_path }) => reply(await explainReceipt(deps.env, {
106
+ receipt,
107
+ proposalB64: proposal_b64,
108
+ // exactOptionalPropertyTypes is on: an absent override must be an
109
+ // ABSENT property, not one present and holding undefined. Spreading
110
+ // conditionally is the honest way to say "there is no override" --
111
+ // the same distinction codegen enforces on the wire root, where an
112
+ // absent optional must not reach a preimage as null.
113
+ ...(trust_anchor_path === undefined ? {} : { trustAnchorPath: trust_anchor_path }),
114
+ })));
115
+ server.registerTool('sandbox_status', {
116
+ title: 'Report whether a tenant is a Ziffer sandbox',
117
+ description: 'Say whether a tenant id is a sandbox tenant, what that does and does not mean, and — ' +
118
+ 'when given a decision_id — whether that decision has been decided or is still waiting ' +
119
+ 'on the robot approver. This tool APPROVES NOTHING and verifies nothing: a sandbox is a ' +
120
+ 'separate tenant inside the deployment, approvals are made there under keys this machine ' +
121
+ 'does not hold, and a sandbox receipt is signed by a different identity so production ' +
122
+ 'verification refuses it. An ALLOW in a sandbox means the path worked, not that a person ' +
123
+ 'agreed.',
124
+ inputSchema: {
125
+ tenant_id: z
126
+ .string()
127
+ .min(1)
128
+ .describe('The tenant_id your proposals carry. This server holds no tenant of its own: the API ' +
129
+ 'key determines the tenant, and a proposal naming a different one is refused.'),
130
+ decision_id: z
131
+ .string()
132
+ .optional()
133
+ .describe('A decision to check for the approver s liveness. Omit to answer on the name alone.'),
134
+ },
135
+ }, async ({ tenant_id, decision_id }) => reply(await sandboxStatus(deps.clientFor, deps.env, {
136
+ tenantId: tenant_id,
137
+ // exactOptionalPropertyTypes, as in explain_receipt: an omitted
138
+ // argument must be an ABSENT property, not one holding undefined.
139
+ ...(decision_id === undefined ? {} : { decisionId: decision_id }),
140
+ })));
141
+ return server;
142
+ }
143
+ /** The production wiring: the real environment and the real client factory. */
144
+ export function createDefaultServer() {
145
+ return createServer({ env: process.env, clientFor: zifferClientFactory });
146
+ }
147
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACpE,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,EAAE,IAAI,EAAY,MAAM,aAAa,CAAC;AAC7C,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,EACL,aAAa,EACb,cAAc,EACd,mBAAmB,EACnB,OAAO,EACP,aAAa,GAGd,MAAM,YAAY,CAAC;AAEpB,+CAA+C;AAC/C,MAAM,CAAC,MAAM,WAAW,GAAG,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAW,CAAC;AAEzE;;yCAEyC;AACzC,MAAM,CAAC,MAAM,UAAU,GAAG;IACxB,SAAS;IACT,gBAAgB;IAChB,uBAAuB;IACvB,iBAAiB;IACjB,gBAAgB;CACR,CAAC;AAYX,+EAA+E;AAC/E,SAAS,KAAK,CAAC,OAAoB;IAIjC,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,EAAE,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC;AACvF,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,YAAY,CAAC,IAAgB;IAC3C,MAAM,MAAM,GAAG,IAAI,SAAS,CAAC,WAAW,CAAC,CAAC;IAE1C,MAAM,CAAC,YAAY,CACjB,SAAS,EACT;QACE,KAAK,EAAE,6BAA6B;QACpC,WAAW,EACT,mFAAmF;YACnF,mFAAmF;YACnF,QAAQ,IAAI,CAAC,OAAO,iEAAiE;YACrF,oFAAoF;YACpF,wDAAwD;QAC1D,WAAW,EAAE;YACX,QAAQ,EAAE,CAAC;iBACR,MAAM,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC;iBAC/B,QAAQ,CACP,8EAA8E;gBAC5E,+CAA+C,CAClD;SACJ;KACF,EACD,KAAK,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,CAAC,KAAK,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC,CACjF,CAAC;IAEF,MAAM,CAAC,YAAY,CACjB,gBAAgB,EAChB;QACE,KAAK,EAAE,wBAAwB;QAC/B,WAAW,EACT,kFAAkF;YAClF,mFAAmF;YACnF,wEAAwE;QAC1E,WAAW,EAAE;YACX,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,sCAAsC,CAAC;SAChF;KACF,EACD,KAAK,EAAE,EAAE,WAAW,EAAE,EAAE,EAAE,CAAC,KAAK,CAAC,MAAM,aAAa,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,WAAW,CAAC,CAAC,CAC7F,CAAC;IAEF,MAAM,CAAC,YAAY,CACjB,uBAAuB,EACvB;QACE,KAAK,EAAE,uCAAuC;QAC9C,WAAW,EACT,+DAA+D;YAC/D,mFAAmF;YACnF,+EAA+E;YAC/E,4DAA4D;QAC9D,WAAW,EAAE;YACX,QAAQ,EAAE,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,QAAQ,CAAC,uCAAuC,CAAC;SAC9E;KACF,EACD,CAAC,EAAE,QAAQ,EAAE,EAAE,EAAE,CAAC,KAAK,CAAC,mBAAmB,CAAC,QAAQ,CAAC,CAAC,CACvD,CAAC;IAEF,MAAM,CAAC,YAAY,CACjB,iBAAiB,EACjB;QACE,KAAK,EAAE,2BAA2B;QAClC,WAAW,EACT,mFAAmF;YACnF,oFAAoF;YACpF,oFAAoF;YACpF,iFAAiF;YACjF,iFAAiF;QACnF,WAAW,EAAE;YACX,OAAO,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,CAAC,gDAAgD,CAAC;YAC/E,YAAY,EAAE,CAAC;iBACZ,MAAM,EAAE;iBACR,GAAG,CAAC,CAAC,CAAC;iBACN,QAAQ,CAAC,kFAAkF,CAAC;YAC/F,iBAAiB,EAAE,CAAC;iBACjB,MAAM,EAAE;iBACR,QAAQ,EAAE;iBACV,QAAQ,CACP,aAAa,IAAI,CAAC,YAAY,oDAAoD,CACnF;SACJ;KACF,EACD,KAAK,EAAE,EAAE,OAAO,EAAE,YAAY,EAAE,iBAAiB,EAAE,EAAE,EAAE,CACrD,KAAK,CACH,MAAM,cAAc,CAAC,IAAI,CAAC,GAAG,EAAE;QAC7B,OAAO;QACP,WAAW,EAAE,YAAY;QACzB,kEAAkE;QAClE,oEAAoE;QACpE,mEAAmE;QACnE,mEAAmE;QACnE,qDAAqD;QACrD,GAAG,CAAC,iBAAiB,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,eAAe,EAAE,iBAAiB,EAAE,CAAC;KACnF,CAAC,CACH,CACJ,CAAC;IAEF,MAAM,CAAC,YAAY,CACjB,gBAAgB,EAChB;QACE,KAAK,EAAE,6CAA6C;QACpD,WAAW,EACT,uFAAuF;YACvF,wFAAwF;YACxF,yFAAyF;YACzF,0FAA0F;YAC1F,uFAAuF;YACvF,0FAA0F;YAC1F,SAAS;QACX,WAAW,EAAE;YACX,SAAS,EAAE,CAAC;iBACT,MAAM,EAAE;iBACR,GAAG,CAAC,CAAC,CAAC;iBACN,QAAQ,CACP,sFAAsF;gBACpF,8EAA8E,CACjF;YACH,WAAW,EAAE,CAAC;iBACX,MAAM,EAAE;iBACR,QAAQ,EAAE;iBACV,QAAQ,CAAC,oFAAoF,CAAC;SAClG;KACF,EACD,KAAK,EAAE,EAAE,SAAS,EAAE,WAAW,EAAE,EAAE,EAAE,CACnC,KAAK,CACH,MAAM,aAAa,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE;QAC5C,QAAQ,EAAE,SAAS;QACnB,gEAAgE;QAChE,kEAAkE;QAClE,GAAG,CAAC,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,WAAW,EAAE,CAAC;KAClE,CAAC,CACH,CACJ,CAAC;IAEF,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,+EAA+E;AAC/E,MAAM,UAAU,mBAAmB;IACjC,OAAO,YAAY,CAAC,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,SAAS,EAAE,mBAAmB,EAAE,CAAC,CAAC;AAC5E,CAAC"}