@decidio/sdk 0.1.10 → 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/dist/index.js CHANGED
@@ -1,8 +1,14 @@
1
- import { DecidioSuspendedError, assertConfirmRecorded } from "./resume.js";
2
- import { signBoundProof, signConfirm, assertJsonSafe, assertSafeTransport, DecidioUnsafeArgumentsError } 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";
3
5
  export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
4
6
  export { guard, Guard } from "./guard.js";
5
- export { generateKeypair, signBoundProof, signConfirm, canonicalize, assertJsonSafe, assertSafeTransport, DecidioUnsafeArgumentsError } 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";
6
12
  export class DecidioBlockedError extends Error {
7
13
  reason;
8
14
  decisionId;
@@ -84,8 +90,15 @@ async function post(cfg, path, body) {
84
90
  const b = await res.json().catch(() => null);
85
91
  throw new DecidioRateLimitError(b?.limit, b?.remaining, b?.resetAt, b?.message);
86
92
  }
87
- if (!res.ok)
88
- 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
+ }
89
102
  return res.json();
90
103
  }
91
104
  async function get(cfg, path) {
@@ -95,7 +108,7 @@ async function get(cfg, path) {
95
108
  headers: { ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
96
109
  });
97
110
  if (!res.ok)
98
- throw new Error(`decidio ${path} ${res.status}`);
111
+ throw new DecidioHttpError(res.status, path);
99
112
  return res.json();
100
113
  }
