@ziffer-io/mcp 0.1.0 → 0.1.1

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.
package/README.md CHANGED
@@ -1,136 +1,76 @@
1
- # `@ziffer-io/mcp`
1
+ # @ziffer-io/mcp
2
2
 
3
- A local MCP server, over stdio, so a coding agent can integrate the ZIFFER SDK against our real
4
- documentation and drive a live decision loop while testing.
3
+ A local MCP server, so a coding agent can integrate the ZIFFER SDK and drive a live decision
4
+ loop.
5
+
6
+ ## Install
7
+
8
+ There is nothing to install first. The server runs over stdio from `npx`.
5
9
 
6
10
  ```bash
7
11
  npx @ziffer-io/mcp
8
12
  ```
9
13
 
10
- ## What it is, and the boundary that matters
14
+ ## Configure your agent
11
15
 
12
- **This server has no hands on your machine.** It serves knowledge and ZIFFER-side calls. It
13
- writes no files, runs no commands, and starts no processes. Your coding agent is the thing that
14
- edits your code; this is a source it can ask.
16
+ Claude Code:
17
+
18
+ ```bash
19
+ claude mcp add ziffer -- npx -y @ziffer-io/mcp
20
+ ```
15
21
 
16
- That boundary is also the honest limit of what it does for your security posture:
22
+ Cursor, or any client that starts an MCP server over stdio:
17
23
 
18
- > **MCP alone routes the question; only the SDK's `verify` line enforces the answer.**
24
+ ```json
25
+ {
26
+ "mcpServers": {
27
+ "ziffer": { "command": "npx", "args": ["-y", "@ziffer-io/mcp"] }
28
+ }
29
+ }
30
+ ```
19
31
 
20
- An agent that has talked to this server has not thereby been gated by anything. It runs on a
21
- developer's machine at development time and is nowhere near your production path. What gates an
22
- action is the `verify` call that ends up in your handler — `docs/onboarding/sdk.md` section 6
23
- says so at length, and that document is what `get_integration_guide` serves.
32
+ ## What it can and cannot do
24
33
 
25
- ## The five tools
34
+ It has five tools: the integration guide, propose, check a decision, explain a receipt, and
35
+ sandbox status. It has no hands on your machine: it writes no files, runs no commands, and
36
+ cannot approve anything.
26
37
 
27
- | tool | what it does | needs |
28
- | --- | --- | --- |
29
- | `get_integration_guide` | returns `docs/onboarding/sdk.md` for `python` or `typescript` | nothing |
30
- | `propose` | submits one wire Proposal, returns the gateway response verbatim | `ZIFFER_API_URL`, `ZIFFER_API_KEY` |
31
- | `check_decision` | one decision by id, receipt included when present | `ZIFFER_API_URL`, `ZIFFER_API_KEY` |
32
- | `explain_receipt` | verifies a receipt, reports `valid: bound to <hash>` or the named clause | `ZIFFER_TRUST_ANCHOR`, `ZIFFER_SUITE_FLOOR` |
33
- | `sandbox_status` | whether a tenant id is a sandbox, and whether one decision still waits on its robot approver | nothing, or `ZIFFER_API_URL`+`ZIFFER_API_KEY` with a `decision_id` |
34
-
35
- `explain_receipt` takes the proposal as **base64 of the bytes your code passes to `verify`**,
36
- not as a JSON object, because `verify` takes bytes and this tool exists to reproduce your call.
37
- Accepting an object would insert an encoding step your production path does not have, and would
38
- hide one real failure outright: bytes that are not UTF-8 JSON at all — a truncated read, a
39
- double-encoded string — which the verifier refuses under `AT-8a`.
40
-
41
- **Key order and whitespace are not the issue**, and it is worth saying because the opposite reads
42
- so plausibly that an earlier draft of this package asserted it. The verifier parses your bytes and
43
- canonicalises them itself, so `JSON.stringify`, `json.dumps` and a pretty-printed copy of one
44
- object all verify identically. A `9.3-3` means a genuinely different proposal, and the tool's hint
45
- says so.
46
-
47
- There is **no approve tool and no simulated approval**, and there will not be one added quietly.
48
- An approval minted on a laptop is a fake receipt factory, which is the exact artifact this product
49
- exists to make impossible.
50
-
51
- `sandbox_status` is not an exception to that, and the reason is the design rather than a
52
- concession. Until ACP-213 this file said a sandbox mode was "a separate ticket with its own
53
- design"; that ticket landed, and the design is: **a ZIFFER sandbox is a separate tenant** with its
54
- own receipt signing identity, approved by a robot service inside the deployment holding keys this
55
- machine never sees. So a sandbox receipt is signed by a different key and fails production
56
- verification (`9.3-1`) by construction. There is a truthful thing for a client to report, and
57
- still nothing for it to approve — `sandbox_status` reads a name and asks the API about one
58
- decision, verifies nothing, and says so in its own output. `docs/onboarding/sandbox.md` states
59
- what the marker guarantees and what it does not.
38
+ Your coding agent edits your code. The `verify` line in your handler is what gates the action.
60
39
 
