@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.
@@ -0,0 +1,12 @@
1
+ /** A gate response this SDK could not accept, carrying what a caller needs to BRANCH on rather
2
+ * than a stringified body they would have to parse. `status` is the HTTP status; `retryable`
3
+ * distinguishes a terminal refusal (401/403 — a credential problem no retry fixes) from a
4
+ * transient one (5xx, 429). Every long-running agent hits the terminal case eventually: an
5
+ * agent token expires, or `init` is re-run and revokes it. */
6
+ export declare class DecidioHttpError extends Error {
7
+ status: number;
8
+ endpoint: string;
9
+ body?: unknown | undefined;
10
+ readonly retryable: boolean;
11
+ constructor(status: number, endpoint: string, body?: unknown | undefined);
12
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,18 @@
1
+ export class DecidioHttpError extends Error {
2
+ status;
3
+ endpoint;
4
+ body;
5
+ retryable;
6
+ constructor(status, endpoint, body) {
7
+ const terminal = status === 401 || status === 403;
8
+ super(`[decidio] ${endpoint} refused this call (HTTP ${status})` +
9
+ (terminal
10
+ ? ` — the credential was rejected. The agent token may have expired or been revoked (re-running \`init\` for the same agent revokes the previous one). Mint a fresh one: npx @decidio/sdk init <agent-name>, then restart this process. Nothing was routed or executed.`
11
+ : ` — this looks transient; retrying is safe (a retry creates a NEW request and can never double-execute). Nothing was routed or executed.`));
12
+ this.status = status;
13
+ this.endpoint = endpoint;
14
+ this.body = body;
15
+ this.name = "DecidioHttpError";
16
+ this.retryable = !terminal;
17
+ }
18
+ }
@@ -0,0 +1,41 @@
1
+ /** Every tier the gate can record, weakest to strongest. Mirrors the server's enum exactly; an
2
+ * answer outside this list means this SDK version does not understand the record, and an answer
3
+ * we do not understand is never treated as evidence. */
4
+ export declare const EVIDENCE_TIERS: readonly ["pending", "execution_unconfirmed", "agent_asserted", "sensor_observed", "application_confirmed", "independently_reconciled"];
5
+ export type EvidenceTier = (typeof EVIDENCE_TIERS)[number];
6
+ export interface EvidenceOutcome {
7
+ /** Decidio accepted the report and named the tier it recorded. */
8
+ reportRecorded: boolean;
9
+ /** The tier Decidio recorded, verbatim. */
10
+ evidenceTier?: EvidenceTier;
11
+ /** The recorded tier attests a committed execution. */
12
+ executionConfirmed: boolean;
13
+ /** Present when the outcome is worth explaining (why a signed report did not confirm, or why
14
+ * a report was not recorded at all) — always actionable, never a shrug. */
15
+ diagnostic?: string;
16
+ }
17
+ /** The SERVER's rule for `application_confirmed`, restated here exactly as implemented so this
18
+ * explanation can never drift from it: the captured response must carry a RECORD ID
19
+ * (`id` / `recordId` / `Id`), and must not explicitly deny success (`success: false` /
20
+ * `ok: false`). An id with no success field is treated as a commit; a response with neither is
21
+ * honestly `execution_unconfirmed`. */
22
+ export declare function describeTierGap(capturedResponse: unknown): string;
23
+ /** Read the gate's confirm response and say precisely what happened. Fail-closed: an unknown or
24
+ * missing tier is NOT a recorded report. */
25
+ export declare function readConfirmResponse(body: unknown, opts: {
26
+ signed: boolean;
27
+ capturedResponse?: unknown;
28
+ journalled?: boolean;
29
+ }): EvidenceOutcome;
30
+ import { type RequesterIdentity } from "./signing.js";
31
+ /** Minimal identity a confirm body needs. */
32
+ export interface ReportIdentity {
33
+ agentId: string;
34
+ agentKey?: string;
35
+ agentDid?: string;
36
+ workspaceId?: string;
37
+ }
38
+ /** The ONE confirm request shape, shared by every door (inline, durable resume, adapters).
39
+ * Carries the identity proof (who is reporting) AND — when a key is configured — a signed,
40
+ * decision-bound ExecutionReport over the canonical response digest (what was reported). */
41
+ export declare function buildConfirmBody(cfg: ReportIdentity, decisionId: string, canonicalResult: unknown, requesterIdentity?: RequesterIdentity, source?: string): Record<string, unknown>;
@@ -0,0 +1,57 @@
1
+ export const EVIDENCE_TIERS = [
2
+ "pending",
3
+ "execution_unconfirmed",
4
+ "agent_asserted",
5
+ "sensor_observed",
6
+ "application_confirmed",
7
+ "independently_reconciled",
8
+ ];
9
+ const CONFIRMING = ["sensor_observed", "application_confirmed", "independently_reconciled"];
10
+ export function describeTierGap(capturedResponse) {
11
+ const r = (capturedResponse ?? {});
12
+ const id = r.id ?? r.recordId ?? r.Id ?? null;
13
+ const denied = r.success === false || r.ok === false;
14
+ if (denied) {
15
+ return "your protected function reported success: false (or ok: false), so Decidio recorded the execution as UNCONFIRMED — that is the response saying the downstream write did not commit. If it did commit, return a truthy success flag.";
16
+ }
17
+ if (id == null) {
18
+ return "your protected function's return value carried no record id (`id`, `recordId` or `Id`), so Decidio has no evidence that the downstream write committed and honestly recorded the execution as UNCONFIRMED. Return the source system's identifiers (e.g. `{ id, success }` or `{ recordId, ok }`), or map them with the `evidence` option, to earn application_confirmed.";
19
+ }
20
+ return "the report was accepted but the gate recorded a weaker tier — check that this agent's registration and signing key still match (`npx @decidio/sdk doctor`).";
21
+ }
22
+ export function readConfirmResponse(body, opts) {
23
+ const retry = opts.journalled
24
+ ? "re-send the report with `guard.resume.reconfirm(decisionId)` once connectivity or the service is healthy."
25
+ : "this path journalled no report, so there is nothing to re-send from here — record the outcome from your own system, or run the action through `guard.protect`'s durable mode, where reports ARE journalled and `reconfirm` can replay them.";
26
+ const b = body;
27
+ if (!b || b.ok !== true || typeof b.confirmation !== "string") {
28
+ return { reportRecorded: false, executionConfirmed: false, diagnostic: `the gate answered 2xx but named no evidence tier — the execution report is NOT recorded. Your action DID run; ${retry}` };
29
+ }
30
+ const tier = b.confirmation;
31
+ if (!EVIDENCE_TIERS.includes(tier)) {
32
+ return { reportRecorded: false, executionConfirmed: false, diagnostic: `the gate reported an evidence tier this SDK does not recognise (${JSON.stringify(tier)}) — refusing to treat it as evidence. Upgrade @decidio/sdk; your action DID run, and ${retry}` };
33
+ }
34
+ const executionConfirmed = CONFIRMING.includes(tier);
35
+ const outcome = { reportRecorded: true, evidenceTier: tier, executionConfirmed };
36
+ if (!executionConfirmed && opts.signed)
37
+ outcome.diagnostic = describeTierGap(opts.capturedResponse);
38
+ else if (!executionConfirmed)
39
+ outcome.diagnostic = "no signing key is configured, so the gate capped this report at an unproven agent assertion. Run `npx @decidio/sdk init` to generate the agent's key and earn a confirming tier.";
40
+ return outcome;
41
+ }
42
+ import { signExecutionReport, resultDigest } from "./signing.js";
43
+ export function buildConfirmBody(cfg, decisionId, canonicalResult, requesterIdentity, source = "wrapper") {
44
+ const executionReport = (cfg.agentKey && cfg.agentDid)
45
+ ? signExecutionReport(cfg.agentKey, {
46
+ decisionId, agentId: cfg.agentId, did: cfg.agentDid, workspaceId: cfg.workspaceId,
47
+ source, resultDigest: resultDigest(canonicalResult ?? null),
48
+ })
49
+ : undefined;
50
+ return {
51
+ decisionId,
52
+ capturedResponse: canonicalResult ?? null,
53
+ source,
54
+ ...(requesterIdentity ? { requesterIdentity } : {}),
55
+ ...(executionReport ? { executionReport } : {}),
56
+ };
57
+ }
package/dist/guard.d.ts CHANGED
@@ -20,9 +20,14 @@ export interface GuardFacadeConfig {
20
20
  fetchImpl?: typeof fetch;
21
21
  }
22
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. */
25
- adapter?: "generic" | "langgraph" | "openai" | "temporal" | "inngest" | "mcp";
23
+ /** null/"generic" → the core gate; engine adapters (langgraph/openai/temporal/inngest)
24
+ * live in @decidio/sdk subpaths and take the engine handle.
25
+ *
26
+ * BETA: the engine adapters share the core's gate semantics (verdict allow-list, canonical
27
+ * arguments, transport rule and evidence handling are one implementation, conformance-tested
28
+ * across every door), but their ENGINE integration is verified against structural mocks, not
29
+ * live engines. Use the core path for production until that changes. */
30
+ adapter?: "generic" | "langgraph" | "openai" | "temporal" | "inngest";
26
31
  /** "durable" (default) suspends for async approval; "blocking" polls (short approvals only). */
27
32
  mode?: "durable" | "blocking";
28
33
  /** The action name this wrapper produces, declared UP FRONT so the resume registry is populated
@@ -31,18 +36,31 @@ export interface ProtectOptions {
31
36
  * `describe` (which names the action) cannot run without the original call arguments. Declare
32
37
  * it and a restarted process resumes work it never originated. */
33
38
  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). */
