@decidio/sdk 0.1.9 → 0.1.11

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
@@ -114,7 +114,9 @@ step 4 printed — angle brackets are placeholders, never typed literally). Appr
114
114
  the agent's floor-limited token — it cannot approve anything):
115
115
 
116
116
  ```bash
117
- npx @decidio/sdk approvals # see it pending
117
+ npx @decidio/sdk approvals --mine # see it pending (--mine narrows to THIS agent; the
118
+ # sandbox is one shared lab workspace, so the bare
119
+ # command lists every member's pending decisions)
118
120
  npx @decidio/sdk approvals approve <decisionId>
119
121
  ```
120
122
 
@@ -126,11 +128,14 @@ becomes the record's execution evidence.
126
128
 
127
129
  ```bash
128
130
  npx @decidio/sdk receipt <decisionId>
129
- npx @decidio/sdk verify decidio-receipt-<decisionId>.json
131
+ npx @decidio/sdk verify --issuer <did:key:...> decidio-receipt-<decisionId>.json
130
132
  ```
131
133
 
132
- The download is an authenticated fetch from your workspace; the *verification* is fully
133
- offline — the verifier ships inside this package (no second download, no registry probe, same
134
+ Lead with the pin: `--issuer` binds the check to YOUR workspace's DID (the `receipt` command
135
+ prints it from the authenticated response, so it is already on your screen). An UNPINNED
136
+ `verify` proves internal consistency only — any keyholder could have issued such a file — and
137
+ says so; pinned verification proves *who*. The download is an authenticated fetch from your
138
+ workspace; the *verification* is fully offline — the verifier ships inside this package (no second download, no registry probe, same
134
139
  implementation as the standalone `@decidio/verify`). The receipt is a W3C Verifiable
135
140
  Credential: signature and content binding verify with no Decidio account and no network — the
136
141
  evidence is yours, not ours. To also prove *who* issued it, pin your workspace's issuer:
@@ -153,11 +158,20 @@ evidence that arrives *after* the seal (your function's captured result) lives o
153
158
  **record**, not inside the credential bytes: the `receipt` command prints the record's current
154
159
  tier next to the file, and with the signing key `init` wrote you'll see `application_confirmed`
155
160
  — the wrapper's report carried a signed identity proof bound to your agent, this decision, and
156
- a freshness window. Precisely: the proof authenticates WHO is confirming WHAT decision — it
157
- does not yet sign the captured response bytes themselves (that binding is on the receipt-v2
158
- roadmap), so treat the tier as proven reporter identity, not a cryptographic hash of the
159
- result. (No signing key configured? The server caps the evidence at the weaker
160
- `agent_asserted`, and the SDK warns at confirm time.) Immutable seal + accruing evidence is the design, not an omission — corrections and
161
+ a freshness window.
162
+
163
+ Since 0.1.11 the report also carries an **execution report**: a separate, domain-separated,
164
+ versioned envelope signed over a SHA-256 digest of the canonicalized response, bound to the
165
+ decision, your agent and your workspace, with a single-use nonce and a short expiry. The server
166
+ recomputes that digest from the response on the same request and refuses the attestation if they
167
+ disagree — so "this agent reported this exact result for this decision" is checked, not asserted.
168
+ It is deliberately separate from the evidence TIER: the tier says how strongly the outcome was
169
+ observed, the report says what was reported and by whom, and no signature can turn a weak
170
+ observation into a strong one. An unverifiable report is refused as an attestation (and the
171
+ refusal is itself audited) while the confirmation is still recorded — an optional proof never
172
+ destroys the record of an execution that really happened; the confirm response tells you which
173
+ happened. (No signing key configured? No execution report is sent, the server caps the evidence
174
+ at the weaker `agent_asserted`, and the SDK warns at confirm time.) Immutable seal + accruing evidence is the design, not an omission — corrections and
161
175
  later facts supersede, they never rewrite.
162
176
 
163
177
  One sharing note: a receipt is **not a secret, but it is not metadata-free** — it names the
@@ -201,29 +215,47 @@ exit. Two ways the parked action resumes when a human decides:
201
215
  **The worker (self-serve — start here).** No inbound URL, no shared secrets, and it resumes work
202
216
  the current process never started. Here is a complete `agent.mjs` you can run:
203
217
 
218
+ Two files, on purpose — **the worker and the thing that creates work are different programs**.
219
+ A single file that both starts the worker and calls the action submits a NEW request every time
220
+ you restart it, which is the opposite of what a recovery test should show.
221
+
222
+ `agent.mjs` — the long-lived worker. Restart it as often as you like; it creates nothing:
223
+
204
224
  ```js
205
225
  import { guard } from "@decidio/sdk";
206
226
 
207
- const payInvoiceRaw = async (invoice) => ({ paid: true, id: invoice.id });
227
+ const payInvoiceRaw = async (invoice) => ({ paid: true, id: invoice.id, success: true });
208
228
 
209
- // Declare the action name so the wrapper registers at LOAD time, not on first call —
210
- // that is what lets a restarted process resume actions approved while it was down.
211
- const payInvoice = guard.protect(
229
+ // Declaring the action name registers the handler at LOAD time, not on first call — that is
230
+ // what lets a restarted process resume actions approved while it was down.
231
+ guard.protect(
212
232
  payInvoiceRaw,
213
233
  (invoice) => ({ action: "payInvoice", amount: invoice.amount, scope: "Invoice" }),
214
234
  { action: "payInvoice" },
215
235
  );
216
236
 
217
- guard.worker(); // polls Decidio, re-executes parked actions on approval, one execution each
237
+ guard.worker(); // polls Decidio and resumes approved actions; creates no work of its own
238
+ console.log("worker running — approve a decision and watch it resume here");
239
+ ```
240
+
241
+ `submit.mjs` — run this ONCE when you want a new request:
242
+
243
+ ```js
244
+ import { guard } from "@decidio/sdk";
245
+
246
+ const payInvoice = guard.protect(
247
+ async (invoice) => ({ paid: true, id: invoice.id, success: true }),
248
+ (invoice) => ({ action: "payInvoice", amount: invoice.amount, scope: "Invoice" }),
249
+ { action: "payInvoice" },
250
+ );
218
251
 
219
- // New work suspends immediately; approvals land later, through the same worker.
220
252
  try { await payInvoice({ id: "INV-1", amount: 86_000 }); }
221
253
  catch (e) { console.log(e.name); } // DecidioSuspendedError — expected, not a failure
222
254
  ```
223
255
 
224
- Run it with `node --env-file=.env agent.mjs`, approve from another terminal, and the running
225
- worker executes it. Kill the process, restart it, then approve: it still executes, because the
226
- action was registered at load. **If you can't declare the name at wrap time** (a dynamic
256
+ Run `node --env-file=.env agent.mjs`, then `node --env-file=.env submit.mjs` once, approve from
257
+ another terminal, and the running worker executes it. Now kill the worker, restart it, and
258
+ approve: it still executes — and restarting created no second request. **If you can't declare the name at wrap time** (a dynamic
227
259
  `describe`), register the raw function explicitly at startup instead —
228
260
  `guard.register("payInvoice", payInvoiceRaw)` — before calling `guard.worker()`. Without one of