61
40
  ## Configuration
62
41
 
63
- | variable | what it is |
64
- | --- | --- |
65
- | `ZIFFER_API_URL` | the gateway base URL. `https://`, or `http://` on loopback only the key is a bearer credential |
66
- | `ZIFFER_API_KEY` | your API key. **It determines the tenant**; nothing here sends a tenant name |
67
- | `ZIFFER_TRUST_ANCHOR` | path to an `acp-bundle pubkey --key <keyfile> --out anchor.json` document |
68
- | `ZIFFER_SUITE_FLOOR` | the CR-4 signature suite floor. No default, deliberately |
69
-
70
- **The server starts without any of them**, which inverts the rule every other process in this
71
- repository follows. The reason is in `src/config.ts`: a stdio server that exits during the
72
- handshake tells the agent only that the server failed to start, and the name of the missing
73
- variable dies on a stderr nobody is reading. So each tool refuses on its own, by name, telling
74
- the agent exactly which variable to set. `get_integration_guide` needs nothing at all, so an
75
- agent can read how to integrate before any credential exists.
76
-
77
- Nothing is defaulted. An unconfigured `propose` sends no request; an unconfigured
78
- `explain_receipt` verifies nothing. The refusal moved from process exit to tool result; it did
79
- not become a fallback.
80
-
81
- ## What does not work yet
82
-
83
- 1. **`npx @ziffer-io/mcp` does not resolve, because nothing is published yet — but the reason it
84
- *could not have been* published is now fixed (ACP-214).** Until this change the blocker was
85
- structural: this package depended on `@acp/client` and `@acp/verify`, which were unpublished
86
- workspace packages, and `@acp/client` in turn declared the engine's `@acp/types` as a `git+`
87
- dependency on a repository that is **private** — so a developer who ran `npx` would have got a
88
- command that installs and then cannot start, or cannot install at all. That is closed. There
89
- are four packages now (`@ziffer-io/types`, `@ziffer-io/verify`, `@ziffer-io/client` and this
90
- one), each publishing compiled `dist/` with no git dependency and nothing for a consumer to
91
- build, and `tools/release-npm.sh` packs and checks all four in dependency order.
92
-
93
- **What remains is the act, and it is the operator's.** The release script prints the four
94
- `npm publish` lines and the tag line and never runs them; nothing reaches the registry until a
95
- person pastes them. So this item stays in this section: today `npx @ziffer-io/mcp` still
96
- resolves to nothing, and it will keep saying so here until the first publication has actually
97
- happened rather than merely become possible. Note also that the first publish of each package
98
- must be that manual one — npm cannot register a trusted publisher for a package that does not
99
- exist yet, so CI takes over only from the second release.
100
-
101
- 2. **All five tools ran against a live gateway on 2026-09-03; what is still untried is a
102
- *deployed* one.** Until that day this item read, in these words, "`propose` and
103
- `check_decision` have never been run against a live gateway [...] treat the two as
104
- wired-and-untried rather than proven", and `tools/rehearse/03-loop.sh` exists to end it (the
105
- transcript is `tools/rehearsal-2026-09-03.json`, `rehearsal GREEN: 4 of 4 steps`). That
106
- step drives this server over a spawned stdio transport against `services/gateway` in a
107
- container, over the composed configuration tree of a real sandbox tenant: `propose` returned
108
- the gateway's answer verbatim, `check_decision` was polled until the receipt attached,
109
- `explain_receipt` reported that receipt valid and refused one bound to a different proposal at
110
- `9.3-3`, and `sandbox_status` and `get_integration_guide` answered beside them. What that run
111
- did **not** cover is a deployed control plane rather than a local compose stack — and it was
112
- driven by a script, which is item 3. It is evidence about that tree on that day and nothing
113
- later; `tools/rehearse.sh` records each rehearsal as `tools/rehearsal-<date>.json`.
114
-
115
- 3. **Nothing here has been exercised by a real coding agent.** The tool descriptions are written
116
- for a model to act on, and whether a model reads them the way they were meant is a question
117
- about models, not about this code. No test can answer it, and none here claims to.
118
-
119
- ## Development
42
+ | Variable | What it is | Where the value comes from |
43
+ | --- | --- | --- |
44
+ | `ZIFFER_API_KEY` | Your API key. It carries your tenant, so no request names a tenant. | We issue it. It expires after 90 days unless you ask for another lifetime. |
45
+ | `ZIFFER_TRUST_ANCHOR` | Path to the public key file your receipts are signed under. | We give you the file. Take it from us, never from the API you are checking. |
46
+ | `ZIFFER_SUITE_FLOOR` | The weakest signature suite you will accept. | You choose it. There is no default. |
47
+ | `ZIFFER_API_URL` | The base URL of the ZIFFER deployment you call. | We give it to you with your key. |
120
48
 