39
+ /** Blocking-mode poll ceiling in ms (default 600000 = 10 min). Set it here so a CI pipeline
40
+ * fails fast instead of blocking the full 10-minute default while a routed action waits.
41
+ * Accepted identically by the Python SDK (poll_timeout, seconds). */
38
42
  pollTimeoutMs?: number;
39
43
  /** Blocking-mode poll interval in ms (default 3000). */
40
44
  pollIntervalMs?: number;
45
+ /** Map YOUR function's natural return value to the evidence Decidio records. The gate awards
46
+ * its strongest wrapper tier (application_confirmed) only for a committed record id plus a
47
+ * success indication, so instead of forcing your API into magic field names:
48
+ * `evidence: (r) => ({ recordId: r.transactionId, success: r.status === "committed" })`. */
49
+ evidence?: (result: any) => unknown;
41
50
  }
42
51
  export declare class Guard {
43
52
  config: GuardFacadeConfig;
44
- resume?: ResumeController;
53
+ private _resume?;
45
54
  constructor(config: GuardFacadeConfig, resume?: ResumeController);
55
+ /** The recovery surface — `guard.resume.recover()` / `.reconfirm(id)`. BUILT ON FIRST ACCESS,
56
+ * because the process that needs to recover is usually a COLD one that never made the original
57
+ * call, so nothing has lazily constructed the controller yet. As a plain field this read was
58
+ * `undefined` there and the documented recovery loop crashed on the exact path it exists for.
59
+ * Unconfigured still fails loud (naming DECIDIO_API_URL), never silently. */
60
+ get resume(): ResumeController;
61
+ /** Kept writable: hosts that construct their own controller (custom store, injected fetch)
62
+ * assign it here, and `new Guard(cfg, controller)` must stay equivalent to that assignment. */
63
+ set resume(r: ResumeController);
46
64
  static fromEnv(): Guard;
47
65
  /** Register a raw fn under its action name so a COLD process (one that never made the original
48
66
  * call) can resume it. Call this at startup beside `guard.worker()` — or pass
@@ -63,6 +81,9 @@ export declare class Guard {
63
81
  stop: () => void;
64
82
  };
65
83
  private ensureResume;
84
+ /** Throws when the facade has no gate URL — at USE time, not import time, so `import { guard }`
85
+ * stays safe in envless contexts. */
86
+ private assertConfigured;
66
87
  protect<A extends any[], R>(fn: (...a: A) => Promise<R>, describe: (...a: A) => ApprovalContext, opts?: ProtectOptions): (...a: A) => Promise<R>;
67
88
  }