229
261
  those two, a cold worker has an empty registry and reports `no_handler` (the parked action is
@@ -243,18 +275,66 @@ also allow-list resume hosts (default-deny). That's operator territory: if you r
243
275
  Decidio (or we run a pilot with you), the webhook is set up then. Against the hosted sandbox,
244
276
  use the worker — a webhook secret your server never learned would (correctly) fail closed.
245
277
 
278
+ **Earning the strongest evidence tier.** Decidio records `application_confirmed` — its
279
+ strongest wrapper tier — only when your function's captured response shows a committed record:
280
+ a record id (`id` / `recordId` / `Id`) and no explicit denial of success. Precisely, as
281
+ implemented: an id with no `success`/`ok` field is taken as a commit; `success: false` (or
282
+ `ok: false`) records `execution_unconfirmed` even with an id; and a response carrying no id at
283
+ all records `execution_unconfirmed`, because Decidio then has no evidence the downstream write
284
+ committed. Note what this does and does not mean: Decidio is trusting your wrapper's report of
285
+ an identifier — it does not independently read that record back (that is the stronger
286
+ `independently_reconciled` tier). If your API returns an id before the write is durable (a
287
+ 202-Accepted or queued shape), map an explicit success flag with `evidence` so the tier reflects
288
+ the commit rather than the acknowledgement. Keep your API's natural shape and map it instead:
289
+
290
+ ```js
291
+ guard.protect(chargeCard, describe, {
292
+ action: "chargeCard",
293
+ evidence: (r) => ({ recordId: r.transactionId, success: r.status === "committed" }),
294
+ });
295
+ ```
296
+
297
+ The SDK tells you which of the two is missing rather than leaving you to guess, and it reports
298
+ `reportRecorded` (Decidio accepted the report) separately from `executionConfirmed` (the
299
+ recorded tier attests a commit) — two different questions that used to share one name.
300
+
246
301
  **The supported argument data model is plain JSON**: `null`, booleans, strings, finite
247
302
  numbers, arrays, and plain objects. Anything that cannot survive JSON with its meaning intact
248
303
  — `NaN`/`Infinity`, `undefined`, `BigInt`, `Map`/`Set`/`Date`, class instances, functions,
249
- circular structures — is **refused before the decision is routed or the payload signed**
250
- (typed error naming the offending path), because a value that silently drifts in serialization
251
- would mean a human approves one thing and your function executes another.
304
+ circular structures, symbol keys, getters, non-enumerable or sparse values — is **refused
305
+ before the decision is routed or the payload signed** (typed error naming the offending path),
306
+ because a value that silently drifts in serialization would mean a human approves one thing and
307
+ your function executes another.
308
+
309
+ Your arguments are canonicalized ONCE, and that canonical snapshot is what `describe` sees, what
310
+ the signature covers, what is parked, and what your function ultimately receives — so the
311
+ approved value and the executed value are the same. Consequence worth stating plainly: JSON
312
+ canonicalization preserves **value** semantics, not JavaScript object identity. Two properties
313
+ pointing at the same object arrive as two equal objects, and mutating an argument inside your
314
+ function does not affect the caller's copy.
315
+
316
+ **What the SDK can and cannot promise about duplicates.** It prevents *concurrent* execution:
317
+ an entry is claimed atomically before it runs, so two workers cannot both execute it and a
318
+ duplicate signal finds nothing to replay. It cannot make an external side effect exactly-once —
319
+ no client can. If your action succeeds and the process dies before the result is recorded,
320
+ whether it ran is not knowable from this machine. So the contract is **at-least-once with
321
+ honest recovery**, and your downstream action should be idempotent (the decision id makes a
322
+ natural idempotency key).
323
+
324
+ Recovery is explicit rather than magical:
252
325
 
253
- Either way, re-execution is **single-use on the happy path**: the parked entry is claimed before
254
- execution and deleted on success, so a duplicate signal finds nothing to replay. Honest edge — if
255
- your action succeeds but the process dies *before* the entry is cleared, the claim can be retried
256
- (robust crash-recovery of stale claims is on the roadmap), so make your downstream action
257
- idempotent if you need exactly-once under crashes.
326
+ ```js
327
+ for (const item of await guard.resume.recover()) {
328
+ console.log(item.state, item.decisionId, item.guidance);
329
+ }
330
+ // "executed_unreported" → guard.resume.reconfirm(id) re-sends the saved report (never re-runs)
331
+ // "indeterminate" → a process died mid-flight; reconcile downstream, do NOT blind-retry
332
+ // "parked" → still waiting on a human; nothing has executed
333
+ ```
334
+
335
+ The report a worker sends is journalled *before* the first attempt, so a lost confirmation is
336
+ recoverable; and because the journal holds no handler binding, replaying it can only re-send
337
+ evidence — never re-run your function.
258
338
 
259
339
  **Parked arguments must be JSON-serializable.** A suspended action is signed and then written to
260
340
  disk as JSON, so values that don't survive `JSON.stringify` round-tripping — `Date`, `Map`,
@@ -278,12 +358,21 @@ If your agent runs on Inngest/Temporal/LangGraph, the engine *is* the durable st
278
358
  `@decidio/sdk/inngest` maps the gate onto `step.waitForEvent`; `@decidio/sdk/langgraph` is a
279
359
  drop-in via `interrupt()`; `@decidio/sdk/temporal` uses `condition` + signal;
280
360
  `@decidio/sdk/openai` gates RunState approvals. All optional peer dependencies — the core SDK
281
- imports none of them. Each adapter's shipped `.d.ts` header documents its wiring; `npx
361
+ imports none of them — install the engine you use alongside the SDK (`npm i inngest`,
362
+ `@langchain/langgraph`, `@temporalio/workflow`, or `@openai/agents`); nothing is downloaded
363
+ for engines you don't. Each adapter's shipped `.d.ts` header documents its wiring; `npx
282
364
  @decidio/sdk quickstart` is the fastest runnable end-to-end.
283
365
 
284
- ## One surface, every runtime — and Python too
366
+ ## Engine adapters (BETA) — and Python too
285
367
 
286
- - **`guard.protect(fn, describe, { adapter })`** is the same call everywhere; the engine adapters above translate it onto each engine's native durable wait. No engine? The worker/webhook resume is the universal path.
368
+ - **Engine adapters are BETA.** They share the core's gate semantics — the verdict allow-list,
369
+ the canonical argument model, the transport rule and evidence handling are ONE implementation,
370
+ and a conformance matrix runs the same malformed-verdict, unsafe-argument and plaintext-transport
371
+ battery against every door. What is *not* yet verified is the engine integration itself: those
372
+ paths are exercised against structural mocks, not live LangGraph/Temporal/Inngest/OpenAI
373
+ runtimes. Use the core path for production until that changes. Each adapter is bound through its
374
+ own `@decidio/sdk/<engine>` subpath (they need the engine handle at call time); no engine at all?
375
+ The worker/webhook resume is the universal path and is not beta.
287
376
  - **Python twin:** `pip install decidio` exposes the identical `guard.protect` (plus the `@guard.approve` decorator, adapters, and the offline verifier); a conformance suite asserts both languages emit an identical request + receipt.
288
377
 
289
378
  ## CLI
@@ -317,6 +406,22 @@ manager for production.
317
406
  - `DecidioRejectedError` — a human rejected the routed action.
318
407
  - `DecidioSuspendedError` — (durable mode) the action was routed and is parked for async approval; the process may exit and resume later. Not a failure.
319
408
  - `DecidioTimeoutError` — (blocking mode only) no human decided within `pollTimeoutMs` (default 10 min); the decision stays open in Decidio, nothing executed locally.
409
+ - `DecidioRateLimitError` — the workspace's governed-action limit was hit; carries the server's `limit` / `remaining` / `resetAt`.
410
+ - `DecidioHttpError` — the gate refused the call. Carries `status`, `endpoint`, the parsed `body`,
411
+ and **`retryable`** — the field to branch on. A 401/403 is terminal (the agent token expired, or
412
+ someone re-ran `init` for that agent and revoked it): retrying cannot help, so the message names
413
+ the fix. Anything else is transient, and retrying is safe because a retry creates a *new* request
414
+ and can never double-execute.
415
+ - `DecidioNetworkError` — the gate was unreachable. Nothing executed locally.
416
+
417
+ ```js
418
+ try { await payInvoice(inv); }
419
+ catch (e) {
420
+ if (e instanceof DecidioHttpError && !e.retryable) { await alertOncall(e.message); throw e; }
421
+ if (e instanceof DecidioHttpError) { await backoff(); return retry(); }
422
+ throw e;
423
+ }
424
+ ```
320
425
 
321
426
  Every error raised after a decision exists carries the `decisionId`, so a suspended or
322
427
  timed-out action is always findable in
@@ -1,13 +1,37 @@
1
1
  import { type GuardConfig, type ApprovalContext } from "../index.js";
2
+ /** The ONLY authorization verdicts this SDK understands. A gate answer outside this set means
3
+ * the SDK does not understand the decision — and an answer you do not understand means NO. */
4
+ export declare const AUTHORIZE_VERDICTS: readonly ["proceed", "route", "block"];
5
+ export type AuthorizeVerdict = (typeof AUTHORIZE_VERDICTS)[number];
2
6
  export interface GateAuth {
3
- decision: "proceed" | "route" | "block";
7
+ decision: AuthorizeVerdict;
4
8
  decisionId: string;
5
9
  receiptId?: string;
6
10
  reason?: string;
7
11
  }
12
+ /** Fail-closed verdict allow-list. Called INSIDE gateAuthorize, so every adapter that authorizes
13
+ * through the shared gate is protected by construction rather than by remembering — an adapter
14
+ * that handled `block` and `route` and let everything else fall through to execute is exactly
15
+ * how an unknown verdict reached a protected action. Version skew (the day the gate learns a
16
+ * fourth verdict) is the realistic trigger, and it must never mean "run it". */
17
+ export declare function assertAuthorizeVerdict(auth: unknown, fallbackDecisionId?: string): GateAuth;
8
18
  export declare function gateAuthorize(config: GuardConfig, ctx: ApprovalContext): Promise<GateAuth>;
9
19
  export declare function gateConfirm(config: GuardConfig, decisionId: string, result: unknown): Promise<void>;
10
20
  /** Normalize a resume payload ({verdict} | "approved" | ...) to a verdict string. */
11
21
  export declare function verdictFrom(payload: any): string;
12
22
  /** Throw the right error for a non-approving verdict; return for approved/auto_approved. */
13
23
  export declare function assertVerdict(verdict: string, decisionId: string, reason?: string): void;
24
+ /** Engine versions these adapters are written and tested against. Peer dependencies are
25
+ * deliberately NOT declared: declaring them made npm resolve four engine ecosystems into ~211MB
26
+ * of cache metadata for every consumer, including the majority who use none of them. The
27
+ * compatibility contract lives here instead, and it is CHECKED at bind time — a mismatch names
28
+ * the version it found, the range it wants, the beta status and the exact remediation, rather
29
+ * than failing somewhere deep in the engine later. */
30
+ export declare const ENGINE_RANGES: Record<string, {
31
+ pkg: string;
32
+ min: string;
33
+ lessThan: string;
34
+ }>;
35
+ /** Warn (never throw — a working integration must not be broken by a version table) when the
36
+ * installed engine is outside the tested range. Called by each adapter at bind time. */
37
+ export declare function checkEngineVersion(adapter: keyof typeof ENGINE_RANGES | string): void;
@@ -1,19 +1,19 @@
1
- // Shared adapter gate logic (TS) — the thin-adapter rule in code. Every engine adapter
2
- // reuses these gate calls + verdict handling; only the suspend/resume translation
3
- // differs. Pure/testable so the offline smoke covers our logic; the engine-primitive
4
- // call in each adapter is a thin one-liner verified by typecheck against real types.
5
- import { DecidioBlockedError, DecidioRejectedError, signBoundProof, signConfirm, assertJsonSafe, assertConfirmRecorded } from "../index.js";
1
+ import { createRequire } from "node:module";
2
+ import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, canonicalize, readConfirmResponse, buildConfirmBody } from "../index.js";
3
+ export const AUTHORIZE_VERDICTS = ["proceed", "route", "block"];
4
+ export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
5
+ const a = auth;
6
+ const decisionId = typeof a?.decisionId === "string" && a.decisionId ? a.decisionId : fallbackDecisionId;
7
+ if (!a || !AUTHORIZE_VERDICTS.includes(a.decision)) {
8
+ throw new DecidioBlockedError(`unrecognised authorization verdict ${JSON.stringify(a?.decision ?? null)} — refusing to execute. The gate answers ${AUTHORIZE_VERDICTS.join(" | ")}; anything else means this SDK version does not understand the decision (upgrade @decidio/sdk), and an answer you do not understand is never permission.`, decisionId);
9
+ }
10
+ return a;
11
+ }
6
12
  export async function gateAuthorize(config, ctx) {
7
- // SAME JSON-safety gate as the core wrapper (guard equivalent-path catch: the adapter door
8
- // skipped it, and the server's amount coercion turns a drifted NaN→null into a $0 request —
9
- // a human would approve an amount that meant something else entirely).
10
- assertJsonSafe(ctx, "describe(...)");
13
+ canonicalize(ctx, "describe(...)");
14
+ assertSafeTransport(config.apiUrl, !!config.apiToken);
11
15
  const f = config.fetchImpl ?? fetch;
12
- // Fall back to the resume controller's URL (matches the core wrapper) so an adapter
13
- // decision carries a callback URL even when only resume.resumeUrl was set.
14
16
  const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
15
- // Sign the bound identity proof like the core wrapper, so an adapter-path agent can PROVE
16
- // its identity and auto-approve (not just route). No key → unsigned → server routes.
17
17
  const requesterIdentity = (config.agentKey && config.agentDid)
18
18
  ? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
19
19
  : undefined;
@@ -26,42 +26,53 @@ export async function gateAuthorize(config, ctx) {
26
26
  ...(requesterIdentity ? { requesterIdentity } : {}), ...(resumeUrl ? { resumeUrl } : {}),
27
27
  }),