121
- ```bash
122
- pnpm --filter @ziffer-io/mcp test # embed the guide, build, run node --test
123
- ```
49
+ Set them in your MCP client's own configuration. The server starts without any of them. A tool
50
+ that needs a value names the variable to set. The guide tool needs nothing at all,
51
+ so an agent can read how to integrate before you have a key.
52
+
53
+ ## When a request is refused
54
+
55
+ Every refusal names the rule that fired; the table of every clause, what it means and what to do
56
+ is at https://ziffer.io/docs/refusals. A refusal is deterministic, so do not retry it.
57
+
58
+ ## Documentation
59
+
60
+ - Quickstart: https://ziffer.io/docs/quickstart
61
+ - Integrating the SDK: https://ziffer.io/docs/developers/sdk
62
+ - Sandbox tenants: https://ziffer.io/docs/developers/sandbox
63
+ - Every refusal: https://ziffer.io/docs/refusals
64
+ - Glossary: https://ziffer.io/docs/glossary
65
+
66
+ ## Support
124
67
 
125
- The build copies `docs/onboarding/sdk.md` into `src/generated/` (gitignored) so the published
126
- server carries the guide it serves. `src/guide.test.ts` reads that document from the repository
127
- and asserts the served text matches it, so an edit to the doc without a rebuild fails the suite
128
- rather than shipping a stale answer.
68
+ Write to hello@ziffer.io. Your API key, your trust anchor file and your suite floor come from us.
69
+ So does an answer about a refusal you cannot explain.
129
70
 
130
- ## Licence
71
+ ## License
131
72
 
132
- Proprietary. Copyright (c) 2026 code75 SASU, Paris, France. ZIFFER is a registered trademark of code75
133
- SASU. This package is **not open source**: it is licensed for use with the ZIFFER service under
134
- your agreement with code75, on the terms in `LICENSE` beside this file. The third-party
135
- open-source components it redistributes are listed in `THIRD-PARTY-NOTICES` and are governed by
136
- their own licences.
73
+ Proprietary. Copyright (c) 2026 code75 SASU, Paris, France. ZIFFER is a registered trademark of
74
+ code75 SASU. This package is not open source. Its use is governed by your agreement with code75
75
+ and by `LICENSE` beside this file. The open-source components it redistributes are listed in
76
+ `THIRD-PARTY-NOTICES`, under their own licences.
package/dist/anchor.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * # The format is not ours to choose
6
6
  *
7
- * `ZIFFER_TRUST_ANCHOR` names the output of `acp-bundle pubkey --key <file>`,
7
+ * `ZIFFER_TRUST_ANCHOR` names the output of `ziffer pubkey --key <file>`,
8
8
  * at engine pin fed43d1, `crates/acp-bundle-cli/src/main.rs::cmd_pubkey` (the
9
9
  * same four keys in the same order it wrote at 356ef8e, where this comment was
10
10
  * first written; re-read at the v1.3.32 pin rather than assumed). That
package/dist/anchor.js CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * # The format is not ours to choose
6
6
  *
7
- * `ZIFFER_TRUST_ANCHOR` names the output of `acp-bundle pubkey --key <file>`,
7
+ * `ZIFFER_TRUST_ANCHOR` names the output of `ziffer pubkey --key <file>`,
8
8
  * at engine pin fed43d1, `crates/acp-bundle-cli/src/main.rs::cmd_pubkey` (the
9
9
  * same four keys in the same order it wrote at 356ef8e, where this comment was
10
10
  * first written; re-read at the v1.3.32 pin rather than assumed). That
@@ -49,7 +49,7 @@ const ED25519_PK_HEX_LEN = 64;
49
49
  /** ML-DSA-65 verification key: 1,952 bytes, so 3,904 hex characters. */
50
50
  const MLDSA65_PK_HEX_LEN = 3904;