68
89
  /** Preconfigured singleton — `import { guard } from "@decidio/sdk"`. */
package/dist/guard.js CHANGED
@@ -1,19 +1,17 @@
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.
5
1
  import { withApproval } from "./index.js";
6
2
  import { createDecidioResume, FilePendingStore } from "./resume.js";
7
3
  export class Guard {
8
4
  config;
9
- resume;
5
+ _resume;
10
6
  constructor(config, resume) {
11
7
  this.config = config;
12
- this.resume = resume;
8
+ this._resume = resume;
13
9
  }
10
+ get resume() { return this.ensureResume(); }
11
+ set resume(r) { this._resume = r; }
14
12
  static fromEnv() {
15
13
  return new Guard({
16
- apiUrl: process.env.DECIDIO_API_URL ?? "http://localhost:4000",
14
+ apiUrl: process.env.DECIDIO_API_URL ?? "",
17
15
  agentId: process.env.DECIDIO_AGENT_ID ?? "agent",
18
16
  apiToken: process.env.DECIDIO_API_TOKEN,
19
17
  sourceSystem: process.env.DECIDIO_SOURCE_SYSTEM,
@@ -26,39 +24,37 @@ export class Guard {
26
24
  appUrl: process.env.DECIDIO_APP_URL,
27
25
  });
28
26
  }
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. */
32
27
  register(action, fn) { this.ensureResume().register(action, fn); }
33
- /** Mount on a node/Express/Fastify route: `app.post("/decidio/resume", guard.resumeHandler())`. */
34
28
  resumeHandler() { return this.ensureResume().webhookHandler(); }
35
- /** Mount on a Next.js/Hono/CF/Bun route: `export const POST = (req) => guard.resumeFetchHandler()(req)`. */
36
29
  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. */
40
30
  worker(opts) {
41
31
  return this.ensureResume().worker(opts);
42
32
  }
43
33
  ensureResume() {
44
- if (!this.resume) {
34
+ this.assertConfigured();
35
+ if (!this._resume) {
45
36
  const c = this.config;
46
- this.resume = createDecidioResume({
37
+ this._resume = createDecidioResume({
47
38
  apiUrl: c.apiUrl, apiToken: c.apiToken, webhookSecret: c.webhookSecret,
48
39
  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).
51
40
  agentId: c.agentId, agentKey: c.agentKey, agentDid: c.agentDid, workspaceId: c.workspaceId,
52
41
  fetchImpl: c.fetchImpl,
53
42
  });
54
43
  }
55
- return this.resume;
44
+ return this._resume;
45
+ }
46
+ assertConfigured() {
47
+ if (!this.config.apiUrl)
48
+ throw new Error("[decidio] no gate URL configured — set DECIDIO_API_URL (the hosted sandbox is https://decidio-api.onrender.com) or run `npx @decidio/sdk init`, then start your process with `node --env-file=.env`. Nothing was routed or executed.");
56
49
  }
57
50
  protect(fn, describe, opts) {
51
+ this.assertConfigured();
58
52
  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.
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.`);
53
+ const REAL = ["langgraph", "openai", "temporal", "inngest"];
54
+ if (REAL.includes(opts.adapter)) {
55
+ 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.`);
56
+ }
57
+ throw new Error(`[decidio] unknown adapter "${opts.adapter}" — the available engine adapters are ${REAL.map((a) => `"${a}"`).join(", ")} (each via its @decidio/sdk/<name> subpath), or omit the option for the core gate.`);
62
58
  }
63
59
  const c = this.config;
64
60
  const base = {
@@ -67,22 +63,19 @@ export class Guard {
67
63
  pollTimeoutMs: opts?.pollTimeoutMs, pollIntervalMs: opts?.pollIntervalMs,
68
64
  };
69
65
  if ((opts?.mode ?? "durable") === "blocking") {
70
- console.warn("[decidio] WARNING: blocking wait — only safe for short, watched approvals. " +
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.
74
- return withApproval(fn, describe, base);
66
+ const ceilingMs = opts?.pollTimeoutMs ?? base.pollTimeoutMs ?? 600_000;
67
+ console.warn(`[decidio] WARNING: blocking wait — this call gives up after ${Math.round(ceilingMs / 1000)}s ` +
68
+ `(pollTimeoutMs), and when it does, the decision stays OPEN in Decidio while this process stops waiting: ` +
69
+ `a later approval cannot resume it. Only safe for short, watched approvals — omit mode:"blocking" for ` +
70
+ `durable async resume on real (minutes-to-days) approvals.`);
71
+ return withApproval(fn, describe, base, opts?.evidence ? { evidence: opts.evidence } : undefined);
75
72
  }
76
- // durable: refuse-don't-downgrade — always wire a resume transport, never silently block.
77
73
  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).
82
74
  if (opts?.action)
83
75
  resume.register(opts.action, fn);
84
- return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume });
76
+ if (opts?.evidence)
77
+ resume.evidence = opts.evidence;
78
+ return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume }, opts?.evidence ? { evidence: opts.evidence } : undefined);
85
79
  }
