@decidio/sdk 0.1.8 → 0.1.9

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/dist/guard.d.ts CHANGED
@@ -8,25 +8,53 @@ export interface GuardFacadeConfig {
8
8
  resumeUrl?: string;
9
9
  webhookSecret?: string;
10
10
  pendingDir?: string;
11
+ /** Local signing key (DECIDIO_AGENT_KEY) + public did (DECIDIO_AGENT_DID), from `init`.
12
+ * When present the gate authorizes against a PROVEN identity, not a claimed label. */
11
13
  agentKey?: string;
12
14
  agentDid?: string;
15
+ /** The workspace this agent acts in (DECIDIO_WORKSPACE_ID). Bound into the signed proof (D-B3.6)
16
+ * so a captured proof can't be replayed in another tenant. */
13
17
  workspaceId?: string;
18
+ /** Base URL of the Decidio app (DECIDIO_APP_URL) — used to deep-link a routed decision. */
14
19
  appUrl?: string;
15
20
  fetchImpl?: typeof fetch;
16
21
  }
17
22
  export interface ProtectOptions {
23
+ /** null/"generic" → the core gate; engine adapters (langgraph/openai/temporal/inngest/mcp)
24
+ * live in @decidio/sdk/adapters and take the engine handle. */
18
25
  adapter?: "generic" | "langgraph" | "openai" | "temporal" | "inngest" | "mcp";
26
+ /** "durable" (default) suspends for async approval; "blocking" polls (short approvals only). */
19
27
  mode?: "durable" | "blocking";
28
+ /** The action name this wrapper produces, declared UP FRONT so the resume registry is populated
29
+ * at WRAP time rather than on first call. Required for the cold-worker pattern: after a
30
+ * restart, `guard.worker()` can only re-execute actions registered in THIS process, and
31
+ * `describe` (which names the action) cannot run without the original call arguments. Declare
32
+ * it and a restarted process resumes work it never originated. */
20
33
  action?: string;
34
+ /** Blocking-mode poll ceiling in ms (default 600000 = 10 min). Documented since 0.1.0 but only
35
+ * reachable via the low-level withApproval config until now (round-2 blind catch: passing it
36
+ * here was SILENTLY ignored and CI pipelines blocked the full 10 minutes instead of failing
37
+ * fast — and the Python twin rejected the same call loudly, a twin divergence). */
38
+ pollTimeoutMs?: number;
39
+ /** Blocking-mode poll interval in ms (default 3000). */
40
+ pollIntervalMs?: number;
21
41
  }