28
28
  });
29
- if (!res.ok)
30
- throw new Error(`decidio /agent/authorize ${res.status}`);
31
- return res.json();
29
+ if (!res.ok) {
30
+ const raw = await res.text().catch(() => "");
31
+ let parsed = raw;
32
+ try {
33
+ parsed = raw ? JSON.parse(raw) : undefined;
34
+ }
35
+ catch { }
36
+ throw new DecidioHttpError(res.status, "/agent/authorize", parsed);
37
+ }
38
+ return assertAuthorizeVerdict(await res.json(), "unknown");
32
39
  }
33
40
  export async function gateConfirm(config, decisionId, result) {
34
41
  const f = config.fetchImpl ?? fetch;
35
42
  try {
36
- // Sign the confirm too (bound to {action:"confirm", scope:decisionId}) so the adapter path
37
- // earns application_confirmed; without a key the server caps it at agent_asserted.
43
+ assertSafeTransport(config.apiUrl, !!config.apiToken);
44
+ }
45
+ catch (e) {
46
+ console.warn(`[decidio] confirm failed (action succeeded): ${e.message}`);
47
+ return;
48
+ }
49
+ try {
38
50
  const requesterIdentity = signConfirm(decisionId, config);
39
- // Same no-silent-downgrade rule as the inline wrap (guard equivalent-path catch).
40
51
  if (!requesterIdentity)
41
52
  console.warn(`[decidio] execution evidence for ${decisionId} recorded as agent_asserted — no signing key configured, so the report can't be proven to come from this agent. Set DECIDIO_AGENT_KEY/DID to earn application_confirmed.`);
53
+ const reported = canonicalize(result ?? null, "result");
42
54
  const res = await f(config.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
43
55
  method: "POST",
44
56
  headers: { "content-type": "application/json", ...(config.apiToken ? { authorization: `Bearer ${config.apiToken}` } : {}) },
45
- body: JSON.stringify({ decisionId, capturedResponse: result ?? null, source: "wrapper", ...(requesterIdentity ? { requesterIdentity } : {}) }),
57
+ body: JSON.stringify(buildConfirmBody(config, decisionId, reported, requesterIdentity)),
46
58
  });
47
- // fetch only rejects on network error — surface a non-2xx (auth/500) too. Best-effort:
48
- // log, don't throw (the action already happened).
49
59
  if (!res.ok) {
50
60
  console.warn(`[decidio] confirm returned ${res.status} (action succeeded)`);
51
61
  return;
52
62
  }
53
- // The ONE confirm-body rule, on this door too (guard equivalent-path catch).
54
- assertConfirmRecorded(await res.json().catch(() => null), decisionId, !!requesterIdentity);
63
+ const ev = readConfirmResponse(await res.json().catch(() => null), { signed: !!requesterIdentity, capturedResponse: reported });
64
+ if (!ev.reportRecorded)
65
+ console.warn(`[decidio] ${decisionId}: ${ev.diagnostic}`);
66
+ else if (!ev.executionConfirmed)
67
+ console.warn(`[decidio] execution evidence for ${decisionId} recorded as ${ev.evidenceTier} — ${ev.diagnostic}`);
55
68
  }