51
51
  /**
52
- * The secret-half field names `acp-bundle` writes into a KEY file.
52
+ * The secret-half field names `ziffer` writes into a KEY file.
53
53
  *
54
54
  * Checked for by name because the mistake this catches is a plausible one: the
55
55
  * operator has two JSON files with similar names, and points
@@ -97,7 +97,7 @@ function isRecord(v) {
97
97
  function hexField(doc, key, expected, path) {
98
98
  const raw = doc[key];
99
99
  if (typeof raw !== 'string') {
100
- throw new AnchorError('TrustAnchorMalformed', path, `${key} is ${raw === undefined ? 'absent' : 'not a string'}; this is not an \`acp-bundle pubkey\` document.`);
100
+ throw new AnchorError('TrustAnchorMalformed', path, `${key} is ${raw === undefined ? 'absent' : 'not a string'}; this is not a \`ziffer pubkey\` document.`);
101
101
  }
102
102
  if (raw.length !== expected) {
103
103
  throw new AnchorError('TrustAnchorMalformed', path, `${key} is ${raw.length} hex characters, expected exactly ${expected}.`);
@@ -142,14 +142,14 @@ export async function loadTrustAnchor(path, minSuite) {
142
142
  parsed = JSON.parse(raw);
143
143
  }
144
144
  catch {
145
- throw new AnchorError('TrustAnchorNotJson', path, 'the file is not JSON; expected the output of `acp-bundle pubkey --key <file>`.');
145
+ throw new AnchorError('TrustAnchorNotJson', path, 'the file is not JSON; expected the output of `ziffer pubkey --key <file>`.');
146
146
  }
147
147
  if (!isRecord(parsed)) {
148
148
  throw new AnchorError('TrustAnchorNotJson', path, 'the file is JSON but not an object.');
149
149
  }
150
150
  for (const secret of SECRET_FIELDS) {
151
151
  if (secret in parsed) {
152
- throw new AnchorError('TrustAnchorHoldsSecret', path, `this file carries ${secret}, so it is a SIGNING KEY, not a public trust anchor. Nothing was read from it. Run \`acp-bundle pubkey --key <this file> --out <anchor file>\` and point ZIFFER_TRUST_ANCHOR at the output.`);
152
+ throw new AnchorError('TrustAnchorHoldsSecret', path, `this file carries ${secret}, so it is a SIGNING KEY, not a public trust anchor. Nothing was read from it. Run \`ziffer pubkey --key <this file> --out <anchor file>\` and point ZIFFER_TRUST_ANCHOR at the output.`);
153
153
  }
154
154
  }
155
155
  return {
@@ -1 +1 @@
1
- {"version":3,"file":"anchor.js","sourceRoot":"","sources":["../src/anchor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAI5C,gEAAgE;AAChE,MAAM,kBAAkB,GAAG,EAAE,CAAC;AAC9B,wEAAwE;AACxE,MAAM,kBAAkB,GAAG,IAAI,CAAC;AAEhC;;;;;;;;;;;GAWG;AACH,MAAM,aAAa,GAAG,CAAC,gBAAgB,EAAE,gBAAgB,CAAU,CAAC;AAEpE;;;;;;;;;;GAUG;AACH,MAAM,OAAO,WAAY,SAAQ,KAAK;IAClB,IAAI,CAAS;IAC/B,+EAA+E;IACtE,IAAI,CAAS;IAEtB,YAAY,IAAY,EAAE,IAAY,EAAE,MAAc;QACpD,KAAK,CAAC,GAAG,IAAI,KAAK,IAAI,KAAK,MAAM,EAAE,CAAC,CAAC;QACrC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;CACF;AAED,SAAS,QAAQ,CAAC,CAAU;IAC1B,OAAO,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;AAClE,CAAC;AAED;;;;;;;;GAQG;AACH,SAAS,QAAQ,CAAC,GAA4B,EAAE,GAAW,EAAE,QAAgB,EAAE,IAAY;IACzF,MAAM,GAAG,GAAG,GAAG,CAAC,GAAG,CAAC,CAAC;IACrB,IAAI,OAAO,GAAG,KAAK,QAAQ,EAAE,CAAC;QAC5B,MAAM,IAAI,WAAW,CACnB,sBAAsB,EACtB,IAAI,EACJ,GAAG,GAAG,OAAO,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,cAAc,kDAAkD,CAC7G,CAAC;IACJ,CAAC;IACD,IAAI,GAAG,CAAC,MAAM,KAAK,QAAQ,EAAE,CAAC;QAC5B,MAAM,IAAI,WAAW,CACnB,sBAAsB,EACtB,IAAI,EACJ,GAAG,GAAG,OAAO,GAAG,CAAC,MAAM,qCAAqC,QAAQ,GAAG,CACxE,CAAC;IACJ,CAAC;IACD,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;QAC7B,yEAAyE;QACzE,oEAAoE;QACpE,0EAA0E;QAC1E,iDAAiD;QACjD,MAAM,IAAI,WAAW,CACnB,sBAAsB,EACtB,IAAI,EACJ,GAAG,GAAG,gCAAgC,CACvC,CAAC;IACJ,CAAC;IACD,MAAM,GAAG,GAAG,IAAI,UAAU,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IAC3C,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QACvC,GAAG,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAC5D,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,IAAY,EAAE,QAAgB;IAClE,IAAI,GAAW,CAAC;IAChB,IAAI,CAAC;QACH,GAAG,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IACrC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,yEAAyE;QACzE,0EAA0E;QAC1E,6DAA6D;QAC7D,MAAM,IAAI,WAAW,CACnB,uBAAuB,EACvB,IAAI,EACJ,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,oBAAoB,CAC9D,CAAC;IACJ,CAAC;IAED,IAAI,MAAe,CAAC;IACpB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC3B,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,WAAW,CACnB,oBAAoB,EACpB,IAAI,EACJ,gFAAgF,CACjF,CAAC;IACJ,CAAC;IACD,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;QACtB,MAAM,IAAI,WAAW,CAAC,oBAAoB,EAAE,IAAI,EAAE,qCAAqC,CAAC,CAAC;IAC3F,CAAC;IAED,KAAK,MAAM,MAAM,IAAI,aAAa,EAAE,CAAC;QACnC,IAAI,MAAM,IAAI,MAAM,EAAE,CAAC;YACrB,MAAM,IAAI,WAAW,CACnB,wBAAwB,EACxB,IAAI,EACJ,qBAAqB,MAAM,6LAA6L,CACzN,CAAC;QACJ,CAAC;IACH,CAAC;IAED,OAAO;QACL,SAAS,EAAE,QAAQ,CAAC,MAAM,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,IAAI,CAAC;QACvE,EAAE,EAAE,QAAQ,CAAC,MAAM,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,IAAI,CAAC;QAChE,QAAQ;KACT,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"anchor.js","sourceRoot":"","sources":["../src/anchor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAI5C,gEAAgE;AAChE,MAAM,kBAAkB,GAAG,EAAE,CAAC;AAC9B,wEAAwE;AACxE,MAAM,kBAAkB,GAAG,IAAI,CAAC;AAEhC;;;;;;;;;;;GAWG;AACH,MAAM,aAAa,GAAG,CAAC,gBAAgB,EAAE,gBAAgB,CAAU,CAAC;AAEpE;;;;;;;;;;GAUG;AACH,MAAM,OAAO,WAAY,SAAQ,KAAK;IAClB,IAAI,CAAS;IAC/B,+EAA+E;IACtE,IAAI,CAAS;IAEtB,YAAY,IAAY,EAAE,IAAY,EAAE,MAAc;QACpD,KAAK,CAAC,GAAG,IAAI,KAAK,IAAI,KAAK,MAAM,EAAE,CAAC,CAAC;QACrC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;CACF;AAED,SAAS,QAAQ,CAAC,CAAU;IAC1B,OAAO,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;AAClE,CAAC;AAED;;;;;;;;GAQG;AACH,SAAS,QAAQ,CAAC,GAA4B,EAAE,GAAW,EAAE,QAAgB,EAAE,IAAY;IACzF,MAAM,GAAG,GAAG,GAAG,CAAC,GAAG,CAAC,CAAC;IACrB,IAAI,OAAO,GAAG,KAAK,QAAQ,EAAE,CAAC;QAC5B,MAAM,IAAI,WAAW,CACnB,sBAAsB,EACtB,IAAI,EACJ,GAAG,GAAG,OAAO,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,cAAc,6CAA6C,CACxG,CAAC;IACJ,CAAC;IACD,IAAI,GAAG,CAAC,MAAM,KAAK,QAAQ,EAAE,CAAC;QAC5B,MAAM,IAAI,WAAW,CACnB,sBAAsB,EACtB,IAAI,EACJ,GAAG,GAAG,OAAO,GAAG,CAAC,MAAM,qCAAqC,QAAQ,GAAG,CACxE,CAAC;IACJ,CAAC;IACD,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;QAC7B,yEAAyE;QACzE,oEAAoE;QACpE,0EAA0E;QAC1E,iDAAiD;QACjD,MAAM,IAAI,WAAW,CACnB,sBAAsB,EACtB,IAAI,EACJ,GAAG,GAAG,gCAAgC,CACvC,CAAC;IACJ,CAAC;IACD,MAAM,GAAG,GAAG,IAAI,UAAU,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IAC3C,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QACvC,GAAG,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAC5D,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,IAAY,EAAE,QAAgB;IAClE,IAAI,GAAW,CAAC;IAChB,IAAI,CAAC;QACH,GAAG,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IACrC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,yEAAyE;QACzE,0EAA0E;QAC1E,6DAA6D;QAC7D,MAAM,IAAI,WAAW,CACnB,uBAAuB,EACvB,IAAI,EACJ,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,oBAAoB,CAC9D,CAAC;IACJ,CAAC;IAED,IAAI,MAAe,CAAC;IACpB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC3B,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,WAAW,CACnB,oBAAoB,EACpB,IAAI,EACJ,4EAA4E,CAC7E,CAAC;IACJ,CAAC;IACD,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;QACtB,MAAM,IAAI,WAAW,CAAC,oBAAoB,EAAE,IAAI,EAAE,qCAAqC,CAAC,CAAC;IAC3F,CAAC;IAED,KAAK,MAAM,MAAM,IAAI,aAAa,EAAE,CAAC;QACnC,IAAI,MAAM,IAAI,MAAM,EAAE,CAAC;YACrB,MAAM,IAAI,WAAW,CACnB,wBAAwB,EACxB,IAAI,EACJ,qBAAqB,MAAM,yLAAyL,CACrN,CAAC;QACJ,CAAC;IACH,CAAC;IAED,OAAO;QACL,SAAS,EAAE,QAAQ,CAAC,MAAM,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,IAAI,CAAC;QACvE,EAAE,EAAE,QAAQ,CAAC,MAAM,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,IAAI,CAAC;QAChE,QAAQ;KACT,CAAC;AACJ,CAAC"}
package/dist/config.d.ts CHANGED
@@ -80,7 +80,7 @@ export interface ApiConfig {
80
80
  }
81
81
  /** Where this server reads the verifier's own trust anchor from. */