101
114
  export function decisionUrl(appUrl, decisionId) {
@@ -103,27 +116,32 @@ export function decisionUrl(appUrl, decisionId) {
103
116
  return null;
104
117
  return `${appUrl.replace(/\/$/, "")}/d/${encodeURIComponent(decisionId)}`;
105
118
  }
106
- export function withApproval(fn, describe, config) {
119
+ export function withApproval(fn, describe, config, opts) {
107
120
  const pollInterval = config.pollIntervalMs ?? 2_000;
108
121
  const pollTimeout = config.pollTimeoutMs ?? 600_000;
109
122
  const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
110
- return async (...args) => {
123
+ return async (...rawArgs) => {
124
+ const args = canonicalizeArgs(rawArgs);
125
+ let ctx;
111
126
  try {
112
- assertJsonSafe(args, "args");
127
+ ctx = describe(...args);
113
128
  }
114
129
  catch (e) {
115
- throw new DecidioUnsafeArgumentsError(`${e?.message ?? e} The decision was NOT routed; nothing reached Decidio.`);
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;
116
140
  }
117
- const ctx = describe(...args);
118
141
  const requesterIdentity = (config.agentKey && config.agentDid)
119
142
  ? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
120
143
  : undefined;
121
- try {
122
- assertJsonSafe(ctx, "describe(...)");
123
- }
124
- catch (e) {
125
- throw new DecidioUnsafeArgumentsError(`${e?.message ?? e} The decision was NOT routed; nothing reached Decidio.`);
126
- }
144
+ canonicalize(ctx, "describe(...)");
127
145
  const auth = await post(config, "/agent/authorize", {
128
146
  requester: config.agentId, action: ctx.action, amount: ctx.amount,
129
147
  scope: ctx.scope, sourceSystem: config.sourceSystem,
@@ -171,15 +189,14 @@ export function withApproval(fn, describe, config) {
171
189
  const result = await fn(...args);
172
190
  try {
173
191
  const confirmIdentity = signConfirm(auth.decisionId, config);
174
- const confirmBody = await post(config, "/agent/confirm", {
175
- decisionId: auth.decisionId,
176
- capturedResponse: result ?? null,
177
- source: "wrapper",
178
- ...(confirmIdentity ? { requesterIdentity: confirmIdentity } : {}),
179
- });
180
- assertConfirmRecorded(confirmBody, auth.decisionId, !!confirmIdentity);
181
- if (!confirmIdentity)
182
- 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}`);
183
200
  }
184
201
  catch (e) {
185
202
  console.warn(`[decidio] confirmation post failed (action succeeded): ${e.message}`);
package/dist/inngest.js CHANGED
@@ -1,28 +1,10 @@
1
1
  import { verifyResumeSignature } from "./resume.js";
2
- import { signBoundProof, signConfirm } from "./signing.js";
2
+ import { gateAuthorize, gateConfirm, assertAuthorizeVerdict, verdictFrom, checkEngineVersion } from "./adapters/_gate.js";
3
3
  export const DECIDIO_RESUME_EVENT = "decidio/decision.resolved";
4
4
  export async function decidioGateStep(args) {
5
5
  const { step, config, ctx } = args;
6
- const base = config.apiUrl.replace(/\/$/, "");
7
- const f = config.fetchImpl ?? fetch;
8
- const authHeaders = { "content-type": "application/json", ...(config.apiToken ? { authorization: `Bearer ${config.apiToken}` } : {}) };
9
- const auth = await step.run("decidio-authorize", async () => {
10
- const requesterIdentity = (config.agentKey && config.agentDid)
11
- ? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
12
- : undefined;
13
- const res = await f(base + "/agent/authorize", {
14
- method: "POST", headers: authHeaders,
15
- body: JSON.stringify({
16
- requester: config.agentId, action: ctx.action, amount: ctx.amount, scope: ctx.scope,
17
- sourceSystem: config.sourceSystem,
18
- ...(requesterIdentity ? { requesterIdentity } : {}),
19
- ...(config.resumeUrl ? { resumeUrl: config.resumeUrl } : {}),
20
- }),
21
- });
22
- if (!res.ok)
23
- throw new Error(`decidio authorize ${res.status}`);
24
- return res.json();
25
- });
6
+ const auth = await step.run("decidio-authorize", async () => gateAuthorize(config, ctx));
7
+ assertAuthorizeVerdict(auth, auth?.decisionId);
26
8
  if (auth.decision === "block")
27
9
  return { status: "blocked", decisionId: auth.decisionId };
28
10
  if (auth.decision === "route") {
@@ -33,21 +15,14 @@ export async function decidioGateStep(args) {
33
15
  });
34
16
  if (!ev)
35
17
  throw new Error(`decidio approval timed out (${auth.decisionId})`);
36
- const verdict = ev.data?.verdict;
18
+ const verdict = verdictFrom(ev.data);
37
19
  if (verdict === "rejected")
38
20
  return { status: "rejected", decisionId: auth.decisionId };
39
21
  if (verdict !== "approved" && verdict !== "auto_approved")
40
22
  return { status: "blocked", decisionId: auth.decisionId };
41
23
  }
42
24
  const result = await step.run("agent-action", args.run);
43
- await step.run("decidio-confirm", async () => {
44
- const requesterIdentity = signConfirm(auth.decisionId, config);
45
- await f(base + "/agent/confirm", {
46
- method: "POST", headers: authHeaders,
47
- body: JSON.stringify({ decisionId: auth.decisionId, capturedResponse: result ?? null, source: "wrapper", ...(requesterIdentity ? { requesterIdentity } : {}) }),
48
- });
49
- return true;
50
- });
25
+ await step.run("decidio-confirm", async () => { await gateConfirm(config, auth.decisionId, result); return true; });
51
26
  return { status: "executed", decisionId: auth.decisionId, result };
52
27
  }
53
28
  export function createInngestResumeBridge(opts) {
@@ -86,3 +61,4 @@ export function createInngestResumeBridge(opts) {
86
61
  };
87
62
  }
88
63
  export { verifyResumeSignature };
64
+ checkEngineVersion("inngest");
package/dist/resume.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { RequestListener } from "node:http";
2
+ import { type EvidenceOutcome, type EvidenceTier } from "./evidence.js";
2
3
  /** A routed action parked on the agent side, awaiting human approval. The `args`
3
4
  * are the original call arguments, replayed verbatim when the action resumes. */
4
5
  export interface PendingAction {
@@ -41,6 +42,9 @@ export interface PendingStore {
41
42
  claim?(decisionId: string): Promise<boolean>;
42
43
  /** Undo a claim so a failed execution is retryable. */
43
44
  release?(decisionId: string): Promise<void>;
45
+ /** Entries currently CLAIMED (excluded from list()). Needed for honest recovery reporting —
46
+ * a claimed entry with no journalled result is the indeterminate case. */
47
+ listClaimed?(): Promise<PendingAction[]>;
44
48
  }
45
49
  export declare class MemoryPendingStore implements PendingStore {
46
50
  private m;
@@ -51,7 +55,13 @@ export declare class MemoryPendingStore implements PendingStore {
51
55
  list(): Promise<PendingAction[]>;
52
56
  claim(id: string): Promise<boolean>;
53
57
  release(id: string): Promise<void>;
58
+ listClaimed(): Promise<PendingAction[]>;
54
59
  }
60
+ /** Restrict a directory to its owner. On Windows POSIX modes are a no-op, so strip inheritance
61
+ * and grant only the effective user. A FAILURE IS SAID, never swallowed: these files hold the
62
+ * arguments of actions awaiting approval, and "silently kept the directory's inherited
63
+ * permissions" is exactly the outcome an operator must not discover from an auditor. */
64
+ export declare function hardenDir(dir: string): void;
55
65
  /** File-backed store so a COLD process (the original requester long gone) can still
56
66
  * resume — the proof that nothing is "kept alive". One JSON file per decision. A claim
57
67
  * renames `<id>.json` → `<id>.claimed` atomically (same-volume rename), so two processes
@@ -69,6 +79,9 @@ export declare class FilePendingStore implements PendingStore {
69
79
  list(): Promise<PendingAction[]>;
70
80
  claim(id: string): Promise<boolean>;
71
81
  release(id: string): Promise<void>;
82
+ /** Claimed entries (`.claimed` files) — invisible to list() by design, but a stranded one is
83
+ * exactly what recovery must surface. */
84
+ listClaimed(): Promise<PendingAction[]>;
72
85
  }
73
86
  /** Thrown by a `withApproval`-wrapped call when the action is routed and configured
74
87
  * for async resume: the action is now SUSPENDED (parked in the store), not failed.
@@ -80,18 +93,57 @@ export declare class DecidioSuspendedError extends Error {
80
93
  constructor(decisionId: string, action: string);
81
94
  }
82
95
  export type ResumeStatus = "executed" | "rejected" | "blocked" | "no_handler" | "unknown";
96
+ /** A durable note that an action ALREADY RAN and what must still be reported. It lives OUTSIDE
97
+ * the pending store on purpose: a journal entry carries no handler binding, so replaying it can
98
+ * only ever re-send a report — it can never re-run the business action. That is what makes
99
+ * recovery safe on stores with and without claim(). */
100
+ export interface ExecutionJournalEntry {
101
+ decisionId: string;
102
+ action: string;
103
+ /** The canonical value that was reported — the durable FACT, journalled before the first
104
+ * attempt. Deliberately NOT the signed request: both proofs on a confirm are short-lived (the
105
+ * identity proof lives 120s, the execution report 5 minutes), so a replayed envelope is
106
+ * refused by the server for any outage longer than that — which is every outage worth
107
+ * recovering from. `reconfirm` re-signs at SEND time over these same bytes, so the report
108
+ * stays replay-safe (it still cannot re-run anything: no handler is journalled) while staying
109
+ * usable a week later. */
110
+ reported: unknown;
111
+ at: string;
112
+ }
113
+ /** The states a durable action can actually be in — every one of them nameable, including the
114
+ * one nobody likes. */
115
+ export type RecoveryState =
116
+ /** Parked, waiting on a human. Nothing has executed. */
117
+ "parked"
118
+ /** Claimed for execution and NOT journalled: the process died somewhere between claiming and
119
+ * recording the result. The action MAY OR MAY NOT have run — this cannot be resolved locally
120
+ * and must never be retried automatically. */
121
+ | "indeterminate"
122
+ /** Executed, result captured, report not yet accepted by Decidio. Safe to `reconfirm`. */
123
+ | "executed_unreported";
124
+ export interface RecoveryItem {
125
+ decisionId: string;
126
+ action?: string;
127
+ state: RecoveryState;
128
+ /** What a human should do about it, in words. */
129
+ guidance: string;
130
+ }
83
131
  export interface ResumeOutcome {
84
132
  status: ResumeStatus;
85
133
  decisionId: string;
86
134
  action?: string;
87
135
  result?: unknown;
88
136
  reason?: string;
89
- /** executed only: did the execution-evidence report reach Decidio AND come back with a recorded
90
- * tier? `status: "executed"` describes the LOCAL run; this describes the record — a lost
91
- * confirmation must never read as recorded evidence. */
92
- confirmed?: boolean;
93
- /** executed only: the evidence tier the server says it recorded (e.g. application_confirmed). */
94
- evidenceTier?: string;
137
+ /** executed only: Decidio ACCEPTED the execution report and named the tier it recorded.
138
+ * `status: "executed"` describes the LOCAL run; this describes the record. */
139
+ reportRecorded?: boolean;
140
+ /** executed only: the recorded tier attests a COMMITTED execution. Deliberately a different
141
+ * question — and a different name — from `reportRecorded`. */
142
+ executionConfirmed?: boolean;
143
+ /** executed only: the evidence tier Decidio recorded, verbatim. */
144
+ evidenceTier?: EvidenceTier;
145
+ /** executed only: why the evidence landed where it did, when that is worth explaining. */
146
+ diagnostic?: string;
95
147
  }
96
148
  /** Minimal config the controller needs to confirm outcomes + poll status. Shared
97
149
  * with GuardConfig (apiUrl/apiToken). */
@@ -110,6 +162,12 @@ export interface ResumeConfig {
110
162
  /** Shared secret Decidio signs the resume webhook with (HMAC-SHA256). Defaults to
111
163
  * the API token if unset. Verified before any re-execution. */
112
164
  webhookSecret?: string;
165
+ /** Where the execution journal lives (default `<pending dir>/reports`). Separate from the
166
+ * pending store by design: journal entries can only re-send a report, never re-run an action. */
167
+ reportDir?: string;
168
+ /** Map the action's natural return value to the evidence Decidio records — same option, same
169
+ * meaning as `protect`'s, so a durable resume reports exactly what the inline path would. */
170
+ evidence?: (result: any) => unknown;
113
171
  /** Escape hatch to accept UNSIGNED resume webhooks (no secret). Off by default:
114
172
  * with no secret and this unset, the webhook handler rejects every call (fail
115
173
  * closed) so a misconfigured agent can't be driven by forged callbacks. Setting it
@@ -158,6 +216,16 @@ export interface ResumeController extends ResumeConfig {
158
216
  }): {
159
217
  stop: () => void;
160
218
  };
219
+ /** Re-send a journalled execution report, IDEMPOTENTLY. Replays the exact request built before
220
+ * the first attempt — it can never re-run the business action, because the journal holds no
221
+ * handler binding. Returns the evidence outcome; a decision with nothing journalled returns
222
+ * `reportRecorded: false` with guidance rather than pretending. */
223
+ reconfirm(decisionId: string): Promise<EvidenceOutcome>;
224
+ /** Every durable item that needs a human's attention, with its state named — including the one
225
+ * state that CANNOT be resolved automatically: `indeterminate`, where a process died between
226
+ * claiming an action and journalling its result, so whether the action ran is unknown locally.
227
+ * This never executes or retries anything; it reports. */
228
+ recover(): Promise<RecoveryItem[]>;
161
229
  }
162
230
  declare function sign(secret: string, body: string): string;
163
231
  /** Verify Decidio's `x-decidio-signature` over the raw webhook body (HMAC-SHA256).
@@ -168,16 +236,6 @@ export declare function verifyResumeSignature(secret: string, body: string, head
168
236
  * tier is a FAILED confirm; a SIGNED confirm coming back below application_confirmed is
169
237
  * disclosed, never silent. Returns the recorded tier or throws. */
170
238
  export declare function assertConfirmRecorded(body: any, decisionId: string, signed: boolean): string;
171
- /**
172
- * Build the resume controller. Spread it into your GuardConfig as `resume`, then
173
- * either mount `webhookHandler()` (production) or run `worker()` (CLI/laptop).
174
- *
175
- * const resume = createDecidioResume({ apiUrl, apiToken, store: new FilePendingStore(".decidio-pending"), resumeUrl });
176
- * const guard = { apiUrl, agentId, apiToken, resume };
177
- * const createOpportunity = withApproval(createOpportunityRaw, describe, guard);
178
- * // production: http.createServer(resume.webhookHandler()).listen(4100)
179
- * // or CLI: resume.worker()
180
- */
181
239
  export declare function createDecidioResume(opts: ResumeConfig & {
182
240
  store: PendingStore;
183
241
  }): ResumeController;
package/dist/resume.js CHANGED
@@ -3,8 +3,11 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync
3
3
  import { join } from "node:path";
4
4
  import { userInfo } from "node:os";
5
5
  import { execFileSync } from "node:child_process";
6
- import { signConfirm, canonicalize, assertJsonSafe, assertSafeTransport, DecidioUnsafeArgumentsError } from "./signing.js";
7
- const argsBindingBytes = (p) => Buffer.from(canonicalize({ decisionId: p.decisionId, action: p.action, amount: p.amount, ...(p.scope !== undefined ? { scope: p.scope } : {}), args: p.args }), "utf8");
6
+ import { signConfirm, canonicalize as jcs, assertSafeTransport } from "./signing.js";
7
+ import { canonicalize as toCanonical, DecidioUnsafeArgumentsError } from "./canonical.js";
8
+ import { readConfirmResponse, buildConfirmBody } from "./evidence.js";
9
+ import { DecidioHttpError } from "./errors.js";
10
+ const argsBindingBytes = (p) => Buffer.from(jcs({ decisionId: p.decisionId, action: p.action, amount: p.amount, ...(p.scope !== undefined ? { scope: p.scope } : {}), args: p.args }), "utf8");
8
11
  function signArgs(p, agentKey, webhookSecret) {
9
12
  const bytes = argsBindingBytes(p);
10
13
  if (agentKey) {
@@ -49,24 +52,32 @@ export class MemoryPendingStore {
49
52
  async claim(id) { if (this.claimed.has(id) || !this.m.has(id))
50
53
  return false; this.claimed.add(id); return true; }
51
54
  async release(id) { this.claimed.delete(id); }
55
+ async listClaimed() { return [...this.claimed].map((id) => this.m.get(id)).filter(Boolean); }
56
+ }
57
+ export function hardenDir(dir) {
58
+ if (process.platform !== "win32")
59
+ return;
60
+ try {
61
+ const user = (() => { try {
62
+ return userInfo().username;
63
+ }
64
+ catch {
65
+ return process.env.USERNAME ?? process.env.USER ?? "";
66
+ } })();
67
+ if (!user)
68
+ throw new Error("could not determine the current user");
69
+ execFileSync("icacls", [dir, "/inheritance:r", "/grant:r", `${user}:(OI)(CI)F`], { stdio: "ignore" });
70
+ }
71
+ catch (e) {
72
+ console.warn(`[decidio] WARNING: could not restrict ${dir} to owner-only (${e?.message ?? e}). It keeps the directory's inherited permissions, so other accounts on this machine may be able to read parked action arguments. Move the pending store to a protected location, or set DECIDIO_PENDING_DIR to one.`);
73
+ }
52
74
  }
53
75
  export class FilePendingStore {
54
76
  dir;
55
77
  constructor(dir) {
56
78
  this.dir = dir;
57
79
  mkdirSync(dir, { recursive: true });
58
- if (process.platform === "win32") {
59
- try {
60
- const user = (() => { try {
61
- return userInfo().username;
62
- }
63
- catch {
64
- return process.env.USERNAME ?? process.env.USER ?? "";
65
- } })();
66
- execFileSync("icacls", [dir, "/inheritance:r", "/grant:r", `${user}:(OI)(CI)F`], { stdio: "ignore" });
67
- }
68
- catch { }
69
- }
80
+ hardenDir(dir);
70
81
  }
71
82
  key(id) { return id.replace(/[^\w.-]/g, "_"); }
72
83
  path(id) { return join(this.dir, `${this.key(id)}.json`); }
@@ -110,6 +121,20 @@ export class FilePendingStore {
110
121
  }
111
122
  catch { }
112
123
  }
124
+ async listClaimed() {
125
+ if (!existsSync(this.dir))
126
+ return [];
127
+ const out = [];
128
+ for (const name of readdirSync(this.dir)) {
129
+ if (!name.endsWith(".claimed"))
130
+ continue;
131
+ try {
132
+ out.push(JSON.parse(readFileSync(join(this.dir, name), "utf8")));
133
+ }
134
+ catch { }
135
+ }
136
+ return out;
137
+ }
113
138
  }
114
139
  export class DecidioSuspendedError extends Error {
115
140
  decisionId;
@@ -141,21 +166,31 @@ export function assertConfirmRecorded(body, decisionId, signed) {
141
166
  }
142
167
  return body.confirmation;
143
168
  }
144
- async function confirm(cfg, decisionId, capturedResponse) {
145
- const f = cfg.fetchImpl ?? fetch;
146
- assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
169
+ function buildResumeConfirmRequest(cfg, decisionId, canonicalResult) {
147
170
  const requesterIdentity = signConfirm(decisionId, cfg);
148
171
  if (!requesterIdentity)
149
- 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.`);
172
+ console.warn(`[decidio] execution evidence for ${decisionId} will be recorded as an unproven agent assertion — no signing key configured. Set DECIDIO_AGENT_KEY/DID to earn a confirming tier.`);
173
+ return buildConfirmBody({ agentId: cfg.agentId ?? "agent", agentKey: cfg.agentKey, agentDid: cfg.agentDid, workspaceId: cfg.workspaceId }, decisionId, canonicalResult, requesterIdentity);
174
+ }
175
+ async function postConfirm(cfg, decisionId, body) {
176
+ const f = cfg.fetchImpl ?? fetch;
177
+ assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
150
178
  const r = await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
151
179
  method: "POST",
152
180
  headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
153
- body: JSON.stringify({ decisionId, capturedResponse: capturedResponse ?? null, source: "wrapper", ...(requesterIdentity ? { requesterIdentity } : {}) }),
181
+ body: JSON.stringify(body),
154
182
  });
155
- if (!r.ok)
156
- throw new Error(`confirm ${r.status}`);
157
- const body = await r.json().catch(() => null);
158
- return assertConfirmRecorded(body, decisionId, !!requesterIdentity);
183
+ if (!r.ok) {
184
+ const raw = await r.text().catch(() => "");
185
+ let parsed = raw;
186
+ try {
187
+ parsed = raw ? JSON.parse(raw) : undefined;
188
+ }
189
+ catch { }
190
+ throw new DecidioHttpError(r.status, "/agent/confirm", parsed);
191
+ }
192
+ const parsed = await r.json().catch(() => null);
193
+ return readConfirmResponse(parsed, { signed: !!body.requesterIdentity, capturedResponse: body.capturedResponse, journalled: true });
159
194
  }
160
195
  async function statusOf(cfg, decisionId) {
161
196
  const f = cfg.fetchImpl ?? fetch;
@@ -164,15 +199,106 @@ async function statusOf(cfg, decisionId) {
164
199
  headers: { ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
165
200
  });
166
201
  if (!r.ok)
167
- throw new Error(`status ${r.status}`);
202
+ throw new DecidioHttpError(r.status, `/agent/status/${decisionId}`);
168
203
  return r.json();
169
204
  }
205
+ class ReportJournal {
206
+ dir;
207
+ constructor(dir) {
208
+ this.dir = dir;
209
+ try {
210
+ mkdirSync(dir, { recursive: true });
211
+ hardenDir(dir);
212
+ }
213
+ catch { }
214
+ }
215
+ p(id) { return join(this.dir, `${id.replace(/[^\w.-]/g, "_")}.json`); }
216
+ write(e) { try {
217
+ writeFileSync(this.p(e.decisionId), JSON.stringify(e, null, 2), { mode: 0o600 });
218
+ }
219
+ catch { } }
220
+ read(id) { try {
221
+ return existsSync(this.p(id)) ? JSON.parse(readFileSync(this.p(id), "utf8")) : null;
222
+ }
223
+ catch {
224
+ return null;
225
+ } }
226
+ clear(id) { try {
227
+ if (existsSync(this.p(id)))
228
+ rmSync(this.p(id));
229
+ }
230
+ catch { } }
231
+ list() {
232
+ try {
233
+ if (!existsSync(this.dir))
234
+ return [];
235
+ return readdirSync(this.dir).filter((f) => f.endsWith(".json")).map((f) => { try {
236
+ return JSON.parse(readFileSync(join(this.dir, f), "utf8"));
237
+ }
238
+ catch {
239
+ return null;
240
+ } }).filter(Boolean);
241
+ }
242
+ catch {
243
+ return [];
244
+ }
245
+ }
246
+ }
170
247
  export function createDecidioResume(opts) {
171
248
  const registry = new Map();
172
249
  const inFlight = new Set();
173
250
  const secret = opts.webhookSecret ?? opts.apiToken ?? "";
174
251
  if (opts.allowUnsigned)
175
252
  console.warn("[decidio] WARNING: allowUnsigned is ON — this controller will accept UNSIGNED resume webhooks. Anyone who can reach the endpoint can signal work (parked-payload integrity still gates execution). Use this only behind a trusted network boundary, never on an internet-reachable endpoint.");
253
+ const journal = new ReportJournal(opts.reportDir ?? join(process.env.DECIDIO_PENDING_DIR ?? ".decidio-pending", "reports"));
254
+ async function reportAfterExecution(pending, decisionId, result) {
255
+ const base = { status: "executed", decisionId, action: pending.action, result };
256
+ let reported;
257
+ let haveReported = false;
258
+ let buildProblem;
259
+ try {
260
+ reported = toCanonical((ctrl.evidence ? ctrl.evidence(result) : result) ?? null, "evidence(result)");
261
+ haveReported = true;
262
+ }
263
+ catch (e) {
264
+ buildProblem = `${e?.message ?? e}`;
265
+ reported = null;
266
+ haveReported = true;
267
+ }
268
+ if (haveReported)
269
+ journal.write({ decisionId, action: pending.action, reported, at: new Date().toISOString() });
270
+ let confirmRequest;
271
+ try {
272
+ confirmRequest = buildResumeConfirmRequest(ctrl, decisionId, reported);
273
+ }
274
+ catch {
275
+ confirmRequest = undefined;
276
+ }
277
+ if (!confirmRequest) {
278
+ await ctrl.store.delete(decisionId).catch(() => { });
279
+ const diagnostic = `"${pending.action}" EXECUTED, but no execution report could be built (${buildProblem ?? "unknown"}). Nothing will re-run it; record the outcome from your own system.`;
280
+ console.warn(`[decidio] ${decisionId}: ${diagnostic}`);
281
+ return { ...base, reportRecorded: false, executionConfirmed: false, diagnostic };
282
+ }
283
+ await ctrl.store.delete(decisionId).catch(() => { });
284
+ let ev;
285
+ try {
286
+ ev = await postConfirm(ctrl, decisionId, confirmRequest);
287
+ }
288
+ catch (e) {
289
+ ev = { reportRecorded: false, executionConfirmed: false, diagnostic: `the execution report did not reach Decidio (${e?.message ?? e}). Your action DID run and the report is journalled - re-send it with reconfirm("${decisionId}").` };
290
+ }
291
+ if (ev.reportRecorded)
292
+ journal.clear(decisionId);
293
+ const diagnostic = buildProblem
294
+ ? `${ev.diagnostic ? ev.diagnostic + " " : ""}Also: the action's return value was not reportable (${buildProblem}), so an empty response was reported - map it with the \`evidence\` option to earn a confirming tier.`
295
+ : ev.diagnostic;
296
+ if (!ev.reportRecorded)
297
+ console.warn(`[decidio] ${decisionId}: ${diagnostic}`);
298
+ else if (!ev.executionConfirmed)
299
+ console.warn(`[decidio] execution evidence for ${decisionId} recorded as ${ev.evidenceTier} - ${diagnostic}`);
300
+ return { ...base, reportRecorded: ev.reportRecorded, executionConfirmed: ev.executionConfirmed, ...(ev.evidenceTier ? { evidenceTier: ev.evidenceTier } : {}), ...(diagnostic ? { diagnostic } : {}) };
301
+ }
176
302
  const refusalWarned = new Set();
177
303
  const refuseOnce = (decisionId, msg) => {
178
304
  if (refusalWarned.has(decisionId))
@@ -196,7 +322,7 @@ export function createDecidioResume(opts) {
196
322
  },
197
323
  async park(p) {
198
324
  try {
199
- assertJsonSafe(p.args, "args");
325
+ toCanonical(p.args, "args");
200
326
  }
201
327
  catch (e) {
202
328
  throw new DecidioUnsafeArgumentsError(`${e?.message ?? e} NOT parked and NOT executable — but decision ${p.decisionId} may already be routed; reject it in your queue: npx @decidio/sdk approvals reject ${p.decisionId}`, p.decisionId);
@@ -257,25 +383,7 @@ export function createDecidioResume(opts) {
257
383
  return { status: "unknown", decisionId };
258
384
  try {
259
385
  const result = await fn(...pending.args);
260
- let confirmed = true;
261
- let tier;
262
- try {
263
- tier = await confirm(ctrl, decisionId, result);
264
- }
265
- catch (e) {
266
- confirmed = false;
267
- console.warn(`[decidio] resume confirm failed (your action DID succeed): ${e.message}`);
268
- }
269
- const retainedIsSafe = typeof ctrl.store.claim === "function";
270
- if (confirmed || !retainedIsSafe)
271
- await ctrl.store.delete(decisionId);
272
- if (!confirmed) {
273
- if (retainedIsSafe)
274
- console.warn(` The parked entry for ${decisionId} is kept as local evidence that it executed — it stays CLAIMED, so it cannot re-run. Retry the confirmation, then delete it.`);
275
- else
276
- console.warn(` This store provides no claim() primitive, so the entry was DELETED to guarantee the action cannot execute twice. ${decisionId} executed, but its confirmation never reached Decidio — re-confirm it from your own records.`);
277
- }
278
- return finish({ status: "executed", decisionId, action: pending.action, result, confirmed, ...(tier ? { evidenceTier: tier } : {}) });
386
+ return finish(await reportAfterExecution(pending, decisionId, result));
279
387
  }
280
388
  catch (e) {
281
389
  if (ctrl.store.release)
@@ -342,6 +450,48 @@ export function createDecidioResume(opts) {
342
450
  }
343
451
  };
344
452
  },
453
+ async reconfirm(decisionId) {
454
+ const entry = journal.read(decisionId);
455
+ if (!entry) {
456
+ return {
457
+ reportRecorded: false, executionConfirmed: false,
458
+ diagnostic: `nothing is journalled for ${decisionId}: either its report was already recorded (nothing to do) or this process never executed it. Check the record in Decidio — do NOT re-run the action to find out.`,
459
+ };
460
+ }
461
+ let request;
462
+ try {
463
+ request = buildResumeConfirmRequest(ctrl, decisionId, entry.reported);
464
+ }
465
+ catch (e) {
466
+ return { reportRecorded: false, executionConfirmed: false, diagnostic: `the journalled report for ${decisionId} could not be re-signed (${e?.message ?? e}). The entry is kept; fix the agent's signing config (DECIDIO_AGENT_KEY/DID) and retry.` };
467
+ }
468
+ const ev = await postConfirm(ctrl, decisionId, request);
469
+ if (ev.reportRecorded)
470
+ journal.clear(decisionId);
471
+ return ev;
472
+ },
473
+ async recover() {
474
+ const items = [];
475
+ for (const e of journal.list()) {
476
+ items.push({
477
+ decisionId: e.decisionId, action: e.action, state: "executed_unreported",
478
+ guidance: `"${e.action}" executed at ${e.at} and its execution report has not been recorded. Re-send it safely: guard.resume.reconfirm("${e.decisionId}") — this replays the saved report and cannot re-run the action.`,
479
+ });
480
+ }
481
+ const claimed = ctrl.store.listClaimed ? await ctrl.store.listClaimed().catch(() => []) : [];
482
+ for (const p of claimed) {
483
+ if (journal.read(p.decisionId))
484
+ continue;
485
+ items.push({
486
+ decisionId: p.decisionId, action: p.action, state: "indeterminate",
487
+ guidance: `"${p.action}" was CLAIMED for execution but no result was ever recorded: a process died mid-flight, so it is unknown locally whether the action ran. Reconcile in the downstream system (or with an idempotency key) before deciding. The SDK will NOT retry it automatically, and neither should a script.`,
488
+ });
489
+ }
490
+ for (const p of await ctrl.store.list().catch(() => [])) {
491
+ items.push({ decisionId: p.decisionId, action: p.action, state: "parked", guidance: `"${p.action}" is parked awaiting a human decision. Nothing has executed.` });
492
+ }
493
+ return items;
494
+ },
345
495
  worker(workerOpts) {
346
496
  const interval = workerOpts?.intervalMs ?? 2_500;
347
497
  const deadline = workerOpts?.timeoutMs ? Date.now() + workerOpts.timeoutMs : Infinity;
@@ -364,10 +514,10 @@ export function createDecidioResume(opts) {
364
514
  continue;
365
515
  try {
366
516
  const outcome = await ctrl.resolve({ decisionId: p.decisionId, verdict, reason: st.reason });
367
- if (outcome.status === "executed" && outcome.confirmed)
368
- console.error(`[decidio] resumed ${p.decisionId}: approved → "${p.action}" executed, evidence recorded as ${outcome.evidenceTier ?? "confirmed"}.`);
517
+ if (outcome.status === "executed" && outcome.reportRecorded)
518
+ console.error(`[decidio] resumed ${p.decisionId}: approved → "${p.action}" executed; evidence recorded as ${outcome.evidenceTier}${outcome.executionConfirmed ? "" : " (execution NOT confirmed — see the warning above)"}.`);
369
519
  else if (outcome.status === "executed")
370
- console.error(`[decidio] resumed ${p.decisionId}: approved → "${p.action}" executed, but its execution evidence did NOT reach Decidio — see the warning above.`);
520
+ console.error(`[decidio] resumed ${p.decisionId}: approved → "${p.action}" executed, but its execution report did NOT reach Decidio — recover with: guard.resume.reconfirm("${p.decisionId}")`);
371
521
  else if (outcome.status === "rejected")
372
522
  console.error(`[decidio] resumed ${p.decisionId}: REJECTED — the parked "${p.action}" was discarded, nothing executed.`);
373
523
  else if (outcome.status === "blocked")