86
80
  }
87
- /** Preconfigured singleton — `import { guard } from "@decidio/sdk"`. */
88
81
  export const guard = Guard.fromEnv();
package/dist/index.d.ts CHANGED
@@ -3,7 +3,12 @@ export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDeci
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, assertJsonSafe } from "./signing.js";
6
+ export { DecidioHttpError } from "./errors.js";
7
+ export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport } from "./signing.js";
8
+ export { canonicalize, canonicalizeArgs, canonicalJson, DecidioUnsafeArgumentsError } from "./canonical.js";
9
+ export type { JsonValue, JsonPrimitive } from "./canonical.js";
10
+ export { readConfirmResponse, buildConfirmBody, describeTierGap, EVIDENCE_TIERS } from "./evidence.js";
11
+ export type { EvidenceOutcome, EvidenceTier } from "./evidence.js";
7
12
  export type { RequesterIdentity } from "./signing.js";
8
13
  export interface GuardConfig {
9
14
  /** Base URL of the Decidio gate, e.g. "https://api.decidio.io". */
@@ -75,15 +80,13 @@ export declare class DecidioTimeoutError extends Error {
75
80
  decisionId: string;
76
81
  constructor(decisionId: string);
77
82
  }
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. */
83
+ /** A transport failure with GUIDANCE instead of a raw fetch stack. Carries the endpoint, the
84
+ * operation, the underlying code, and the safety state a caller cares about most: whether
85
+ * anything could have executed. */
82
86
  /** 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`. */
87
+ * server says exactly how much and until when — `limit`, `remaining` and `resetAt` relay its
88
+ * answer verbatim so automation never has to parse a message. Nothing was routed, recorded,
89
+ * or executed; retry after `resetAt`. */
87
90
  export declare class DecidioRateLimitError extends Error {
88
91
  limit: number | undefined;
89
92
  remaining: number | undefined;
@@ -104,4 +107,10 @@ export declare class DecidioNetworkError extends Error {
104
107
  /** `<appUrl>/d/<decisionId>` — the deep link that opens this decision directly, on the web app or
105
108
  * the phone build. Null when DECIDIO_APP_URL is not configured, never a half-built URL. */
106
109
  export declare function decisionUrl(appUrl: string | undefined, decisionId: string): string | null;
107
- export declare function withApproval<A extends any[], R>(fn: (...args: A) => Promise<R>, describe: (...args: A) => ApprovalContext, config: GuardConfig): (...args: A) => Promise<R>;
110
+ export declare function withApproval<A extends any[], R>(fn: (...args: A) => Promise<R>, describe: (...args: A) => ApprovalContext, config: GuardConfig, opts?: {
111
+ /** Map YOUR function's natural return value to the evidence Decidio records. The gate awards
112
+ * its strongest wrapper tier (application_confirmed) only for a committed record id plus a
113
+ * success indication — this lets a real API keep its own shape instead of being forced into
114
+ * magic field names: `evidence: (r) => ({ recordId: r.transactionId, success: r.status === "committed" })`. */
115
+ evidence?: (result: R) => unknown;
116
+ }): (...args: A) => Promise<R>;
package/dist/index.js CHANGED
@@ -1,37 +1,14 @@
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";
1
+ import { DecidioSuspendedError } from "./resume.js";
2
+ import { signBoundProof, signConfirm, assertSafeTransport } from "./signing.js";
3
+ import { canonicalize, canonicalizeArgs } from "./canonical.js";
4
+ import { readConfirmResponse, buildConfirmBody } from "./evidence.js";
32
5
  export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
33
6
  export { guard, Guard } from "./guard.js";
34
- export { generateKeypair, signBoundProof, signConfirm, canonicalize, assertJsonSafe } from "./signing.js";
7
+ export { DecidioHttpError } from "./errors.js";
8
+ import { DecidioHttpError } from "./errors.js";
9
+ export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport } from "./signing.js";
10
+ export { canonicalize, canonicalizeArgs, canonicalJson, DecidioUnsafeArgumentsError } from "./canonical.js";
11
+ export { readConfirmResponse, buildConfirmBody, describeTierGap, EVIDENCE_TIERS } from "./evidence.js";
35
12
  export class DecidioBlockedError extends Error {
36
13
  reason;
37
14
  decisionId;
@@ -61,15 +38,6 @@ export class DecidioTimeoutError extends Error {
61
38
  }
62
39
  }
63
40
  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`. */
73
41
  export class DecidioRateLimitError extends Error {
74
42
  limit;
75
43
  remaining;
@@ -88,9 +56,6 @@ export class DecidioNetworkError extends Error {
88
56
  op;
89
57
  code;
90
58
  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.
94
59
  super(`[decidio] could not reach ${endpoint} during ${op}${code ? ` (${code})` : ""}. ` +
95
60
  (/localhost|127\.0\.0\.1/.test(endpoint) && code === "ECONNREFUSED"
96
61
  ? `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). `
@@ -115,6 +80,7 @@ async function reach(f, url, op, init) {
115
80
  }
116
81
  async function post(cfg, path, body) {
117
82
  const f = cfg.fetchImpl ?? fetch;
83
+ assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
118
84
  const res = await reach(f, cfg.apiUrl.replace(/\/$/, "") + path, `POST ${path}`, {
119
85
  method: "POST",
120
86
  headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
@@ -124,47 +90,58 @@ async function post(cfg, path, body) {
124
90
  const b = await res.json().catch(() => null);
125
91
  throw new DecidioRateLimitError(b?.limit, b?.remaining, b?.resetAt, b?.message);
126
92
  }
127
- if (!res.ok)
128
- throw new Error(`decidio ${path} ${res.status}: ${await res.text().catch(() => "")}`);
93
+ if (!res.ok) {
94
+ const raw = await res.text().catch(() => "");
95
+ let parsed = raw;
96
+ try {
97
+ parsed = raw ? JSON.parse(raw) : undefined;
98
+ }
99
+ catch { }
100
+ throw new DecidioHttpError(res.status, path, parsed);
101
+ }
129
102
  return res.json();
130
103
  }
131
104
  async function get(cfg, path) {
132
105
  const f = cfg.fetchImpl ?? fetch;
106
+ assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
133
107
  const res = await reach(f, cfg.apiUrl.replace(/\/$/, "") + path, `GET ${path}`, {
134
108
  headers: { ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
135
109
  });
136
110
  if (!res.ok)
137
- throw new Error(`decidio ${path} ${res.status}`);
111
+ throw new DecidioHttpError(res.status, path);
138
112
  return res.json();
139
113
  }
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. */
147
114
  export function decisionUrl(appUrl, decisionId) {
148
115
  if (!appUrl)
149
116
  return null;
150
117
  return `${appUrl.replace(/\/$/, "")}/d/${encodeURIComponent(decisionId)}`;
151
118
  }
152
- export function withApproval(fn, describe, config) {
119
+ export function withApproval(fn, describe, config, opts) {
153
120
  const pollInterval = config.pollIntervalMs ?? 2_000;
154
121
  const pollTimeout = config.pollTimeoutMs ?? 600_000;
155
122
  const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
156
- return async (...args) => {
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.
123
+ return async (...rawArgs) => {
124
+ const args = canonicalizeArgs(rawArgs);
125
+ let ctx;
126
+ try {
127
+ ctx = describe(...args);
128
+ }
129
+ catch (e) {
130
+ if (e instanceof TypeError && /read only|not extensible|object is not extensible|frozen/i.test(e.message ?? "")) {
131
+ throw new TypeError(`[decidio] your describe() callback tried to MODIFY the call arguments (${e.message}). ` +
132
+ `Those arguments are the approved value: they are what a human sees, what the signature covers, ` +
133
+ `what is parked for a durable resume, and what your function ultimately runs on — so changing them ` +
134
+ `here would mean the action that executes is not the action that was approved. Read from the ` +
135
+ `arguments and RETURN the context instead: (inv) => ({ action: "pay", amount: inv.amount }). ` +
136
+ `If you need a modified value at execution time, compute it inside your function. ` +
137
+ `Nothing was routed or executed.`);
138
+ }
139
+ throw e;
140
+ }
162
141
  const requesterIdentity = (config.agentKey && config.agentDid)
163
142
  ? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
164
143
  : 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(...)");
144
+ canonicalize(ctx, "describe(...)");
168
145
  const auth = await post(config, "/agent/authorize", {
169
146
  requester: config.agentId, action: ctx.action, amount: ctx.amount,
170
147
  scope: ctx.scope, sourceSystem: config.sourceSystem,
@@ -172,52 +149,28 @@ export function withApproval(fn, describe, config) {
172
149
  ...(requesterIdentity ? { requesterIdentity } : {}),
173
150
  ...(resumeUrl ? { resumeUrl } : {}),
174
151
  });
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.
178
152
  if (auth.decision !== "proceed" && auth.decision !== "route" && auth.decision !== "block")
179
153
  throw new DecidioBlockedError(`unrecognized gate verdict: ${String(auth.decision)}`, auth.decisionId);
180
154
  if (auth.decision === "block")
181
155
  throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
182
- // 2. routed → either SUSPEND for async resume (durable) or BLOCK-poll (interactive)
183
156
  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.
189
157
  const approvalUrl = decisionUrl(config.appUrl, auth.decisionId);
190
158
  if (config.onPending)
191
159
  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).
195
160
  else
196
161
  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.
201
162
  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.)
205
163
  config.resume.agentId ??= config.agentId;
206
164
  config.resume.agentKey ??= config.agentKey;
207
165
  config.resume.agentDid ??= config.agentDid;
208
166
  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).
211
167
  await config.resume.park({
212
168
  decisionId: auth.decisionId, receiptId: auth.receiptId,
213
169
  action: ctx.action, amount: ctx.amount, scope: ctx.scope, args,
214
170
  });
215
171
  throw new DecidioSuspendedError(auth.decisionId, ctx.action);
216
172
  }
217
- // Interactive transport (no resume configured): block until decided. Only
218
- // appropriate for short, watched approvals (demos, synchronous tools).
219
173
  const deadline = Date.now() + pollTimeout;
220
- // eslint-disable-next-line no-constant-condition
221
174
  while (true) {
222
175
  const st = await get(config, `/agent/status/${encodeURIComponent(auth.decisionId)}`);
223
176
  if (st.state === "approved" || st.state === "auto_approved")
@@ -227,42 +180,25 @@ export function withApproval(fn, describe, config) {
227
180
  if (st.state === "blocked")
228
181
  throw new DecidioBlockedError(st.reason ?? "blocked", auth.decisionId);
229
182
  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.
233
183
  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.`);