82
82
  export interface AnchorConfig {
83
- /** `ZIFFER_TRUST_ANCHOR` — path to an `acp-bundle pubkey` document. */
83
+ /** `ZIFFER_TRUST_ANCHOR` — path to a `ziffer pubkey` document. */
84
84
  readonly anchorPath: string;
85
85
  /** `ZIFFER_SUITE_FLOOR` — the CR-4 floor, by wire suite name. */
86
86
  readonly suiteFloor: string;
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,IAAI;;;;;CAKP,CAAC;AAEX;;;;;;;;;;;;;GAaG;AACH,qBAAa,WAAY,SAAQ,KAAK;IACpC,6CAA6C;IAC7C,SAAkB,IAAI,EAAE,MAAM,CAAC;IAC/B,yDAAyD;IACzD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;gBAEd,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAK3D;AAED,+EAA+E;AAC/E,MAAM,WAAW,SAAS;IACxB,oEAAoE;IACpE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;;qEAEiE;IACjE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,oEAAoE;AACpE,MAAM,WAAW,YAAY;IAC3B,uEAAuE;IACvE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,iEAAiE;IACjE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED,4EAA4E;AAC5E,MAAM,MAAM,GAAG,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;AAU/D;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,GAAG,GAAG,SAAS,CA2C7C;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,GAAG,EAAE,kBAAkB,CAAC,EAAE,MAAM,GAAG,YAAY,CAkBhF"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,IAAI;;;;;CAKP,CAAC;AAEX;;;;;;;;;;;;;GAaG;AACH,qBAAa,WAAY,SAAQ,KAAK;IACpC,6CAA6C;IAC7C,SAAkB,IAAI,EAAE,MAAM,CAAC;IAC/B,yDAAyD;IACzD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;gBAEd,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAK3D;AAED,+EAA+E;AAC/E,MAAM,WAAW,SAAS;IACxB,oEAAoE;IACpE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;;qEAEiE;IACjE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,oEAAoE;AACpE,MAAM,WAAW,YAAY;IAC3B,kEAAkE;IAClE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,iEAAiE;IACjE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED,4EAA4E;AAC5E,MAAM,MAAM,GAAG,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;AAU/D;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,GAAG,GAAG,SAAS,CA2C7C;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,GAAG,EAAE,kBAAkB,CAAC,EAAE,MAAM,GAAG,YAAY,CAkBhF"}
@@ -1,3 +1,3 @@
1
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";
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 `ziffer 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### The one refusal in this section that is worth retrying, and it is not `verify`'s\n\n| the decision is refused when | clause | what you do |\n| --- | --- | --- |\n| the receipt's policy basis is not the bundle the Executor holds \u2014 *the window right after you publish policy* | `9.3-4` | **retry once, after the activation window.** This is not a policy refusal |\n\nEverything above this row is your own `verify` refusing a receipt in your process. This one is\nthe deployment refusing, and it has a cause you can plan around: activation is not\ninstantaneous. When your CI publishes a new policy, each process that reads policy picks it up\non its own poll \u2014 15 seconds by default \u2014 so for up to **two intervals, 30 seconds at the\ndefault**, an Executor can still hold epoch N while the engine has already graded under N+1.\nThe receipt then names a policy basis the Executor does not have, and it refuses at `9.3-4`\nrather than executing under a rule set it cannot see. That is the control working.\n\nTwo things follow, and the second is the one people get wrong:\n\n- **Retry once, after the window.** The same proposal under the settled epoch gets a receipt\n that verifies. A single retry after 30 seconds is the whole handling.\n- **Do not treat it as `ALLOW` and do not treat it as a rule saying no.** It is neither: it is\n two halves of the deployment being momentarily one epoch apart. `policy-ci.md` section 7 has\n the publisher's side \u2014 your workflow prints `active` when every reader has the new bundle,\n and that is the moment the window closes.\n\n`9.3-4` outside that window means something else: an Executor that never picked up a bundle, or\na receipt from a different deployment. If it persists after a publish has printed `active`,\nstop and report it with the receipt.\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) 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\"], # ziffer 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**All four npm packages are published, at 0.1.0** (2026-09-14): `@ziffer-io/client`,\n`@ziffer-io/verify`, `@ziffer-io/types` and `@ziffer-io/mcp`. `npm install @ziffer-io/client` is\nthe whole of it; `install.md` section 4 has the install for every machine. The Python package is\non PyPI at the same version: `pip install ziffer`.\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\"\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\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 a `ziffer 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
3
  //# sourceMappingURL=guide-source.d.ts.map