22
42
  export declare class Guard {
23
43
  config: GuardFacadeConfig;
24
44
  resume?: ResumeController;
25
45
  constructor(config: GuardFacadeConfig, resume?: ResumeController);
26
46
  static fromEnv(): Guard;
47
+ /** Register a raw fn under its action name so a COLD process (one that never made the original
48
+ * call) can resume it. Call this at startup beside `guard.worker()` — or pass
49
+ * `{ action }` to `protect(...)`, which does it for you at wrap time. */
27
50
  register(action: string, fn: (...a: any[]) => Promise<any>): void;
51
+ /** Mount on a node/Express/Fastify route: `app.post("/decidio/resume", guard.resumeHandler())`. */
28
52
  resumeHandler(): import("http").RequestListener;
53
+ /** Mount on a Next.js/Hono/CF/Bun route: `export const POST = (req) => guard.resumeFetchHandler()(req)`. */
29
54
  resumeFetchHandler(): (req: Request) => Promise<Response>;
55
+ /** Durable resume with NOTHING to host: a background watcher polls Decidio outward and finishes
56
+ * any parked approval — so a suspended action survives a process restart. One line at startup.
57
+ * Production may swap this for the webhook (resumeUrl); the suspend/resume shape is identical. */
30
58
  worker(opts?: {
31
59
  intervalMs?: number;
32
60
  timeoutMs?: number;
@@ -37,4 +65,5 @@ export declare class Guard {
37
65
  private ensureResume;
38
66
  protect<A extends any[], R>(fn: (...a: A) => Promise<R>, describe: (...a: A) => ApprovalContext, opts?: ProtectOptions): (...a: A) => Promise<R>;
39
67
  }
68
+ /** Preconfigured singleton — `import { guard } from "@decidio/sdk"`. */
40
69
  export declare const guard: Guard;
package/dist/guard.js CHANGED
@@ -1,3 +1,7 @@
1
+ // The Guard facade — `guard.protect(fn, describe)`, the one agnostic line (TS mirror
2
+ // of decidio-guard Python). A thin layer over the verified primitives: durable mode
3
+ // builds a resume controller and suspends; blocking mode polls (opt-in, loud). The
4
+ // agent executes its own action; Decidio gates + records + signals — never executes.
1
5
  import { withApproval } from "./index.js";
2
6
  import { createDecidioResume, FilePendingStore } from "./resume.js";
3
7
  export class Guard {
@@ -22,9 +26,17 @@ export class Guard {
22
26
  appUrl: process.env.DECIDIO_APP_URL,
23
27
  });
24
28
  }
29
+ /** Register a raw fn under its action name so a COLD process (one that never made the original
30
+ * call) can resume it. Call this at startup beside `guard.worker()` — or pass
31
+ * `{ action }` to `protect(...)`, which does it for you at wrap time. */
25
32
  register(action, fn) { this.ensureResume().register(action, fn); }
33
+ /** Mount on a node/Express/Fastify route: `app.post("/decidio/resume", guard.resumeHandler())`. */
26
34
  resumeHandler() { return this.ensureResume().webhookHandler(); }
35
+ /** Mount on a Next.js/Hono/CF/Bun route: `export const POST = (req) => guard.resumeFetchHandler()(req)`. */
27
36
  resumeFetchHandler() { return this.ensureResume().fetchHandler(); }
37
+ /** Durable resume with NOTHING to host: a background watcher polls Decidio outward and finishes
38
+ * any parked approval — so a suspended action survives a process restart. One line at startup.
39
+ * Production may swap this for the webhook (resumeUrl); the suspend/resume shape is identical. */
28
40
  worker(opts) {
29
41
  return this.ensureResume().worker(opts);
30
42
  }
@@ -34,6 +46,8 @@ export class Guard {
34
46
  this.resume = createDecidioResume({
35
47
  apiUrl: c.apiUrl, apiToken: c.apiToken, webhookSecret: c.webhookSecret,
36
48
  resumeUrl: c.resumeUrl, store: new FilePendingStore(c.pendingDir ?? ".decidio-pending"),
49
+ // Carry the signing identity so the durable/cold confirm on resume is signed too (incl. the
50
+ // workspace binding — D-B3.6 — so a cold-resume confirm proof is tenant-scoped as well).
37
51
  agentId: c.agentId, agentKey: c.agentKey, agentDid: c.agentDid, workspaceId: c.workspaceId,
38
52
  fetchImpl: c.fetchImpl,
39
53
  });
@@ -42,22 +56,33 @@ export class Guard {
42
56
  }
43
57
  protect(fn, describe, opts) {
44
58
  if (opts?.adapter && opts.adapter !== "generic") {
59
+ // Engine adapters are bound explicitly via @decidio/sdk/adapters (they need the
60
+ // engine handle at call time). Surfaced as a clear error until wired in this build.
45
61
  throw new Error(`[decidio] adapter "${opts.adapter}" must be bound via the @decidio/sdk/${opts.adapter} subpath (it needs the engine handle). See that adapter's exports.`);
46
62
  }
47
63
  const c = this.config;
48
64
  const base = {
49
65
  apiUrl: c.apiUrl, agentId: c.agentId, apiToken: c.apiToken,
50
66
  sourceSystem: c.sourceSystem, agentKey: c.agentKey, agentDid: c.agentDid, workspaceId: c.workspaceId, appUrl: c.appUrl, fetchImpl: c.fetchImpl,
67
+ pollTimeoutMs: opts?.pollTimeoutMs, pollIntervalMs: opts?.pollIntervalMs,
51
68
  };
52
69
  if ((opts?.mode ?? "durable") === "blocking") {
53
70
  console.warn("[decidio] WARNING: blocking wait — only safe for short, watched approvals. " +
54
71
  "Omit mode:\"blocking\" for durable async resume on real (minutes-to-days) approvals.");
72
+ // No resumeUrl on the blocking path — the server must not fire a webhook to an
73
+ // endpoint a blocking agent isn't serving; the poll is the only resume channel.
55
74
  return withApproval(fn, describe, base);
56
75
  }
76
+ // durable: refuse-don't-downgrade — always wire a resume transport, never silently block.
57
77
  const resume = this.ensureResume();
78
+ // WRAP-TIME registration when the action is declared: the call-time registration inside
79
+ // withApproval only helps the process that made the original call, so a restarted worker
80
+ // found an empty registry and answered no_handler — the documented restart-survival pattern
81
+ // could not actually work without this (or an explicit guard.register at startup).
58
82
  if (opts?.action)
59
83
  resume.register(opts.action, fn);
60
84
  return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume });
61
85
  }
62
86
  }
