@decidio/sdk 0.6.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,173 @@ the gate, signing, receipts and the verifier - is conformance-locked between the
5
5
  first-run surfaces (the fork, `--demo`, the code sign-in) are TypeScript-first and named as such in the parity
6
6
  allowlist. Each entry names what changed, what a reader must do on upgrade, and what proved it.
7
7
 
8
+ ## 0.7.0 — approvals bind to the exact request they approved; receipts pin to a real trust anchor
9
+
10
+ - **The receipt trust anchor is built in — nobody pastes an issuer DID, and nothing pins to a receipt's own
11
+ claimed issuer** (founder-approved 3A, 2026-09-24). `receipt` and the tour used to pin their offline check to the
12
+ `issuer` field of the server's verify response — which echoes the credential's OWN claim — and never read `valid`:
13
+ a receipt signed by any key that names itself verified "pinned" against itself, and a record the server's own check
14
+ had failed was shown as VALID. Now:
15
+ - `receipt` and the tour pin to `expectedIssuer` — the server's configured key, which the API now returns beside
16
+ `issuer` — else to the DID remembered for that host; they fail (exit 1) when the server says `valid: false`.
17
+ - Sign-in (`login`, the tour's or `init`'s sign-in) and `init` fetch the host's public `/api/did` and remember it in
18
+ the profile beside the stored sign-in (`issuers.json`, owner-only; kept past logout). A later `/api/did` answering
19
+ a DIFFERENT DID is warned about loudly and NOT adopted.
20
+ - `verify` with no `--issuer` pins to that remembered DID for the configured host, else to the built-in
21
+ `KNOWN_ISSUERS` (exported by `@decidio/verify`: the hosted service's issuer only — never the verification kit's
22
+ published sample key), else refuses (exit 2) with the remedy. An explicit `--issuer` always wins, and every run
23
+ prints its trust anchor first. An unreadable `issuers.json` refuses rather than falling back.
24
+ - `@decidio/verify` (and `npx @decidio/verify`) with no `--issuer` pins to the built-in list; a receipt from any other
25
+ issuer is refused (exit 2) with the remedy. `runVerify` takes an optional anchor description and prints
26
+ `trust anchor: …` as its first line.
27
+ On upgrade: nothing to do; scripts that pass `--issuer` behave as before (plus the anchor line on stdout). Deploy the
28
+ API first: against a server without `expectedIssuer`, `receipt` and the tour fall back to the remembered DID and
29
+ otherwise fail closed. Proved by: `cli-smoke.ts` (a forged receipt naming itself fails the tour and is never offered
30
+ as the anchor; `valid:false` fails `receipt` and the tour; init/sign-in remember `/api/did`; flagless verify pins to
31
+ it; `--issuer` wins; a changed DID is not adopted; nothing remembered → exit 2 naming the built-in anchor; an
32
+ unreadable store refuses), `decidio-verify/smoke.ts` (the built-in list's content and refusals), the three-door
33
+ conformance run (every door names the same built-in anchor), and a claims tombstone for the retired
34
+ "refuses to run without --issuer" sentence.
35
+
36
+ - **3A follow-up (independent invariant-guard review, 2026-09-24).**
37
+ - **The remembered DID wins over a server that names another.** `receipt` and the tour compared nothing: a server whose
38
+ `expectedIssuer` differed from the DID remembered for its host was followed, so "not adopted" held at sign-in and
39
+ failed one command later. Now the same loud HAS CHANGED warning prints and the pin stays the remembered DID (the
40
+ receipt then fails its offline check: `receipt` exits 1, the tour fails). An unreadable `issuers.json` now yields no
41
+ anchor at all rather than the server's.
42
+ - **`receipt` checks the file offline against the anchor it prints.** It honoured the server's `valid` but never
43
+ checked the credential against the anchor it recommended - a server naming the built-in DID over another key's
44
+ credential got "No --issuer needed" and exit 0 while that flagless verify then refused. Any failure now behaves like
45
+ `valid:false`: exit 1, the file kept for inspection only.
46
+ - **One host rule.** `verify` resolves its remembered DID for `DECIDIO_API_URL` from the shell (it reads no `.env`),
47
+ and `receipt` prints the flagless command only when that verify lands on the same anchor - a host set only in `.env`
48
+ gets the explicit `--issuer` form and says why.
49
+ - **`issuers.json` is keyed by URL origin** (scheme + lowercased host + non-default port, no path, no trailing slash):
50
+ `HTTP://Host:80/` and `http://host` are one entry, so a variant spelling cannot pass as a first sighting and skip
51
+ the change warning.
52
+ - **Output contract.** `receipt --json`'s `issuer` is the trust anchor the file was checked against (null against an
53
+ older server with nothing remembered), and `valid` is false when the server's check or the offline check failed.
54
+ `verify`'s stdout starts with a `trust anchor: <did> (<source>)` line before the verdicts.
55
+ Proved by: `cli-smoke.ts` +9 (a server naming a different key than the remembered DID: warned, not followed, receipt
56
+ exit 1, tour fails; a server naming the built-in DID over another key's credential: exit 1, never "No --issuer
57
+ needed"; the flipped forged vector now exits 1; a variant spelling of the host is the same entry; eight origin-key
58
+ cases shared with Python; `.env` never chooses verify's anchor; the flagless command follows verify's host; an issuer
59
+ store with `"v": true` is refused; the update gate's switches come from the shell) - 8 of them red against the
60
+ previous commit's code; the other three are the twins of Python-only bugs the TS CLI never had. The release workflow
61
+ now fails if `KNOWN_ISSUERS` (TS or Python) differs from the live `https://decidio-api.onrender.com/api/did`.
62
+ - **3A follow-up, round 2 (guard re-review, 2026-09-24) — same hostile-`.env` threat model.**
63
+ - **The session and the trust store live where the SHELL says.** `sessionDir()` (and the terminal-id read) took
64
+ `XDG_CONFIG_HOME`/`LOCALAPPDATA`/`HOME` from the environment *after* `.env` was promoted, so a project `.env` with
65
+ `XDG_CONFIG_HOME=.cfg` put the session token and `issuers.json` inside the repo — and let the repo plant a DID that
66
+ then showed as "remembered on this computer". Both now read the shell snapshot.
67
+ - **A project `.env` promotes only `DECIDIO_*`.** It configures Decidio; it does not reconfigure the OS, toolchain or
68
+ network stack. A cloned repo's `.env` can no longer inject `HTTP(S)_PROXY`/`NO_PROXY` (which would route this CLI's
69
+ authenticated calls, bearer token and all, through the repo's server), TLS-trust or CA-bundle variables,
70
+ `NODE_OPTIONS`/`GIT_*` code loaders, or the session-dir variables into the environment a network command runs with.
71
+ A switch genuinely wanted is set in the shell, which still wins.
72
+ - **The cached update-check value is validated like the network answer** (a full `x.y.z`), and `semverNewer` requires
73
+ exactly three all-digit components: a planted `update-check.json` with trailing bytes can no longer be printed as an
74
+ available-version notice, escape codes and all, or used to force an upgrade.
75
+ - **`issuerStoreKey` fails closed** on host spellings it cannot canonicalise identically to the Python twin
76
+ (percent-encoding, IDN, numeric-shorthand IPs, backslashes): it no longer uses the browser URL's canonicalisation,
77
+ which mapped `https://%64ecidio-api.onrender.com` onto the real host — a planted store entry under an exotic spelling
78
+ is never trusted as the real Decidio.
79
+ - **An `issuers.json` whose version literal is `1.0`** (a float, not the integer `1`) is refused, matching Python: JS
80
+ cannot tell `1.0` from `1` after `JSON.parse`, so the raw token is checked.
81
+ - **`receipt --json` reports `valid: null` when no offline check ran** (no anchor — e.g. an unreadable store): the
82
+ server's own verdict alone is not enough to call a receipt verified, because the point is the offline pin.
83
+ Proved by: `cli-smoke.ts` +5 (session/trust-store location; `.env` promotes only `DECIDIO_*`; the poisoned cache; the
84
+ `1.0` version literal; `receipt --json` valid:null) plus 7 new origin-key cases, all red against the previous commit's
85
+ code. The release workflow's live-DID gate now uses `curl --retry-all-errors` and emits an explicit `::error::` when
86
+ the host is unreachable or the body is malformed.
87
+
88
+ ### An approval is bound to the exact request it approved (#4B, BREAKING)
89
+
90
+ **Breaking.** Read "On upgrade" before bumping. The version is not bumped here; this ships as the next minor.
91
+
92
+ - **Every door confirms with Decidio that THIS request was approved before it runs it** — inline (the blocking
93
+ wait), the durable webhook and worker, LangGraph, Temporal, Inngest and OpenAI Agents, through one check
94
+ (`checkApproval`, exported; the adapters share `adapters/_gate.ts`). `GET /agent/status` must answer
95
+ `approved`/`auto_approved` AND return the request commitment this SDK sent with `/agent/authorize`, recomputed
96
+ over the request about to run. A resume webhook, a Temporal signal, an Inngest event or a LangGraph resume value
97
+ is now only a WAKE-UP; before, each of them was the authority, so anything able to send one approved the action.
98
+ A mismatch throws `DecidioRequestChangedError` ("the request changed after approval", naming the decision); a
99
+ gate that returns no commitment for a request this SDK bound throws `DecidioApprovalUnverifiedError`
100
+ (`code: "commitment_missing"`; `"unverifiable"` when there is no key material to recompute it). Both extend
101
+ `DecidioBlockedError`, so existing `catch` blocks still see a refusal. The durable resolve reports
102
+ `not_approved` (Decidio still says pending - the entry stays parked for the next wake-up) or
103
+ `request_unverified` (refused, kept parked, named once). Because it now asks Decidio first, **`resolve()` THROWS
104
+ when Decidio is unreachable** (nothing ran; the entry stays parked) - callers must catch it.
105
+ - **The commitment is keyed, never a digest** (invariant I5): lowercase-hex HMAC-SHA256 over the canonical
106
+ request (`committedRequestJson`) under a subkey of a secret only the agent holds - the Ed25519 signing key when
107
+ `agentKey`+`agentDid` are configured, else the API token (`bindingMaterial`, `requestCommitment`). An unkeyed
108
+ sha256 would sit in Decidio's database as a dictionary-attackable fingerprint of the request. Byte-identical to
109
+ the Python twin: shared vectors in `packages/conformance/vectors/canonical.json` (`commitments`), consumed by
110
+ both runners. A keyless agent sends none and is checked on Decidio's state alone. **Prefer the Ed25519 key for
111
+ binding**: under the API-token fallback the key is a bearer Decidio receives on every call, so the commitment is
112
+ opaque to everyone except the Decidio server itself. **Rotation strands pending approvals**: re-minting the
113
+ token, changing `DECIDIO_AGENT_KEY`, or a guard and resume controller with different key material turns every
114
+ routed approval still pending into `request_changed` - drain before rotating, or ask again.
115
+ - **LangGraph: the $100 -> $1,000,000 resume bug is closed.** LangGraph re-runs an interrupted node on resume, so
116
+ the node authorized AGAIN - minting a phantom second decision nobody saw - and then ran whatever it computed the
117
+ second time under the first approval: approve $100, edit the graph state, resume, and $1,000,000 executed (and
118
+ was confirmed under the phantom). Now the re-run authorizes with an idempotency key derived from its own thread +
119
+ checkpoint namespace + call index + commitment (`idempotencyKey`), Decidio answers with the SAME decision, the
120
+ resume value must name that decision, and the check runs. An edited state is a new task and a new request: it
121
+ becomes a NEW pending decision and the graph pauses on it; the old approval is never applied across requests.
122
+ `decidioResumeCommand(body)` now carries - and requires - the webhook's `decisionId`. The gate needs key material
123
+ and a checkpointed run with a `thread_id` and the node execution's scratchpad; without them it refuses (before
124
+ sending anything, or before pausing on a decision it could never find again, naming it - it never falls back to
125
+ a constant call index).
126
+ - **Temporal**: `DecidioGateOpts.verify` runs the check (as your activity) after the signal and before `run()`;
127
+ it is REQUIRED whenever `authorize` is supplied (refused before anything is routed). The activity body is
128
+ exported: `verifyApprovalActivity(config, decisionId, ctx)`. A signal only wakes the workflow; still pending
129
+ -> it waits for the next signal.
130
+ - **Inngest**: after each wait the check runs as a memoized step (`decidio-verify-approval`); a premature or
131
+ forged event waits again (later waits are `decidio-await-approval-1`, `-2`, ...: deterministic across replays).
132
+ - **OpenAI Agents: `applyResume(state, item, verdict)` is REMOVED**, not deprecated. Its only input was a verdict
133
+ string, so no deprecation shim could check anything - keeping it would keep the hole. Use
134
+ `await applyVerifiedResume(config, state, item, decisionId, describe)`: it approves the interruption only when
135
+ Decidio records that decision as approved for THIS tool call, rejects on a rejection, and otherwise throws with
136
+ the RunState unmodified. The approval is bound to the call itself - its name, call id and exact argument string
137
+ from `rawItem`, beside `describe(item)` (`toolCallRequest`) - so a parked item edited after approval (a target
138
+ `describe()` never covered) and a second identical call whose own decision is still pending are both refused
139
+ (`request_changed`); binding `describe(item)` alone let both through (#4B review W2). `pending[]` now carries
140
+ each `callId`; an interruption with no call id is refused before anything is sent.
141
+ - **The resume webhook no longer falls back to the API token** (`webhookHandler`, `fetchHandler`): with no
142
+ `webhookSecret` (and no `allowUnsigned`) it answers 401 naming the fix - a dedicated secret, **never the Decidio
143
+ server's `API_AUTH_TOKEN`** (its admin bearer). The token is a credential the agent SENDS; accepting webhooks
144
+ signed with it let anyone holding it drive resumes. The LOCAL integrity signature on parked payloads keeps its
145
+ token fallback, so entries parked before the upgrade still verify.
146
+ - **Known limit (predates this release): one gated action per LangGraph node.** The node re-runs on resume, so an
147
+ earlier gate in it runs again; one whose request auto-approved (action already run) replays as `proceed` to a
148
+ verified agent and its action runs a second time when a later gate in the node resumes. Put each gated action
149
+ in its own node, or make the actions idempotent.
150
+ - **Known limit (predates this release): LangGraph and OpenAI Agents have no single-execution claim.** Two
151
+ concurrent resumes of the same thread or RunState can both pass the check and both run the action; resume each
152
+ from one place. The core durable path claims each execution in its store.
153
+ - The demo gate (`demo-gate.ts`, the `--demo` stand-in) stores and returns commitments like the real gate.
154
+
155
+ **On upgrade:** (1) deploy the Decidio gate from this release FIRST - an older gate returns no commitment and a
156
+ keyed SDK refuses its routed approvals (`commitment_missing`: fail closed, never a silent pass). (2) Webhook
157
+ deployments: set `DECIDIO_WEBHOOK_SECRET` on both sides, or switch to `worker()`. (3) LangGraph: run with a
158
+ checkpointer + `thread_id`, and build the resume with `decidioResumeCommand(webhookBody)`. (4) Temporal: add the
159
+ `verify` activity. (5) OpenAI Agents: replace `applyResume` with `applyVerifiedResume`, park each pending
160
+ `{ callId, decisionId }` and find items again by `rawItem.callId`. (6) A test double of the gate must return
161
+ `requestCommitment` from `/agent/status`. (7) Catch errors from `resolve()`. (8) Drain pending approvals before
162
+ rotating the agent token or key. Entries parked by 0.6.x carry no commitment and are checked on Decidio's state
163
+ alone.
164
+
165
+ Proved by: `smoke.ts` (the request-binding block: approved-for-another-request, gate drops the commitment,
166
+ keyless, rejected/pending wake-ups, the durable webhook and worker), `adapters-smoke.ts` (the REAL
167
+ `@langchain/langgraph`: unedited resume = one decision; edit after approval = a new pending decision and
168
+ nothing executed; rejected; keyless refused; no scratchpad refused; Inngest forged event; Temporal verify
169
+ required; OpenAI verified resume incl. an edited target and a second identical call), `inngest-smoke.ts` (a forged
170
+ event executes nothing), both conformance runners (commitment vectors incl. the OpenAI tool-call binding built by
171
+ each SDK from its own native item, every door with a gate that returns the commitment), and `conformance/run.mjs` -
172
+ which caught the two twins committing different bytes for the same call (Python sent an empty `kwargs` member)
173
+ before this shipped.
174
+
8
175
  ## 0.6.4 — the request closes when the agent gives up
9
176
 
10
177
  - **The README links the agent skill file** published on the site (https://decidioai.com/skills/decidio-guard.md, one page for both languages) and the site's llms.txt. The file is served by the site, not shipped in the package; an in-package copy is a one-line publish-boundary decision for this release if wanted.
package/README.md CHANGED
@@ -154,21 +154,30 @@ becomes the record's execution evidence.
154
154
 
155
155
  ```bash
156
156
  npx @decidio/sdk receipt <decisionId>
157
- npx @decidio/sdk verify --issuer <did:key:...> decidio-receipt-<decisionId>.json
157
+ npx @decidio/sdk verify decidio-receipt-<decisionId>.json
158
158
  ```
159
159
 
160
- Lead with the pin: `--issuer` binds the check to YOUR workspace's DID (the `receipt` command
161
- prints it from the authenticated response, so it is already on your screen). Since 0.5.0 the
162
- CLI refuses to run without it: an UNPINNED check proves internal consistency only — any keyholder
163
- could have issued such a file — so it is a named choice (`--allow-unpinned`), and its verdict says
164
- so; pinned verification proves *who*. The download is an authenticated fetch from your
160
+ No DID to paste: `verify` pins to a **trust anchor** and prints it first. In order: an explicit
161
+ `--issuer <did:key>` if you give one; else the issuer DID this computer remembered for your Decidio
162
+ when you signed in (`login`, or the sign-in `init`/the tour asks for) — fetched from the host's
163
+ public `<api>/api/did` and kept in your profile beside the stored sign-in; else the built-in
164
+ issuer of Decidio's hosted service (`did:key:z6MkmLGeR5NjJ87a1yU5ygqju49CtnUrt38esqM54Y9tXnKq`,
165
+ published at https://decidio-api.onrender.com/api/did). A receipt's own `issuer` field is never the
166
+ anchor — it is only what the file claims, and any key can sign a file that names itself. If a later
167
+ sign-in sees the host publish a DIFFERENT DID - or a server's verify response names one - the CLI
168
+ says so loudly and keeps the remembered one: a new key is confirmed with the host's operator, not
169
+ adopted silently. The `receipt` command pins to the DID remembered for the host, else the DID the
170
+ server names as its own (`expectedIssuer`); checks the file offline against it; fails (exit 1) when
171
+ that check or the server's own check says the record does not verify; and prints the flagless verify
172
+ command only when `verify` - which takes its host from your shell's `DECIDIO_API_URL`, never `.env` -
173
+ would land on the same DID. An UNPINNED check proves internal consistency only, so it stays a named choice
174
+ (`--allow-unpinned`), and its verdict says so. The download is an authenticated fetch from your
165
175
  workspace; the *verification* is fully offline — the verifier ships inside this package (no second download, no registry probe, same
166
176
  implementation as the standalone `@decidio/verify`). The receipt is a W3C Verifiable
167
177
  Credential: signature and content binding verify with no Decidio account and no network — the
168
- evidence is yours, not ours. To also prove *who* issued it, pin your workspace's issuer:
169
- `npx @decidio/sdk verify --issuer <did:key:...> receipt.json` (the DID is shown in your app's
170
- record verify panel — or simply read it off the `receipt` command's output, which prints your
171
- workspace's issuer DID from the authenticated response so you never have to hunt for it).
178
+ evidence is yours, not ours. On another machine, pin the same DID explicitly:
179
+ `npx @decidio/verify --issuer <did:key:...> receipt.json` (receipts from the hosted service need no
180
+ flag there either — the anchor is built in).
172
181
 
173
182
  **What VALID means — and what it does not.** A pinned `VALID` proves the receipt's **claims** were
174
183
  sealed by your workspace's issuer and that none of them has changed since. Not its *bytes*: the
@@ -320,8 +329,10 @@ executed, so without a second submission there is nothing left to approve.
320
329
  those two, a cold worker has an empty registry and reports `no_handler` (the parked action is
321
330
  kept, not lost, and the message names the exact fix).
322
331
 
323
- **The signed webhook (operator deployments).** Decidio POSTs a signed verdict to your agent's
324
- `resumeUrl`; the handler verifies the HMAC signature fail-closed, then re-runs your function:
332
+ **The signed webhook (operator deployments).** Decidio POSTs a signed wake-up to your agent's
333
+ `resumeUrl`; the handler verifies the HMAC signature fail-closed, asks Decidio for the decision, and
334
+ re-runs your function only if Decidio says THIS request was approved (the verdict inside the webhook
335
+ is never the authority — see "Every door confirms the approval" below):
325
336
 
326
337
  ```js
327
338
  // Express: app.post("/decidio/resume", guard.resumeHandler())
@@ -332,7 +343,12 @@ Honest requirement: webhook signing uses a **shared secret configured on both si
332
343
  `DECIDIO_WEBHOOK_SECRET` must equal the Decidio server's, and self-hosted production deployments
333
344
  also allow-list resume hosts (default-deny). That's operator territory: if you run your own
334
345
  Decidio (or we run a pilot with you), the webhook is set up then. Against the hosted sandbox,
335
- use the worker — a webhook secret your server never learned would (correctly) fail closed.
346
+ use the worker — a webhook secret your server never learned would (correctly) fail closed. Since
347
+ 0.7.0 there is **no API-token fallback**: without `webhookSecret` (or `allowUnsigned`, for a trusted
348
+ network only) the handler answers 401 and names the fix. The worker needs no inbound secret. The
349
+ secret is a dedicated one — **never the Decidio server's `API_AUTH_TOKEN`** (its admin bearer, which
350
+ must not be copied onto an agent host); since this release the server sends no webhook at all without
351
+ `DECIDIO_WEBHOOK_SECRET`.
336
352
 
337
353
  **Earning the strongest evidence tier.** Decidio records `application_confirmed` — its
338
354
  strongest wrapper tier — only when your function's captured response shows a committed record:
@@ -475,6 +491,64 @@ imports none of them — install the engine you use alongside the SDK (`npm i in
475
491
  for engines you don't. Each adapter's shipped `.d.ts` header documents its wiring; `npx
476
492
  @decidio/sdk quickstart` is the fastest runnable end-to-end.
477
493
 
494
+ ### Every door confirms the approval is for THIS request (0.7.0)
495
+
496
+ Before 0.7.0 a resume ran on the word of whatever woke it — a webhook, a Temporal signal, an Inngest
497
+ event, a LangGraph resume value saying "approved". Now every door (inline, the durable webhook and
498
+ worker, LangGraph, Temporal, Inngest, OpenAI Agents) asks Decidio before your function runs:
499
+ `GET /agent/status` must say `approved` (or `auto_approved`) **and** return the request commitment
500
+ this SDK sent with `/agent/authorize`, recomputed over the request about to run. A wake-up is only a
501
+ wake-up.
502
+
503
+ - **The commitment** is an HMAC-SHA256 under your agent's key material over the canonical request:
504
+ `{action, amount, scope, args}` on the core wrapper, the described context on an engine adapter
505
+ (plus the tool call itself on the OpenAI door). With the Ed25519 key `init` writes
506
+ (`DECIDIO_AGENT_KEY` + `DECIDIO_AGENT_DID`) only your agent holds the key, and Decidio stores and
507
+ returns an opaque value it can neither read nor forge for another request. **Prefer that key.** Without
508
+ it the commitment is keyed by `DECIDIO_API_TOKEN` — a bearer Decidio receives on every call — so it is
509
+ opaque to everyone except the Decidio server itself.
510
+ - **Rotating key material strands pending approvals.** Re-minting the agent token, changing
511
+ `DECIDIO_AGENT_KEY`, or running the guard and the resume controller with different key material makes
512
+ every routed approval still pending fail its check as `request_changed` (the recompute no longer
513
+ matches). Drain pending approvals before rotating, or ask again for each afterwards.
514
+ - **Refusals** (both extend `DecidioBlockedError`; nothing ran): `DecidioRequestChangedError` —
515
+ "the request changed after approval", naming the decision — and `DecidioApprovalUnverifiedError`
516
+ (`code: "commitment_missing"` when Decidio returns no commitment for a request this SDK bound, or
517
+ `"unverifiable"`). The durable path reports `not_approved` (Decidio still says pending: the action
518
+ stays parked) or `request_unverified`. **`resolve()` now asks Decidio before it runs, so it THROWS when
519
+ Decidio is unreachable** (nothing ran; the entry stays parked) — catch it and retry, or leave the entry
520
+ for the worker.
521
+ - **LangGraph** needs key material (`init`, or `DECIDIO_API_TOKEN`) and a checkpointer run with a
522
+ `thread_id` (a routed gate without the run's thread, task and per-execution scratchpad refuses, naming
523
+ its decision). LangGraph re-runs an interrupted node on resume; the re-run authorizes with an
524
+ idempotency key derived from its own thread + task + commitment, so Decidio answers with the SAME
525
+ decision instead of minting a second one. Edit the graph state and the edited request becomes a
526
+ NEW pending decision — an approval is never applied across requests. `decidioResumeCommand` now
527
+ requires the webhook's `decisionId`.
528
+ - **Temporal**: whenever you pass `authorize`, also pass `verify: (id) => executeActivity(verifyActivity, id)`,
529
+ where the activity returns `verifyApprovalActivity(config, id, ctx)` (from `@decidio/sdk/temporal`)
530
+ for the same `ctx`. A signal only wakes the workflow; still pending → it waits for the next one.
531
+ - **Inngest**: the check runs as a memoized step after the event; a premature or forged event waits again.
532
+ - **OpenAI Agents**: `applyResume(state, item, verdict)` is removed. Use
533
+ `await applyVerifiedResume(config, state, item, decisionId, describe)`, which approves the
534
+ interruption only when Decidio records that decision as approved for THIS tool call: the approval is
535
+ bound to the call's name, call id and exact argument string as well as `describe(item)`, so an item
536
+ edited after approval, or a second identical call, never rides it. Park each pending
537
+ `{ callId, decisionId }` (both on `authorizeInterruptions(...).pending`) with the serialized state and,
538
+ after `RunState.fromString`, find each item again by `item.rawItem.callId`. An interruption with no
539
+ call id is refused before anything is sent.
540
+ - **Known limit — one gated action per LangGraph node** (true before 0.7.0 too). LangGraph re-runs the
541
+ whole node on resume, so an earlier gate in the same node runs again: if its request auto-approved and
542
+ its action already ran, the replay answers `proceed` to your verified agent and the action runs twice.
543
+ Put each gated action in its own node, or make the actions idempotent.
544
+ - **Known limit — LangGraph and OpenAI Agents have no single-execution claim** (true before 0.7.0 too).
545
+ The checkpoint or the RunState is your storage, so two concurrent resumes of the same thread or run
546
+ can both pass the check and both run the action. Resume each one from one place (one worker per
547
+ thread/run id, or a lock). The core durable path claims each execution in its store; Temporal and
548
+ Inngest rely on their engine's own activity/step semantics.
549
+ - **Upgrade the gate first.** A keyed SDK refuses a routed approval from a Decidio gate that does not
550
+ return commitments (`commitment_missing`) — it fails closed, never silently passes.
551
+
478
552
 
479
553
 
480
554
 
@@ -561,11 +635,13 @@ sealed after a specific predecessor, but a single-file verification cannot check
561
635
  exists or is what it claims. Verify a chain (`verify --issuer <did> a.json b.json c.json`) when the sequence
562
636
  matters.
563
637
 
564
- **Pinning is trust-on-first-use.** The first time you fetch a receipt, the record *and* the issuer
565
- DID arrive in the same authenticated response — so that first check trusts the connection, not the
566
- key. What pinning buys is everything afterwards: record the DID once, out of band if you can, and
567
- every later verification is checked against a key you chose rather than one you were handed. The
568
- verifier refuses an unpinned run unless you ask for one (`--allow-unpinned`), and marks that verdict.
638
+ **Pinning is trust-on-first-use.** The first sign-in (or `init`) against a Decidio remembers the DID
639
+ it publishes at `<api>/api/did` — so that first contact trusts the connection, not the key. What
640
+ remembering buys is everything afterwards: every later verification is checked against a key this
641
+ computer already holds rather than one it was handed with the receipt, and a host that starts
642
+ publishing a different key is flagged, never silently adopted. Receipts from Decidio's hosted service
643
+ also verify against the issuer built into the verifier. The verifier refuses an unpinned run unless you
644
+ ask for one (`--allow-unpinned`), and marks that verdict.
569
645
 
570
646
  ## Upgrading from 0.2.x to 0.3.0
571
647
 
@@ -653,7 +729,7 @@ that fits every other confirm error.
653
729
  - `doctor` — config, connectivity, and what your current token can do
654
730
  - `approvals [--mine] [--json]` / `approvals approve|reject <id> [reason]` — the human side, from your terminal (the list is workspace-wide; `--mine` narrows it to this directory's agent)
655
731
  - `receipt <id> [--json]` — download a sealed decision's Authority Receipt to a `.json` file (`--json`: stdout is the JSON result only; the identities your access covers print only on a terminal, or in the JSON)
656
- - `verify [--issuer <did>] <file>...` — verify receipts offline (the canonical verifier; the standalone `@decidio/verify` package is the same implementation, for auditors who want zero Decidio dependency — the sdk depends on the verifier released alongside it, at the same version). Unknown flags are refused, never ignored. Fully offline AND fully decoupled: it touches no network and reads no `.env` — a receipt verifies identically from an empty directory.
732
+ - `verify [--issuer <did>] <file>...` — verify receipts offline (the canonical verifier; the standalone `@decidio/verify` package is the same implementation, for auditors who want zero Decidio dependency — the sdk depends on the verifier released alongside it, at the same version). Unknown flags are refused, never ignored. It pins to the trust anchor it prints first: `--issuer`, else the DID remembered for your Decidio at sign-in, else the built-in hosted-service issuer — never the receipt's own `issuer` field. Fully offline AND fully decoupled: it touches no network and reads no `.env` (only the issuer DIDs remembered in your profile) — a receipt verifies identically from any directory.
657
733
  - `tokens list [--mine]` / `tokens revoke <jti>` — see and kill agent tokens (values are never re-shown; the jti is the revocation handle). Each row carries its status — `active`, `expired`, or `REVOKED <date>` — so the list confirms the revocations it performs; re-revoking is an honest no-op (exit 1). The list is workspace-wide (a shared workspace shows every member's agents); `--mine` narrows to this directory's agent
658
734
  - `quickstart cleanup` — revoke the tour's token and delete its local credentials. What stays, honestly: sealed records (permanent by design) and the agent's registration row (inert without key or token; server-side deregistration is on the roadmap) Also ends the stored sign-in.
659
735
  - **Update check.** Network commands ask the public npm registry once a day whether a newer version exists; if so, an interactive run asks `Upgrade now? [y/n]` first (`y` upgrades the way you installed - `npm i @decidio/sdk@latest` in the project for a local install, `-g` for a global one, and under `npx` it prints the `@latest` form - then re-runs your command once; `n` exits), a headless run prints the notice and continues (`DECIDIO_REQUIRE_LATEST=1` makes it exit). `DECIDIO_NO_UPDATE_CHECK=1` skips it. Nothing is sent to Decidio; `verify` never checks.
@@ -679,6 +755,8 @@ manager for production.
679
755
 
680
756
  - `DecidioBlockedError` — policy blocked the action: a named block rule, or the universal backstop that fires when NO active policy governs agents at all (fail closed, no human involved). Distinct from routing-by-default — with an active policy and no matching auto-approve rule, a request is not blocked, it is ROUTED to a person.
681
757
  - `DecidioRejectedError` — a human rejected the routed action.
758
+ - `DecidioRequestChangedError` (0.7.0, a `DecidioBlockedError`) — Decidio approved the decision for a *different* request than the one about to run ("the request changed after approval"); nothing executed. Ask again for the request you mean to run.
759
+ - `DecidioApprovalUnverifiedError` (0.7.0, a `DecidioBlockedError`) — the approval could not be tied to this request: `code: "commitment_missing"` (Decidio returned no request commitment although this SDK sent one — typically a gate older than 0.7.0) or `"unverifiable"`. Nothing executed.
682
760
  - `DecidioSuspendedError` — (durable mode) the action was routed and is parked for async approval; the process may exit and resume later. Not a failure.
683
761
  - `DecidioTimeoutError` — (blocking mode only) no human decided within `pollTimeoutMs` (default 10 min); nothing executed locally. Since 0.6.4 the SDK's last act before throwing is to tell Decidio it gave up, so the request is closed as **withdrawn** (it leaves the queue; a later approval does nothing) — `error.withdrawn` is `true` when that landed and `false` when the call failed and the request may still be open. Withdrawing needs the bound agent token and the signing key `init` writes (an unbound or unsigned caller cannot withdraw anything — it just gets `withdrawn:false`). If a human decided in the last poll window, the SDK honours that decision instead: the action runs as approved, or `DecidioRejectedError` is thrown. The durable path never withdraws: its parked action is what a later approval resumes.
684
762
  - `DecidioRateLimitError` — the workspace's governed-action limit was hit; carries the server's `limit` / `remaining` / `resetAt`.
@@ -712,10 +790,12 @@ the next reader grades a promise, not a moving target:
712
790
  - **Engine adapters are Beta and outside the stability promise.** They pass the same conformance
713
791
  battery as the core against structural mocks; no live LangGraph/Temporal/Inngest/OpenAI restart suite
714
792
  exists yet. Use the core path for production until it does.
715
- - **The issuer DID is trust on first use.** The tour and `receipt` print your workspace's issuer DID
716
- from the same authenticated response that carries the receipt. Pinned verification is only as strong
717
- as where you keep that DID: record it through a channel you trust independently (your app's
718
- record-verify panel, and published in the open by the Decidio host you use at `<api>/api/did` — for the hosted sandbox https://decidio-api.onrender.com/api/did).
793
+ - **The issuer DID is trust on first use.** Sign-in and `init` remember the DID your Decidio publishes at
794
+ `<api>/api/did`; the tour and `receipt` pin to the DID the server names as its own, never to the one
795
+ the receipt claims. Pinned verification is only as strong as that first contact: cross-check the DID
796
+ through a channel you trust independently (published in the open by the Decidio host you use at
797
+ `<api>/api/did` — for the hosted sandbox https://decidio-api.onrender.com/api/did, which is also the
798
+ issuer built into the verifier).
719
799
  - **Verification is cryptographic, not schema validation.** `VALID` means the issuer's key signed these
720
800
  canonical claims; the sealed-payload schema the receipt names by hash is not shipped, so a
721
801
  structurally odd but correctly signed document is not refused on shape. Shipping the schema is on
@@ -1,4 +1,4 @@
1
- import { type GuardConfig, type ApprovalContext, type EvidenceOutcome } from "../index.js";
1
+ import { type GuardConfig, type ApprovalContext, type EvidenceOutcome, type ApprovalCheck, type CommittedRequest } from "../index.js";
2
2
  /** The ONLY authorization verdicts this SDK understands. A gate answer outside this set means
3
3
  * the SDK does not understand the decision — and an answer you do not understand means NO. */
4
4
  export declare const AUTHORIZE_VERDICTS: readonly ["proceed", "route", "block"];
@@ -15,7 +15,32 @@ export interface GateAuth {
15
15
  * how an unknown verdict reached a protected action. Version skew (the day the gate learns a
16
16
  * fourth verdict) is the realistic trigger, and it must never mean "run it". */
17
17
  export declare function assertAuthorizeVerdict(auth: unknown, fallbackDecisionId?: string): GateAuth;
18
- export declare function gateAuthorize(config: GuardConfig, ctx: ApprovalContext): Promise<GateAuth>;
18
+ /** What an ENGINE door binds a routed approval to (0.7.0): the whole described context, canonicalized
19
+ * exactly as gateAuthorize canonicalizes it. An adapter cannot see inside its `run()` closure, so the
20
+ * described request - action, amount, scope and the display details a human approved - is the request.
21
+ * Recompute it from the SAME describe() the door authorized with. */
22
+ export declare function adapterRequest(ctx: ApprovalContext): CommittedRequest;
23
+ /** What the OpenAI Agents door binds a routed approval to (#4B review W2): the described context AND the
24
+ * tool call itself - its name, its call id and its exact argument string, from the interruption's raw
25
+ * item. describe(item) is the host's summary and need not cover every argument (edit a parked item's
26
+ * target and it can stay the same), and it never says WHICH call: without the call id one approval could
27
+ * be applied to a second, identical call whose own decision is still pending. An item with no call id
28
+ * cannot be bound to one call, so it is refused - nothing routed, nothing approved. (Python twin:
29
+ * tool_call_request.) */
30
+ export declare function toolCallRequest(item: unknown, ctx: ApprovalContext): CommittedRequest;
31
+ /** THE ONE APPROVAL CHECK on an engine door: GET /agent/status + the request commitment recomputed over
32
+ * this `ctx` (see checkApproval) - or over `request` when the door binds more than the described context
33
+ * (the OpenAI door's toolCallRequest). Returns the plain-JSON outcome - an engine that must run HTTP
34
+ * inside an activity or a step (Temporal, Inngest) memoizes exactly this value. */
35
+ export declare function gateCheckApproval(config: GuardConfig, decisionId: string, ctx: ApprovalContext, request?: CommittedRequest): Promise<ApprovalCheck>;
36
+ /** Validate an approval check that crossed a serialization boundary (a Temporal activity result, an
37
+ * Inngest step memo). Anything that is not a well-formed check is refused as unverifiable - the same
38
+ * "an answer you do not understand is never permission" rule the verdict allow-list applies. */
39
+ export declare function assertApprovalCheckShape(raw: unknown, decisionId: string): ApprovalCheck;
40
+ export declare function gateAuthorize(config: GuardConfig, ctx: ApprovalContext, opts?: {
41
+ idempotencyKey?: string;
42
+ request?: CommittedRequest;
43
+ }): Promise<GateAuth>;
19
44
  /** Report an adapter-path execution and RETURN what Decidio recorded.
20
45
  *
21
46
  * Two 0.2.0 changes, both closing the same gap. It applies the caller's `evidence` mapper — the
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, carriesCredential, canonicalize, assertDescribed, readConfirmResponse, buildConfirmBody, decisionIdProblem } from "../index.js";
2
+ import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, carriesCredential, canonicalize, assertDescribed, readConfirmResponse, buildConfirmBody, decisionIdProblem, bindingMaterial, requestCommitment, checkApproval } from "../index.js";
3
3
  export const AUTHORIZE_VERDICTS = ["proceed", "route", "block"];
4
4
  export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
5
5
  const a = auth;
@@ -13,7 +13,34 @@ export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
13
13
  }
14
14
  return a;
15
15
  }
16
- export async function gateAuthorize(config, ctx) {
16
+ export function adapterRequest(ctx) {
17
+ const c = canonicalize(ctx, "describe(...)");
18
+ return { action: c.action, amount: c.amount, ...(c.scope !== undefined && c.scope !== null ? { scope: c.scope } : {}), ...(c.context !== undefined && c.context !== null ? { context: c.context } : {}) };
19
+ }
20
+ export function toolCallRequest(item, ctx) {
21
+ const it = (item && typeof item === "object" ? item : {});
22
+ const raw = (it.rawItem && typeof it.rawItem === "object" ? it.rawItem : it);
23
+ const callId = raw.callId ?? raw.call_id ?? raw.id;
24
+ if (typeof callId !== "string" || callId === "") {
25
+ throw new Error("[decidio] openai: this tool-approval item carries no call id (rawItem.callId), so an approval could not be bound to this ONE call - nothing was routed or approved. Pass the items state.getInterruptions() returns.");
26
+ }
27
+ const name = raw.name ?? it.name ?? it.toolName;
28
+ const args = raw.arguments ?? it.arguments;
29
+ return { ...adapterRequest(ctx), args: [{ arguments: args === undefined ? null : args, callId, name: typeof name === "string" ? name : null }] };
30
+ }
31
+ export async function gateCheckApproval(config, decisionId, ctx, request) {
32
+ return checkApproval(config, decisionId, request ?? adapterRequest(ctx));
33
+ }
34
+ export function assertApprovalCheckShape(raw, decisionId) {
35
+ const c = raw;
36
+ if (!c || typeof c !== "object" || typeof c.ok !== "boolean" || c.decisionId !== decisionId || typeof c.state !== "string") {
37
+ return { ok: false, decisionId, state: "", code: "unverifiable", reason: `the approval check for decision ${decisionId} came back malformed (${JSON.stringify(raw ?? null).slice(0, 120)}) - return checkApproval(...) / gateCheckApproval(...) from your activity unchanged` };
38
+ }
39
+ if (c.ok === true && c.code !== undefined)
40
+ return { ok: false, decisionId, state: c.state, code: "unverifiable", reason: "the approval check came back contradictory (ok with a refusal code)" };
41
+ return c;
42
+ }
43
+ export async function gateAuthorize(config, ctx, opts = {}) {
17
44
  ctx = canonicalize(ctx, "describe(...)");
18
45
  assertDescribed(ctx);
19
46
  assertSafeTransport(config.apiUrl, carriesCredential(config));
@@ -22,6 +49,8 @@ export async function gateAuthorize(config, ctx) {
22
49
  const requesterIdentity = (config.agentKey && config.agentDid)
23
50
  ? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
24
51
  : undefined;
52
+ const material = bindingMaterial(config);
53
+ const commitment = material ? requestCommitment(material, opts.request ?? adapterRequest(ctx)) : undefined;
25
54
  const res = await f(config.apiUrl.replace(/\/$/, "") + "/agent/authorize", {
26
55
  method: "POST",
27
56
  headers: { "content-type": "application/json", ...(config.apiToken ? { authorization: `Bearer ${config.apiToken}` } : {}) },
@@ -29,6 +58,8 @@ export async function gateAuthorize(config, ctx) {
29
58
  requester: config.agentId, action: ctx.action, amount: ctx.amount, scope: ctx.scope,
30
59
  sourceSystem: config.sourceSystem, ...(ctx.context ? { context: ctx.context } : {}),
31
60
  ...(requesterIdentity ? { requesterIdentity } : {}), ...(resumeUrl ? { resumeUrl } : {}),
61
+ ...(commitment ? { requestCommitment: commitment } : {}),
62
+ ...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}),
32
63
  }),
33
64
  });
34
65
  if (!res.ok) {
@@ -1,22 +1,16 @@
1
1
  import { Command } from "@langchain/langgraph";
2
2
  import { type GuardConfig, type ApprovalContext, type ProtectedExecution } from "../index.js";
3
- /**
4
- * @decidio/sdk/langgraph — durable suspend via LangGraph's native `interrupt()`.
5
- * `interrupt` finds the running graph itself (no handle needed), so the gate drops into
6
- * a node: authorize → on route, interrupt() durably pauses (the checkpointer persists;
7
- * the process can die) → on resume (Command(resume={verdict})) run the agent's own
8
- * action + confirm. Decidio is the approval system; the host bridges its webhook to the
9
- * graph resume (decidioResumeCommand). Requires @langchain/langgraph (optional peer dep).
10
- *
11
- * (This wiring doc ships in the .d.ts — the README points here.)
12
- */
13
3
  /** Gate `run()` behind Decidio inside a LangGraph node. */
14
4
  export declare function decidioGate<R>(config: GuardConfig, ctx: ApprovalContext, run: () => Promise<R>, opts?: {
15
5
  evidence?: (r: R) => unknown;
16
6
  }): Promise<ProtectedExecution<R>>;
17
7
  /** Translate Decidio's signed resume webhook into the LangGraph resume Command. The host
18
- * maps decisionId → thread_id and passes this to graph.invoke/stream. */
8
+ * maps decisionId → thread_id and passes this to graph.invoke/stream. Since 0.7.0 the Command
9
+ * carries the decisionId - the gate refuses a resume value that does not say which decision it
10
+ * approves - and the verdict in it is informational only: the node confirms the approval with
11
+ * Decidio. Verify the webhook's signature BEFORE building this (verifyResumeSignature). */
19
12
  export declare function decidioResumeCommand(body: {
13
+ decisionId?: string;
20
14
  verdict?: string;
21
15
  reason?: string;
22
16
  }): Command;
@@ -1,19 +1,70 @@
1
1
  import { interrupt, Command } from "@langchain/langgraph";
2
- import { DecidioBlockedError } from "../index.js";
3
- import { gateAuthorize, gateConfirm, verdictFrom, assertVerdict, checkEngineVersion } from "./_gate.js";
2
+ import * as langgraph from "@langchain/langgraph";
3
+ import { DecidioBlockedError, approvalRefusal, bindingMaterial, requestCommitment, idempotencyKey, decisionIdProblem } from "../index.js";
4
+ import { gateAuthorize, gateConfirm, gateCheckApproval, adapterRequest, checkEngineVersion } from "./_gate.js";
5
+ const gatesOpened = new WeakMap();
6
+ function runIdentity() {
7
+ let conf;
8
+ try {
9
+ const getConfig = langgraph.getConfig;
10
+ conf = typeof getConfig === "function" ? getConfig()?.configurable : undefined;
11
+ }
12
+ catch {
13
+ return undefined;
14
+ }
15
+ const thread = conf?.["thread_id"], ns = conf?.["checkpoint_ns"];
16
+ if ((typeof thread !== "string" && typeof thread !== "number") || String(thread) === "")
17
+ return undefined;
18
+ if (typeof ns !== "string" || ns === "")
19
+ return undefined;
20
+ const scratch = conf?.["__pregel_scratchpad"];
21
+ if (!scratch || typeof scratch !== "object")
22
+ return undefined;
23
+ const call = gatesOpened.get(scratch) ?? 0;
24
+ gatesOpened.set(scratch, call + 1);
25
+ return { thread: String(thread), ns, call };
26
+ }
4
27
  export async function decidioGate(config, ctx, run, opts) {
5
- const auth = await gateAuthorize(config, ctx);
28
+ const material = bindingMaterial(config);
29
+ if (!material) {
30
+ throw new Error("[decidio] the LangGraph adapter binds a routed approval to the exact request with a key only this agent holds, and none is configured - set DECIDIO_AGENT_KEY + DECIDIO_AGENT_DID (npx @decidio/sdk init) or DECIDIO_API_TOKEN. Nothing was routed or executed.");
31
+ }
32
+ const request = adapterRequest(ctx);
33
+ const self = runIdentity();
34
+ const key = self ? idempotencyKey(material, { thread: self.thread, ns: self.ns, call: self.call, commitment: requestCommitment(material, request) }) : undefined;
35
+ const auth = await gateAuthorize(config, ctx, key ? { idempotencyKey: key } : {});
6
36
  if (auth.decision === "block")
7
37
  throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
8
38
  if (auth.decision === "route") {
9
- const resumed = interrupt({ decidio: { decisionId: auth.decisionId, action: ctx.action, amount: ctx.amount, scope: ctx.scope } });
10
- assertVerdict(verdictFrom(resumed), auth.decisionId, resumed?.reason);
39
+ if (!self) {
40
+ throw new DecidioBlockedError(`this LangGraph run exposes no thread/task identity (a checkpointer with a thread_id, @langchain/langgraph's getConfig() and the node execution's scratchpad are required), so a resumed node could not recognise decision ${auth.decisionId} as its own. Nothing was executed; ${auth.decisionId} is pending in Decidio - reject it (npx @decidio/sdk approvals reject ${auth.decisionId}).`, auth.decisionId);
41
+ }
42
+ const payload = { decidio: { decisionId: auth.decisionId, action: ctx.action, amount: ctx.amount, scope: ctx.scope } };
43
+ let resumed = interrupt(payload);
44
+ for (;;) {
45
+ const named = resumed && typeof resumed === "object" ? resumed.decisionId : undefined;
46
+ if (typeof named !== "string" || named === "") {
47
+ throw new DecidioBlockedError(`the LangGraph resume value names no decision (${JSON.stringify(resumed ?? null).slice(0, 120)}) - refusing to execute. Build it with decidioResumeCommand(webhookBody), which carries the decisionId: an approval that does not say which decision it approves cannot be applied to this one.`, auth.decisionId);
48
+ }
49
+ if (named === auth.decisionId) {
50
+ const check = await gateCheckApproval(config, auth.decisionId, ctx);
51
+ if (check.ok)
52
+ break;
53
+ if (check.code !== "pending")
54
+ throw approvalRefusal(check);
55
+ }
56
+ resumed = interrupt(payload);
57
+ }
11
58
  }
12
59
  const result = await run();
13
60
  const confirmation = await gateConfirm(config, auth.decisionId, result, opts?.evidence);
14
61
  return { value: result, decisionId: auth.decisionId, confirmation };
15
62
  }
16
63
  export function decidioResumeCommand(body) {
17
- return new Command({ resume: { verdict: body.verdict, reason: body.reason } });
64
+ const decisionId = body?.decisionId;
65
+ const problem = decisionIdProblem(decisionId);
66
+ if (problem)
67
+ throw new Error(`[decidio] decidioResumeCommand needs the webhook's decisionId (${problem}) - the node refuses a resume that does not say which decision it approves. Pass the webhook body verbatim.`);
68
+ return new Command({ resume: { decisionId, verdict: body.verdict, reason: body.reason } });
18
69
  }
19
70
  checkEngineVersion("langgraph");