@@ -1 +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"}
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,w/+BAAo++B,CAAC"}
@@ -2,5 +2,5 @@
2
2
  // Gitignored and rewritten on every build: see that script for why the doc is
3
3
  // embedded rather than read from disk at runtime.
4
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";
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 `ziffer 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### The one refusal in this section that is worth retrying, and it is not `verify`'s\n\n| the decision is refused when | clause | what you do |\n| --- | --- | --- |\n| the receipt's policy basis is not the bundle the Executor holds — *the window right after you publish policy* | `9.3-4` | **retry once, after the activation window.** This is not a policy refusal |\n\nEverything above this row is your own `verify` refusing a receipt in your process. This one is\nthe deployment refusing, and it has a cause you can plan around: activation is not\ninstantaneous. When your CI publishes a new policy, each process that reads policy picks it up\non its own poll — 15 seconds by default — so for up to **two intervals, 30 seconds at the\ndefault**, an Executor can still hold epoch N while the engine has already graded under N+1.\nThe receipt then names a policy basis the Executor does not have, and it refuses at `9.3-4`\nrather than executing under a rule set it cannot see. That is the control working.\n\nTwo things follow, and the second is the one people get wrong:\n\n- **Retry once, after the window.** The same proposal under the settled epoch gets a receipt\n that verifies. A single retry after 30 seconds is the whole handling.\n- **Do not treat it as `ALLOW` and do not treat it as a rule saying no.** It is neither: it is\n two halves of the deployment being momentarily one epoch apart. `policy-ci.md` section 7 has\n the publisher's side — your workflow prints `active` when every reader has the new bundle,\n and that is the moment the window closes.\n\n`9.3-4` outside that window means something else: an Executor that never picked up a bundle, or\na receipt from a different deployment. If it persists after a publish has printed `active`,\nstop and report it with the receipt.\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) 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\"], # ziffer 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**All four npm packages are published, at 0.1.0** (2026-09-14): `@ziffer-io/client`,\n`@ziffer-io/verify`, `@ziffer-io/types` and `@ziffer-io/mcp`. `npm install @ziffer-io/client` is\nthe whole of it; `install.md` section 4 has the install for every machine. The Python package is\non PyPI at the same version: `pip install ziffer`.\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\"\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\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 a `ziffer 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
6
  //# sourceMappingURL=guide-source.js.map