87
+ /** Preconfigured singleton — `import { guard } from "@decidio/sdk"`. */
63
88
  export const guard = Guard.fromEnv();
package/dist/index.d.ts CHANGED
@@ -1,27 +1,54 @@
1
1
  import type { ResumeController } from "./resume.js";
2
- export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume } from "./resume.js";
2
+ export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
3
3
  export type { PendingAction, PendingStore, ResumeController, ResumeOutcome } from "./resume.js";
4
4
  export { guard, Guard } from "./guard.js";
5
5
  export type { GuardFacadeConfig, ProtectOptions } from "./guard.js";
6
- export { generateKeypair, signBoundProof, signConfirm, canonicalize } from "./signing.js";
6
+ export { generateKeypair, signBoundProof, signConfirm, canonicalize, assertJsonSafe } from "./signing.js";
7
7
  export type { RequesterIdentity } from "./signing.js";
8
8
  export interface GuardConfig {
9
+ /** Base URL of the Decidio gate, e.g. "https://api.decidio.io". */
9
10
  apiUrl: string;
11
+ /** The agent identity registered with Decidio (onboarding wizard), e.g. "salesforce/SalesforceBot". */
10
12
  agentId: string;
13
+ /** Bearer token for the REST floor (a trusted, already-authenticated internal
14
+ * caller). Lives ONLY in the agent host's environment — never in client code. */
11
15
  apiToken?: string;
16
+ /** Source system label sealed onto the receipt (e.g. "salesforce"). */
12
17
  sourceSystem?: string;
18
+ /** The agent's PRIVATE signing key (PKCS8 base64), generated locally by `init` and held
19
+ * ONLY in the agent host's env as DECIDIO_AGENT_KEY. When set (with `agentDid`), the SDK
20
+ * signs a request-bound identity proof on each authorize so the floor PROVES the requester
21
+ * instead of trusting the label — required for a registered agent to auto-approve. */
13
22
  agentKey?: string;
23
+ /** The agent's PUBLIC did:key (the one registered with Decidio). Pairs with `agentKey`. */
14
24
  agentDid?: string;
25
+ /** The workspace this agent acts in (DECIDIO_WORKSPACE_ID, from the owner's agent-token mint).
26
+ * Bound into the signed proof (D-B3.6) so a captured proof can't be replayed in another tenant.
27
+ * For a tester's per-workspace agent token this is the tester's workspace id. */
15
28
  workspaceId?: string;
29
+ /** Poll cadence + ceiling while a routed action awaits human approval. Used ONLY
30
+ * by the blocking transport (no `resume` configured). */
16
31
  pollIntervalMs?: number;
17
32
  pollTimeoutMs?: number;
33
+ /** Optional async notifier so a long human wait surfaces in your logs/UI. */
18
34
  onPending?: (info: {
19
35
  decisionId: string;
20
36
  receiptId: string;
21
37
  url: string | null;
22
38
  }) => void;
39
+ /** Base URL of the Decidio app (DECIDIO_APP_URL). When set, a routed action can say WHERE to
40
+ * approve it — `<appUrl>/d/<decisionId>` opens that decision directly, on web or on the phone
41
+ * build. This link used to be built only inside our own example, so a developer who pasted the
42
+ * env block set DECIDIO_APP_URL and nothing ever read it. */
23
43
  appUrl?: string;
44
+ /** Durable async resume. When set, a routed action SUSPENDS (parks its args in the
45
+ * store, throws DecidioSuspendedError) instead of blocking — the process may exit
46
+ * and resume later via the controller's webhook handler or poll worker. This is
47
+ * the production transport for real (minutes-to-days) human approvals. */
24
48
  resume?: ResumeController;
49
+ /** Callback URL Decidio POSTs the verdict to on approval (webhook transport).
50
+ * Sent on /agent/authorize so the decision carries it. Falls back to
51
+ * resume.resumeUrl. Omit when using the poll worker. */
25
52
  resumeUrl?: string;
26
53
  fetchImpl?: typeof fetch;
27
54
  }
@@ -29,6 +56,9 @@ export interface ApprovalContext {
29
56
  action: string;
30
57
  amount: number;
31
58
  scope?: string;
59
+ /** Optional human-facing DETAILS of the action (e.g. { Account, "Lead Source", Stage }) shown to
60
+ * the approver as "what you're approving". Display-only — the policy decides on `amount`/`scope`,
61
+ * never these. Residual-safe tokenized server-side before persistence. */
32
62
  context?: Record<string, string | number>;
33
63
  }