56
69
  catch (e) {
57
70
  console.warn(`[decidio] confirm failed (action succeeded): ${e.message}`);
58
71
  }
59
72
  }
60
- /** Normalize a resume payload ({verdict} | "approved" | ...) to a verdict string. */
61
73
  export function verdictFrom(payload) {
62
74
  return (payload && typeof payload === "object" ? payload.verdict : payload) ?? "";
63
75
  }
64
- /** Throw the right error for a non-approving verdict; return for approved/auto_approved. */
65
76
  export function assertVerdict(verdict, decisionId, reason) {
66
77
  if (verdict === "approved" || verdict === "auto_approved")
67
78
  return;
@@ -69,3 +80,35 @@ export function assertVerdict(verdict, decisionId, reason) {
69
80
  throw new DecidioRejectedError(reason ?? "rejected by approver", decisionId);
70
81
  throw new DecidioBlockedError(reason ?? "not approved", decisionId);
71
82
  }
83
+ export const ENGINE_RANGES = {
84
+ langgraph: { pkg: "@langchain/langgraph", min: "0.2.0", lessThan: "2.0.0" },
85
+ temporal: { pkg: "@temporalio/workflow", min: "1.0.0", lessThan: "2.0.0" },
86
+ openai: { pkg: "@openai/agents", min: "0.0.1", lessThan: "2.0.0" },
87
+ inngest: { pkg: "inngest", min: "3.0.0", lessThan: "4.0.0" },
88
+ };
89
+ const cmp = (a, b) => {
90
+ const pa = a.split(".").map((n) => parseInt(n, 10) || 0), pb = b.split(".").map((n) => parseInt(n, 10) || 0);
91
+ for (let i = 0; i < 3; i++) {
92
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0))
93
+ return (pa[i] ?? 0) - (pb[i] ?? 0);
94
+ }
95
+ return 0;
96
+ };
97
+ export function checkEngineVersion(adapter) {
98
+ const spec = ENGINE_RANGES[adapter];
99
+ if (!spec)
100
+ return;
101
+ let found;
102
+ try {
103
+ const req = createRequire(import.meta.url);
104
+ found = req(`${spec.pkg}/package.json`)?.version;
105
+ }
106
+ catch { }
107
+ if (!found)
108
+ return;
109
+ if (cmp(found, spec.min) < 0 || cmp(found, spec.lessThan) >= 0) {
110
+ console.warn(`[decidio] ${spec.pkg} ${found} is outside the range this BETA adapter is tested against (>=${spec.min} <${spec.lessThan}). ` +
111
+ `The adapter may still work, but nothing verifies it at this version. Remediation: install a supported version ` +
112
+ `(npm i "${spec.pkg}@>=${spec.min} <${spec.lessThan}"), or use the core guard.protect path, which is not beta and needs no engine.`);
113
+ }
114
+ }
@@ -1,23 +1,11 @@
1
1
  import { interrupt, Command } from "@langchain/langgraph";