234
184
  throw new DecidioTimeoutError(auth.decisionId);
235
185
  }
236
186
  await sleep(pollInterval);
237
187
  }
238
188
  }
239
- // 3. authorized inline (proceed, or interactive route just approved) → run the
240
- // REAL action and capture the source response (listener)
241
189
  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.
245
190
  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.
249
191
  const confirmIdentity = signConfirm(auth.decisionId, config);
250
- const confirmBody = await post(config, "/agent/confirm", {
251
- decisionId: auth.decisionId,
252
- capturedResponse: result ?? null,
253
- source: "wrapper", // the SDK captured the genuine response, not an agent self-report
254
- ...(confirmIdentity ? { requesterIdentity: confirmIdentity } : {}),
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.
261
- if (!confirmIdentity)
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.`);
192
+ const evidenceOf = (opts?.evidence ?? ((r) => r));
193
+ const reported = canonicalize(evidenceOf(result) ?? null, "evidence(result)");
194
+ const confirmBody = await post(config, "/agent/confirm", buildConfirmBody(config, auth.decisionId, reported, confirmIdentity));
195
+ const ev = readConfirmResponse(confirmBody, { signed: !!confirmIdentity, capturedResponse: reported });
196
+ if (!ev.reportRecorded)
197
+ console.warn(`[decidio] ${auth.decisionId}: ${ev.diagnostic}`);
198
+ else if (!ev.executionConfirmed)
199
+ console.warn(`[decidio] execution evidence for ${auth.decisionId} recorded as ${ev.evidenceTier} — ${ev.diagnostic}`);
263
200
  }
264
201
  catch (e) {
265
- // surface but don't throw — the action already happened
266
202
  console.warn(`[decidio] confirmation post failed (action succeeded): ${e.message}`);
267
203
  }
268
204
  return result;