@@ -1 +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"}
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,i++BAAi++B,CAAC"}
package/package.json CHANGED
@@ -1,10 +1,18 @@
1
1
  {
2
2
  "name": "@ziffer-io/mcp",
3
- "version": "0.1.0",
4
- "description": "A local MCP server over stdio so a coding agent can integrate the ZIFFER SDK and drive a live decision loop.",
3
+ "version": "0.1.1",
4
+ "description": "A local MCP server so a coding agent can integrate the ZIFFER SDK and drive a live decision loop.",
5
+ "keywords": [
6
+ "ziffer",
7
+ "agent",
8
+ "ai-agent",
9
+ "guardrail",
10
+ "approval",
11
+ "receipt",
12
+ "mcp"
13
+ ],
5
14
  "author": "code75 SASU",
6
15
  "license": "SEE LICENSE IN LICENSE",
7
- "comment-license": "The SDK is proprietary (code75 SASU). \"license\" is the SPDX escape hatch for exactly this case: there is no SPDX identifier for these terms, so the field points at the file that states them, and LICENSE ships in the tarball beside THIRD-PARTY-NOTICES. Do not put an OSI identifier here -- Apache-2.0 stood in these four files until ACP-214 and was wrong the whole time.",
8
16
  "type": "module",
9
17
  "main": "./dist/index.js",
10
18
  "types": "./dist/index.d.ts",
@@ -35,20 +43,15 @@
35
43
  "publishConfig": {
36
44
  "access": "public"
37
45
  },
38
- "comment-no-provenance": "There is deliberately no \"provenance\": true here, and it was removed rather than never added. npm generates a provenance attestation only from a recognised CI runner, and its documentation states plainly that provenance is NOT SUPPORTED for private repositories (docs.npmjs.com/trusted-publishers, read 2026-09-03) -- ziffer-hq/ziffer is private (`gh api` says so). Setting the flag does not degrade to a warning: npm attempts the attestation and the publish FAILS, so the field would have broken the operator's very first publish from a laptop and every CI publish after it. tools/release-npm.sh asserts the field stays absent, and that assertion is the thing to delete on the day this repository becomes public -- at which point trusted publishing generates provenance on its own, with no flag at all.",
39
46
  "ziffer": {
40
- "enginePin": "fed43d10b427e0a4435a8f04e474334d88a5aa6e"
47
+ "enginePin": "8ae262196dbe75210a975a456785df5d9f0524a3"
41
48
  },
42
- "comment-enginePin": "The engine commit this server's verifier and wire types come from, through @ziffer-io/verify and @ziffer-io/client. A COPY of the rev in Cargo.toml, which is the one authority tools/guard.sh reads; tools/release-npm.sh refuses to release when the two differ, by name. tools/bump-pin.sh does not move this field -- see packages/types/package.json for why and what closing it costs.",
43
- "comment-private-removed": "`private: true` is gone (ACP-214). It was here because the whole point is `npx @ziffer-io/mcp` and this package depended on two workspace packages that had no public home, so publishing it would have handed a developer a command that installs and then cannot start. All four now publish together, in dependency order, from tools/release-npm.sh -- @ziffer-io/types, then verify, then client, then this. NOTE WHAT REMOVING THE FLAG DOES AND DOES NOT DO: it makes publication POSSIBLE, not done. Nothing is on the registry until the operator runs the publish lines the release script prints and never executes, and packages/mcp/README.md's 'What does not work yet' says so in those words rather than leaving a reader to infer it from this file.",
44
- "comment-deps": "@modelcontextprotocol/sdk is a new dependency and it IS the protocol -- an MCP server that hand-rolls JSON-RPC framing over stdio is a second implementation of a wire format someone else owns (ACP-197 runbook section 6b point 1 justifies it by name). zod is NOT a fourth choice: it is that SDK's declared peer dependency and the type its registerTool input schemas are written in, so it arrives with the SDK or the SDK does not work. It is confined to the tool-schema layer, which is this package's contracts layer, exactly as .claude/rules/typescript.md requires. @ziffer-io/verify is the ONE home of receipt verification (runbook section 6b point 2: zero verification logic of our own) and @ziffer-io/client the one home of the HTTP surface.",
45
49
  "dependencies": {
46
50
  "@modelcontextprotocol/sdk": "1.30.0",
47
51
  "zod": "4.5.4",
48
- "@ziffer-io/client": "0.1.0",
49
- "@ziffer-io/verify": "0.1.0"
52
+ "@ziffer-io/client": "0.1.1",
53
+ "@ziffer-io/verify": "0.1.1"
50
54
  },
51
- "comment-devdeps": "@noble/curves and @noble/post-quantum are TEST-ONLY here and ship in nothing: explain_receipt's positive path and its 9.3-3 hint both sit BEHIND signature verification, so a test that cannot sign can only ever assert the refusal it stops at first, and those two branches would go to a customer unexercised. They are the versions services/approval and packages/acp-verify already pin, so this adds no new code to the tree -- and the alternative considered and rejected was re-deriving test keys locally, which would be two definitions of one identity. `files: [dist]` is what keeps them out of the tarball; they still appear in the PUBLISHED package.json, because pnpm pack does not strip devDependencies (measured), and that is cosmetic rather than a resolution a consumer performs.",
52
55
  "devDependencies": {
53
56
  "@noble/curves": "2.3.0",
54
57
  "@noble/post-quantum": "0.7.0",