2
2
  import { DecidioBlockedError } from "../index.js";
3
- import { gateAuthorize, gateConfirm, verdictFrom, assertVerdict } from "./_gate.js";
4
- /**
5
- * @decidio/sdk/langgraph — durable suspend via LangGraph's native `interrupt()`.
6
- * `interrupt` finds the running graph itself (no handle needed), so the gate drops into
7
- * a node: authorize → on route, interrupt() durably pauses (the checkpointer persists;
8
- * the process can die) → on resume (Command(resume={verdict})) run the agent's own
9
- * action + confirm. Decidio is the approval system; the host bridges its webhook to the
10
- * graph resume (decidioResumeCommand). Requires @langchain/langgraph (optional peer dep).
11
- *
12
- * (This wiring doc ships in the .d.ts — the README points here.)
13
- */
14
- /** Gate `run()` behind Decidio inside a LangGraph node. */
3
+ import { gateAuthorize, gateConfirm, verdictFrom, assertVerdict, checkEngineVersion } from "./_gate.js";
15
4
  export async function decidioGate(config, ctx, run) {
16
5
  const auth = await gateAuthorize(config, ctx);
17
6
  if (auth.decision === "block")
18
7
  throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
19
8
  if (auth.decision === "route") {
20
- // Durable pause. On resume, interrupt() returns the Command(resume=...) payload.
21
9
  const resumed = interrupt({ decidio: { decisionId: auth.decisionId, action: ctx.action, amount: ctx.amount, scope: ctx.scope } });
22
10
  assertVerdict(verdictFrom(resumed), auth.decisionId, resumed?.reason);
23
11
  }
@@ -25,8 +13,7 @@ export async function decidioGate(config, ctx, run) {
25
13
  await gateConfirm(config, auth.decisionId, result);
26
14
  return result;
27
15
  }
28
- /** Translate Decidio's signed resume webhook into the LangGraph resume Command. The host
29
- * maps decisionId → thread_id and passes this to graph.invoke/stream. */
30
16
  export function decidioResumeCommand(body) {
31
17
  return new Command({ resume: { verdict: body.verdict, reason: body.reason } });
32
18
  }
19
+ checkEngineVersion("langgraph");
@@ -1,7 +1,4 @@
1
- import { gateAuthorize } from "./_gate.js";
2
- /** Ask Decidio for each pending tool-approval on a RunState.
3
- * proceed → state.approve(item); block → state.reject(item); route → pending (a human
4
- * decides via Decidio; the host parks state.toString() + resumes via applyResume). */
1
+ import { gateAuthorize, checkEngineVersion } from "./_gate.js";
5
2
  export async function gateInterruptions(config, state, describe) {
6
3
  const resolved = [];
7
4
  const pending = [];
@@ -20,10 +17,10 @@ export async function gateInterruptions(config, state, describe) {
20
17
  }
21
18
  return { resolved, pending };
22
19
  }
23
- /** Apply a Decidio verdict to a resumed RunState (after RunState.fromString). */
24
20
  export function applyResume(state, item, verdict) {
25
21
  if (verdict === "approved" || verdict === "auto_approved")
26
22
  state.approve(item);
27
23
  else
28
24
  state.reject(item);
29
25
  }
26
+ checkEngineVersion("openai");
@@ -1,38 +1,36 @@
1
1
  import { condition, defineSignal, setHandler } from "@temporalio/workflow";
2
2
  import { DecidioBlockedError } from "../index.js";
3
- import { gateAuthorize, gateConfirm, verdictFrom, assertVerdict } from "./_gate.js";
4
- /**
5
- * @decidio/sdk/temporal — durable suspend via Temporal's native `condition()` + a
6
- * Decidio resume signal. Strongest durability (the workflow survives worker restarts for
7
- * days); the agent's action runs on resume. Requires @temporalio/workflow (optional peer).
8
- *
9
- * Inside your workflow:
10
- * const verdicts = installDecidioSignal(); // wires the resume signal
11
- * const out = await decidioGate(config, ctx, () => createOpp(o), verdicts, {
12
- * authorize: () => executeActivity(authorizeActivity, ...), // HTTP belongs in activities
13
- * confirm: (id, r) => executeActivity(confirmActivity, ...),
14
- * });
15
- * Decidio's webhook bridges to: handle.signal(decidioResumeSignal, decisionId, verdict, reason)
16
- *
17
- * (This wiring doc ships in the .d.ts — the README points here.)
18
- */
3
+ import { gateAuthorize, gateConfirm, verdictFrom, assertVerdict, checkEngineVersion, assertAuthorizeVerdict } from "./_gate.js";
19
4
  export const decidioResumeSignal = defineSignal("decidioResume");
20
- /** Call inside the workflow to wire the resume signal; returns the verdict store. */
21
5
  export function installDecidioSignal() {
22
6
  const verdicts = new Map();
23
7
  setHandler(decidioResumeSignal, (decisionId, verdict, reason) => { verdicts.set(decisionId, { verdict, reason }); });
24
8
  return verdicts;
25
9
  }
10
+ let confirmWarned = false;
26
11
  export async function decidioGate(config, ctx, run, verdicts, opts = {}) {
27
- const auth = await (opts.authorize ?? (() => gateAuthorize(config, ctx)))();
12
+ const auth = assertAuthorizeVerdict(await (opts.authorize ?? (() => gateAuthorize(config, ctx)))(), "unknown");
28
13
  if (auth.decision === "block")
29
14
  throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
30
15
  if (auth.decision === "route") {
31
- await condition(() => verdicts.has(auth.decisionId)); // durable wait on the signal
16
+ await condition(() => verdicts.has(auth.decisionId));
32
17
  const v = verdicts.get(auth.decisionId);
33
18
  assertVerdict(verdictFrom(v), auth.decisionId, v.reason);
34
19
  }
20
+ else if (auth.decision !== "proceed") {
21
+ throw new DecidioBlockedError(`unhandled authorization verdict ${JSON.stringify(auth.decision)} — refusing to execute`, auth.decisionId);
22
+ }
35
23
  const result = await run();
36
- await (opts.confirm ?? ((id, r) => gateConfirm(config, id, r)))(auth.decisionId, result);
24
+ if (opts.confirm) {
25
+ await opts.confirm(auth.decisionId, result);
26
+ if (!confirmWarned) {
27
+ confirmWarned = true;
28
+ console.warn(`[decidio] temporal: a custom \`confirm\` reports through your own activity, so this SDK cannot read back the evidence tier Decidio recorded. Check the record (or return the gate's response body from your activity and validate it with readConfirmResponse) if you need to know whether the execution was CONFIRMED, not merely reported.`);
29
+ }
30
+ }
31
+ else {
32
+ await gateConfirm(config, auth.decisionId, result);
33
+ }
37
34
  return result;
38
35
  }
36
+ checkEngineVersion("temporal");
@@ -0,0 +1,18 @@
1
+ /** The value model this SDK transports: exactly what JSON can carry, losslessly. */
2
+ export type JsonPrimitive = string | number | boolean | null;
3
+ export type JsonValue = JsonPrimitive | JsonValue[] | {
4
+ [key: string]: JsonValue;
5
+ };
6
+ /** Typed refusal for a value outside the canonical model. `decisionId` is set only when the
7
+ * refusal happens after a decision already exists (a direct-park caller). */
8
+ export declare class DecidioUnsafeArgumentsError extends Error {
9
+ decisionId?: string | undefined;
10
+ constructor(message: string, decisionId?: string | undefined);
11
+ }
12
+ /** Deep-clone `value` into the canonical model, REFUSING anything that cannot be carried
13
+ * losslessly. Returns a fresh structure; the input is never referenced by the result. */
14
+ export declare function canonicalize<T = JsonValue>(value: unknown, path?: string): T;
15
+ export declare function canonicalizeArgs(args: readonly unknown[]): JsonValue[];
16
+ /** RFC 8785-shaped canonical JSON of an already-canonical value — the exact bytes hashed for an
17
+ * execution report, so both languages produce the same digest. */
18
+ export declare function canonicalJson(value: JsonValue): string;