34
64
  export declare class DecidioBlockedError extends Error {
@@ -45,6 +75,15 @@ export declare class DecidioTimeoutError extends Error {
45
75
  decisionId: string;
46
76
  constructor(decisionId: string);
47
77
  }
78
+ /** A transport failure with GUIDANCE instead of a raw undici stack (round-4 Codex catch: the CLI
79
+ * wrapped network errors kindly but the runtime leaked `TypeError: fetch failed`). Carries the
80
+ * endpoint, the operation, the underlying code, and the safety state a caller cares about most:
81
+ * whether anything could have executed. */
82
+ /** The workspace's governed-action allowance is exhausted (HTTP 429, code AGENT_RATE). The
83
+ * server says exactly how much and until when — relayed here instead of discarded (round-2
84
+ * blind catch: this surfaced as a bare `Error` with no decisionId, the tour misdiagnosed it as
85
+ * a connectivity failure, and doctor then corroborated the wrong suspect). Nothing was routed,
86
+ * recorded, or executed; retry after `resetAt`. */
48
87
  export declare class DecidioRateLimitError extends Error {
49
88
  limit: number | undefined;
50
89
  remaining: number | undefined;
@@ -57,5 +96,12 @@ export declare class DecidioNetworkError extends Error {
57
96
  code: string | undefined;
58
97
  constructor(endpoint: string, op: string, code: string | undefined, cause: unknown);
59
98
  }
99
+ /**
100
+ * Wrap an async action behind the Decidio gate. `describe` extracts the approval
101
+ * context (action, amount, scope) from the call arguments. Returns a function with
102
+ * the SAME signature as `fn` — drop-in.
103
+ */
104
+ /** `<appUrl>/d/<decisionId>` — the deep link that opens this decision directly, on the web app or
105
+ * the phone build. Null when DECIDIO_APP_URL is not configured, never a half-built URL. */
60
106
  export declare function decisionUrl(appUrl: string | undefined, decisionId: string): string | null;
61
107
  export declare function withApproval<A extends any[], R>(fn: (...args: A) => Promise<R>, describe: (...args: A) => ApprovalContext, config: GuardConfig): (...args: A) => Promise<R>;
package/dist/index.js CHANGED
@@ -1,8 +1,37 @@
1
- import { DecidioSuspendedError } from "./resume.js";
2
- import { signBoundProof, signConfirm } from "./signing.js";
3
- export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume } from "./resume.js";
1
+ // @decidio/sdk — the one-line approval gate for AI-agent actions.
2
+ //
3
+ // import { guard } from "@decidio/sdk";
4
+ // const createOpportunity = guard.protect(createOpportunityRaw, (a) => ({
5
+ // action: "createOpportunity", amount: a.amount, scope: "Opportunity",
6
+ // }));
7
+ //
8
+ // NOTE: `guard` is the preconfigured SINGLETON (a Guard instance, see guard.ts). It is NOT a
9
+ // GuardConfig, so it cannot be passed as withApproval's third argument — this comment used to
10
+ // show exactly that, and the error propagated into the product's own copy-paste snippets.
11
+ // `withApproval(fn, describe, config)` is the lower-level export and takes a config OBJECT.
12
+ //
13
+ // On call, the wrapper asks Decidio's gate to authorize the action. Decidio's
14
+ // policy engine (Cedar / computeChain) returns proceed | route | block:
15
+ // • proceed → the wrapped fn runs immediately (auto-approved under policy)
16
+ // • route → the wrapper WAITS for a human to approve in Decidio's queue, then
17
+ // runs the fn (or throws if rejected)
18
+ // • block → the wrapper throws; the fn never runs
19
+ // The wrapper is a LISTENER: it captures the real source-system response the wrapped
20
+ // fn returns and reports it to Decidio, which seals an `application_confirmed`
21
+ // Authority Receipt (Decidio minimizes + tokenizes before sealing — the immutable
22
+ // seal never stores raw payloads). The agent executes its own action; Decidio
23
+ // authorizes, records, and (optionally) independently verifies it. Decidio holds NO
24
+ // write credentials for your system — only the authority decision and the record.
25
+ //
26
+ // Framework-agnostic by design: it wraps at the tool-call boundary, so it works with
27
+ // any agent runtime (LangGraph, OpenAI Agents SDK, CrewAI, a raw tool loop) and any
28
+ // action (Salesforce, M365, SAP, a DB write) — the wrapper doesn't care what the fn
29
+ // does, only that it returns the source response.
30
+ import { DecidioSuspendedError, assertConfirmRecorded } from "./resume.js";
31
+ import { signBoundProof, signConfirm, assertJsonSafe } from "./signing.js";
32
+ export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
4
33
  export { guard, Guard } from "./guard.js";
5
- export { generateKeypair, signBoundProof, signConfirm, canonicalize } from "./signing.js";
34
+ export { generateKeypair, signBoundProof, signConfirm, canonicalize, assertJsonSafe } from "./signing.js";
6
35
  export class DecidioBlockedError extends Error {
7
36
  reason;
8
37
  decisionId;
@@ -32,6 +61,15 @@ export class DecidioTimeoutError extends Error {
32
61
  }
33
62
  }
34
63
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
64
+ /** A transport failure with GUIDANCE instead of a raw undici stack (round-4 Codex catch: the CLI
65
+ * wrapped network errors kindly but the runtime leaked `TypeError: fetch failed`). Carries the
66
+ * endpoint, the operation, the underlying code, and the safety state a caller cares about most:
67
+ * whether anything could have executed. */
68
+ /** The workspace's governed-action allowance is exhausted (HTTP 429, code AGENT_RATE). The
69
+ * server says exactly how much and until when — relayed here instead of discarded (round-2
70
+ * blind catch: this surfaced as a bare `Error` with no decisionId, the tour misdiagnosed it as
71
+ * a connectivity failure, and doctor then corroborated the wrong suspect). Nothing was routed,
72
+ * recorded, or executed; retry after `resetAt`. */
35
73
  export class DecidioRateLimitError extends Error {
36
74
  limit;
37
75
  remaining;
@@ -50,6 +88,9 @@ export class DecidioNetworkError extends Error {
50
88
  op;
51
89
  code;
52
90
  constructor(endpoint, op, code, cause) {
91
+ // A refused localhost connection almost always means MISSING CONFIG, not a broken network
92
+ // (blind-review catch: forgetting `--env-file .env` produced advice about network debugging).
93
+ // Name the likely cause before the generic one.
53
94
  super(`[decidio] could not reach ${endpoint} during ${op}${code ? ` (${code})` : ""}. ` +
54
95
  (/localhost|127\.0\.0\.1/.test(endpoint) && code === "ECONNREFUSED"
55
96
  ? `A refused localhost connection usually means the SDK never saw your config: if your settings live in .env, run node with --env-file=.env (or export DECIDIO_API_URL — the hosted sandbox is https://decidio-api.onrender.com). `
@@ -96,6 +137,13 @@ async function get(cfg, path) {
96
137
  throw new Error(`decidio ${path} ${res.status}`);
97
138
  return res.json();
98
139
  }
140
+ /**
141
+ * Wrap an async action behind the Decidio gate. `describe` extracts the approval
142
+ * context (action, amount, scope) from the call arguments. Returns a function with
143
+ * the SAME signature as `fn` — drop-in.
144
+ */
145
+ /** `<appUrl>/d/<decisionId>` — the deep link that opens this decision directly, on the web app or
146
+ * the phone build. Null when DECIDIO_APP_URL is not configured, never a half-built URL. */
99
147
  export function decisionUrl(appUrl, decisionId) {
100
148
  if (!appUrl)
101
149
  return null;
@@ -107,9 +155,16 @@ export function withApproval(fn, describe, config) {
107
155
  const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
108
156
  return async (...args) => {
109
157
  const ctx = describe(...args);
158
+ // 1. ask the gate (carry the resume URL so a routed decision knows where to call back).
159
+ // When a signing key is configured, sign a request-bound identity proof so the floor
160
+ // can PROVE this agent rather than trust the `requester` label (registered agents must
161
+ // sign to auto-approve). Unsigned still works for generic/non-agent callers.
110
162
  const requesterIdentity = (config.agentKey && config.agentDid)
111
163
  ? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
112
164
  : undefined;
165
+ // The described request must ALSO be JSON-safe (prodsec F-01): a NaN amount would reach the
166
+ // human as null — they would approve something other than what the caller described.
167
+ assertJsonSafe(ctx, "describe(...)");
113
168
  const auth = await post(config, "/agent/authorize", {
114
169
  requester: config.agentId, action: ctx.action, amount: ctx.amount,
115
170
  scope: ctx.scope, sourceSystem: config.sourceSystem,
@@ -117,28 +172,52 @@ export function withApproval(fn, describe, config) {
117
172
  ...(requesterIdentity ? { requesterIdentity } : {}),
118
173
  ...(resumeUrl ? { resumeUrl } : {}),
119
174
  });
175
+ // Allow-list, not deny-list: an unrecognised verdict means this SDK is older than the
176
+ // server, and the safe reading of an answer you do not understand is NO. Without this, the
177
+ // day a fourth verdict ships every deployed SDK falls through and executes it.
120
178
  if (auth.decision !== "proceed" && auth.decision !== "route" && auth.decision !== "block")
121
179
  throw new DecidioBlockedError(`unrecognized gate verdict: ${String(auth.decision)}`, auth.decisionId);
122
180
  if (auth.decision === "block")
123
181
  throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
182
+ // 2. routed → either SUSPEND for async resume (durable) or BLOCK-poll (interactive)
124
183
  if (auth.decision === "route") {
184
+ // ALWAYS hand back the decision id — an agent that suspends and says only "waiting" leaves
185
+ // the operator hunting through a queue, and an agent that says NOTHING (the pre-0.1.2 bug:
186
+ // the print was gated on appUrl, which init never writes) leaves them staring at a silent
187
+ // terminal. The Codex acceptance run failed exactly this claim; the id line is unconditional
188
+ // now, with the deep link when appUrl is configured and the CLI command when not.
125
189
  const approvalUrl = decisionUrl(config.appUrl, auth.decisionId);
126
190
  if (config.onPending)
127
191
  config.onPending({ decisionId: auth.decisionId, receiptId: auth.receiptId, url: approvalUrl });
192
+ // stderr, not stdout: this is SDK diagnostics inside a USER'S process — their stdout
193
+ // belongs to their application output (round-3 blind catch: piping the agent's stdout
194
+ // captured this status line interleaved with app output, while the warning went to stderr).
128
195
  else
129
- console.log(`[decidio] ${ctx.action} is awaiting approval (decision ${auth.decisionId}) — approve it ${approvalUrl ? `at ${approvalUrl}` : `in your Decidio queue, or: npx @decidio/sdk approvals approve ${auth.decisionId}`}`);
196
+ console.error(`[decidio] ${ctx.action} is awaiting approval (decision ${auth.decisionId}) — approve it ${approvalUrl ? `at ${approvalUrl}` : `in your Decidio queue, or: npx @decidio/sdk approvals approve ${auth.decisionId}`}`);
197
+ // Durable transport: park the args agent-side, register the raw fn so a (cold)
198
+ // resume can replay it, and SUSPEND. The process may exit now; the controller's
199
+ // webhook handler or poll worker re-executes THIS fn when a human approves.
200
+ // Decidio never executes — it only signals.
130
201
  if (config.resume) {
202
+ // Propagate the agent's signing identity to the resume controller so the eventual
203
+ // (possibly cold) confirm on resume is SIGNED too — earning application_confirmed on
204
+ // the durable path, not just inline. (No-op if the controller already has its own.)
131
205
  config.resume.agentId ??= config.agentId;
132
206
  config.resume.agentKey ??= config.agentKey;
133
207
  config.resume.agentDid ??= config.agentDid;
134
208
  config.resume.register(ctx.action, fn);
209
+ // park() (not store.put) so the payload is SIGNED — resolve() refuses a pending file
210
+ // whose args no longer match what was routed (round-6 acceptance catch).
135
211
  await config.resume.park({
136
212
  decisionId: auth.decisionId, receiptId: auth.receiptId,
137
213
  action: ctx.action, amount: ctx.amount, scope: ctx.scope, args,
138
214
  });
139
215
  throw new DecidioSuspendedError(auth.decisionId, ctx.action);
140
216
  }
217
+ // Interactive transport (no resume configured): block until decided. Only
218
+ // appropriate for short, watched approvals (demos, synchronous tools).
141
219
  const deadline = Date.now() + pollTimeout;
220
+ // eslint-disable-next-line no-constant-condition
142
221
  while (true) {
143
222
  const st = await get(config, `/agent/status/${encodeURIComponent(auth.decisionId)}`);
144
223
  if (st.state === "approved" || st.state === "auto_approved")
@@ -148,25 +227,42 @@ export function withApproval(fn, describe, config) {
148
227
  if (st.state === "blocked")
149
228
  throw new DecidioBlockedError(st.reason ?? "blocked", auth.decisionId);
150
229
  if (Date.now() > deadline) {
230
+ // The founder-visible failure of blocking: the wait has a ceiling, and past it the
231
+ // work is LOST — the decision stays open in Decidio but nothing is parked to resume.
232
+ // Say so, say the limit, and say the durable way out.
151
233
  console.warn(`[decidio] blocking wait hit its ${Math.round(pollTimeout / 1000)}s limit — decision ${auth.decisionId} is still open in Decidio, but this process stopped waiting and nothing was executed. Re-run to request again, or use durable resume (guard.worker()) so an approval is never lost.`);
152
234
  throw new DecidioTimeoutError(auth.decisionId);
153
235
  }
154
236
  await sleep(pollInterval);
155
237
  }
156
238
  }
239
+ // 3. authorized inline (proceed, or interactive route just approved) → run the
240
+ // REAL action and capture the source response (listener)
157
241
  const result = await fn(...args);
242
+ // 4. report the captured outcome → Decidio seals application_confirmed
243
+ // (server minimizes + tokenizes the response before sealing). Best-effort:
244
+ // a confirmation failure must not undo a successful business action.
158
245
  try {
246
+ // Sign the confirmation too (bound to {action:"confirm", scope:decisionId}) so the gate
247
+ // can PROVE this is the receipt's original requester before granting the wrapper tier
248
+ // (application_confirmed). Without a key the report is capped at agent_asserted server-side.
159
249
  const confirmIdentity = signConfirm(auth.decisionId, config);
160
- await post(config, "/agent/confirm", {
250
+ const confirmBody = await post(config, "/agent/confirm", {
161
251
  decisionId: auth.decisionId,
162
252
  capturedResponse: result ?? null,
163
- source: "wrapper",
253
+ source: "wrapper", // the SDK captured the genuine response, not an agent self-report
164
254
  ...(confirmIdentity ? { requesterIdentity: confirmIdentity } : {}),
165
255
  });
256
+ // The ONE confirm-body rule, on this door too (guard equivalent-path catch: transport
257
+ // success is not confirmation — a tierless 2xx must not read as recorded evidence).
258
+ assertConfirmRecorded(confirmBody, auth.decisionId, !!confirmIdentity);
259
+ // An UNSIGNED confirm is server-capped at the weaker agent_asserted tier. Say so at the
260
+ // moment it happens — a silent evidence downgrade was round 3's acceptance FAIL.
166
261
  if (!confirmIdentity)
167
262
  console.warn(`[decidio] execution evidence for ${auth.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 (npx @decidio/sdk init writes them) to earn application_confirmed.`);
168
263
  }
169
264
  catch (e) {
265
+ // surface but don't throw — the action already happened
170
266
  console.warn(`[decidio] confirmation post failed (action succeeded): ${e.message}`);
171
267
  }
172
268
  return result;
package/dist/inngest.d.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import type { RequestListener } from "node:http";
2
2
  import { verifyResumeSignature } from "./resume.js";
3
3
  import type { GuardConfig, ApprovalContext } from "./index.js";
4
+ /** The Inngest event Decidio's verdict is translated into. The waiting function
5
+ * correlates on `data.decisionId`. */
4
6
  export declare const DECIDIO_RESUME_EVENT = "decidio/decision.resolved";
7
+ /** The minimal slices of the Inngest step + client APIs we use, typed structurally so
8
+ * this file doesn't force an `inngest` type dependency on the core build. */
5
9
  export interface InngestStepLike {
6
10
  run<T>(id: string, fn: () => Promise<T>): Promise<T>;
7
11
  waitForEvent(id: string, opts: {
@@ -25,6 +29,16 @@ export type GateStepOutcome<R> = {
25
29
  decisionId: string;
26
30
  result?: R;
27
31
  };
32
+ /**
33
+ * Gate an action INSIDE an Inngest function, with durable suspend on routing.
34
+ * • proceed → run the action (as a step) + confirm.
35
+ * • route → `step.waitForEvent` durably suspends until Decidio's resume event for
36
+ * THIS decision arrives; approved → run + confirm; rejected → status.
37
+ * • block → return blocked (the action never runs).
38
+ * The action runs in YOUR process with YOUR credentials — Decidio only signals.
39
+ * `config.resumeUrl` must point at the bridge endpoint (createInngestResumeBridge),
40
+ * so Decidio's webhook is translated into the event this step waits on.
41
+ */
28
42
  export declare function decidioGateStep<R>(args: {
29
43
  step: InngestStepLike;
30
44
  config: GuardConfig;
@@ -32,6 +46,12 @@ export declare function decidioGateStep<R>(args: {
32
46
  run: () => Promise<R>;
33
47
  timeout?: string;
34
48
  }): Promise<GateStepOutcome<R>>;
49
+ /**
50
+ * Decidio → Inngest bridge. Mount as an HTTP endpoint and register it as the agent's
51
+ * `resumeUrl`. It verifies Decidio's signed resume webhook (same HMAC as the core
52
+ * handler) and emits `DECIDIO_RESUME_EVENT` into Inngest, waking the suspended
53
+ * `decidioGateStep`. Fails CLOSED without a configured secret (unless allowUnsigned).
54
+ */
35
55
  export declare function createInngestResumeBridge(opts: {
36
56
  inngest: InngestClientLike;
37
57
  webhookSecret?: string;
package/dist/inngest.js CHANGED
@@ -1,12 +1,26 @@
1
1
  import { verifyResumeSignature } from "./resume.js";
2
2
  import { signBoundProof, signConfirm } from "./signing.js";
3
+ /** The Inngest event Decidio's verdict is translated into. The waiting function
4
+ * correlates on `data.decisionId`. */
3
5
  export const DECIDIO_RESUME_EVENT = "decidio/decision.resolved";
6
+ /**
7
+ * Gate an action INSIDE an Inngest function, with durable suspend on routing.
8
+ * • proceed → run the action (as a step) + confirm.
9
+ * • route → `step.waitForEvent` durably suspends until Decidio's resume event for
10
+ * THIS decision arrives; approved → run + confirm; rejected → status.
11
+ * • block → return blocked (the action never runs).
12
+ * The action runs in YOUR process with YOUR credentials — Decidio only signals.
13
+ * `config.resumeUrl` must point at the bridge endpoint (createInngestResumeBridge),
14
+ * so Decidio's webhook is translated into the event this step waits on.
15
+ */
4
16
  export async function decidioGateStep(args) {
5
17
  const { step, config, ctx } = args;
6
18
  const base = config.apiUrl.replace(/\/$/, "");
7
19
  const f = config.fetchImpl ?? fetch;
8
20
  const authHeaders = { "content-type": "application/json", ...(config.apiToken ? { authorization: `Bearer ${config.apiToken}` } : {}) };
21
+ // 1. authorize (memoized as a step, so a replay doesn't re-ask the gate)
9
22
  const auth = await step.run("decidio-authorize", async () => {
23
+ // Sign the bound proof so an Inngest-path agent can prove its identity + auto-approve.
10
24
  const requesterIdentity = (config.agentKey && config.agentDid)
11
25
  ? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
12
26
  : undefined;
@@ -26,6 +40,9 @@ export async function decidioGateStep(args) {
26
40
  if (auth.decision === "block")
27
41
  return { status: "blocked", decisionId: auth.decisionId };
28
42
  if (auth.decision === "route") {
43
+ // 2. durably SUSPEND until the resume event for THIS decision arrives. Zero
44
+ // compute while waiting; the worker/process may be recycled — Inngest resumes
45
+ // from this checkpoint. `if` correlates the awaited event to this decisionId.
29
46
  const ev = await step.waitForEvent("decidio-await-approval", {
30
47
  event: DECIDIO_RESUME_EVENT,
31
48
  timeout: args.timeout ?? "30d",
@@ -39,6 +56,8 @@ export async function decidioGateStep(args) {
39
56
  if (verdict !== "approved" && verdict !== "auto_approved")
40
57
  return { status: "blocked", decisionId: auth.decisionId };
41
58
  }
59
+ // 3. authorized → perform the agent's OWN action (a step), then confirm the captured
60
+ // response so Decidio seals application_confirmed (never executes it itself).
42
61
  const result = await step.run("agent-action", args.run);
43
62
  await step.run("decidio-confirm", async () => {
44
63
  const requesterIdentity = signConfirm(auth.decisionId, config);
@@ -50,6 +69,12 @@ export async function decidioGateStep(args) {
50
69
  });
51
70
  return { status: "executed", decisionId: auth.decisionId, result };
52
71
  }
72
+ /**
73
+ * Decidio → Inngest bridge. Mount as an HTTP endpoint and register it as the agent's
74
+ * `resumeUrl`. It verifies Decidio's signed resume webhook (same HMAC as the core
75
+ * handler) and emits `DECIDIO_RESUME_EVENT` into Inngest, waking the suspended
76
+ * `decidioGateStep`. Fails CLOSED without a configured secret (unless allowUnsigned).
77
+ */
53
78
  export function createInngestResumeBridge(opts) {
54
79
  const secret = opts.webhookSecret ?? "";
55
80
  const eventName = opts.eventName ?? DECIDIO_RESUME_EVENT;
@@ -85,4 +110,5 @@ export function createInngestResumeBridge(opts) {
85
110
  });
86
111
  };
87
112
  }
113
+ // Re-export the signature check so a host can verify the webhook out-of-band if needed.
88
114
  export { verifyResumeSignature };