@decidio/sdk 0.1.10 → 0.1.12

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.js CHANGED
@@ -1,12 +1,15 @@
1
1
  import { withApproval } from "./index.js";
2
2
  import { createDecidioResume, FilePendingStore } from "./resume.js";
3
+ import { join } from "node:path";
3
4
  export class Guard {
4
5
  config;
5
- resume;
6
+ _resume;
6
7
  constructor(config, resume) {
7
8
  this.config = config;
8
- this.resume = resume;
9
+ this._resume = resume;
9
10
  }
11
+ get resume() { return this.ensureResume(); }
12
+ set resume(r) { this._resume = r; }
10
13
  static fromEnv() {
11
14
  return new Guard({
12
15
  apiUrl: process.env.DECIDIO_API_URL ?? "",
@@ -22,7 +25,7 @@ export class Guard {
22
25
  appUrl: process.env.DECIDIO_APP_URL,
23
26
  });
24
27
  }
25
- register(action, fn) { this.ensureResume().register(action, fn); }
28
+ register(action, fn, evidence) { this.ensureResume().register(action, fn, evidence); }
26
29
  resumeHandler() { return this.ensureResume().webhookHandler(); }
27
30
  resumeFetchHandler() { return this.ensureResume().fetchHandler(); }
28
31
  worker(opts) {
@@ -30,16 +33,17 @@ export class Guard {
30
33
  }
31
34
  ensureResume() {
32
35
  this.assertConfigured();
33
- if (!this.resume) {
36
+ if (!this._resume) {
34
37
  const c = this.config;
35
- this.resume = createDecidioResume({
38
+ this._resume = createDecidioResume({
36
39
  apiUrl: c.apiUrl, apiToken: c.apiToken, webhookSecret: c.webhookSecret,
37
40
  resumeUrl: c.resumeUrl, store: new FilePendingStore(c.pendingDir ?? ".decidio-pending"),
41
+ reportDir: join(c.pendingDir ?? ".decidio-pending", "reports"),
38
42
  agentId: c.agentId, agentKey: c.agentKey, agentDid: c.agentDid, workspaceId: c.workspaceId,
39
43
  fetchImpl: c.fetchImpl,
40
44
  });
41
45
  }
42
- return this.resume;
46
+ return this._resume;
43
47
  }
44
48
  assertConfigured() {
45
49
  if (!this.config.apiUrl)
@@ -61,14 +65,17 @@ export class Guard {
61
65
  pollTimeoutMs: opts?.pollTimeoutMs, pollIntervalMs: opts?.pollIntervalMs,
62
66
  };
63
67
  if ((opts?.mode ?? "durable") === "blocking") {
64
- console.warn("[decidio] WARNING: blocking wait — only safe for short, watched approvals. " +
65
- "Omit mode:\"blocking\" for durable async resume on real (minutes-to-days) approvals.");
66
- return withApproval(fn, describe, base);
68
+ const ceilingMs = opts?.pollTimeoutMs ?? base.pollTimeoutMs ?? 600_000;
69
+ console.warn(`[decidio] WARNING: blocking wait — this call gives up after ${Math.round(ceilingMs / 1000)}s ` +
70
+ `(pollTimeoutMs), and when it does, the decision stays OPEN in Decidio while this process stops waiting: ` +
71
+ `a later approval cannot resume it. Only safe for short, watched approvals — omit mode:"blocking" for ` +
72
+ `durable async resume on real (minutes-to-days) approvals.`);
73
+ return withApproval(fn, describe, base, opts?.evidence ? { evidence: opts.evidence } : undefined);
67
74
  }
68
75
  const resume = this.ensureResume();
69
76
  if (opts?.action)
70
- resume.register(opts.action, fn);
71
- return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume });
77
+ resume.register(opts.action, fn, opts?.evidence);
78
+ return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume }, opts?.evidence ? { evidence: opts.evidence } : undefined);
72
79
  }
73
80
  }
74
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, assertSafeTransport, DecidioUnsafeArgumentsError } from "./signing.js";
6
+ export { DecidioHttpError } from "./errors.js";
7
+ export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport, carriesCredential } 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". */
@@ -102,4 +107,10 @@ export declare class DecidioNetworkError extends Error {
102
107
  /** `<appUrl>/d/<decisionId>` — the deep link that opens this decision directly, on the web app or
103
108
  * the phone build. Null when DECIDIO_APP_URL is not configured, never a half-built URL. */
104
109
  export declare function decisionUrl(appUrl: string | undefined, decisionId: string): string | null;
105
- 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,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, carriesCredential } 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, carriesCredential } 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;
@@ -74,7 +80,7 @@ async function reach(f, url, op, init) {
74
80
  }
75
81
  async function post(cfg, path, body) {
76
82
  const f = cfg.fetchImpl ?? fetch;
77
- assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
83
+ assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
78
84
  const res = await reach(f, cfg.apiUrl.replace(/\/$/, "") + path, `POST ${path}`, {
79
85
  method: "POST",
80
86
  headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
@@ -84,18 +90,25 @@ 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) {
92
105
  const f = cfg.fetchImpl ?? fetch;
93
- assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
106
+ assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
94
107
  const res = await reach(f, cfg.apiUrl.replace(/\/$/, "") + path, `GET ${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,
@@ -133,6 +151,8 @@ export function withApproval(fn, describe, config) {
133
151
  });
134
152
  if (auth.decision !== "proceed" && auth.decision !== "route" && auth.decision !== "block")
135
153
  throw new DecidioBlockedError(`unrecognized gate verdict: ${String(auth.decision)}`, auth.decisionId);
154
+ if (typeof auth.decisionId !== "string" || !auth.decisionId)
155
+ throw new DecidioBlockedError(`the gate answered "${auth.decision}" with no usable decisionId (${JSON.stringify(auth.decisionId ?? null)}) — refusing to execute. Every authorized action must be addressable: without an id it cannot be polled, resumed, confirmed or audited.`, auth.decisionId);
136
156
  if (auth.decision === "block")
137
157
  throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
138
158
  if (auth.decision === "route") {
@@ -145,7 +165,7 @@ export function withApproval(fn, describe, config) {
145
165
  config.resume.agentId ??= config.agentId;
146
166
  config.resume.agentKey ??= config.agentKey;
147
167
  config.resume.agentDid ??= config.agentDid;
148
- config.resume.register(ctx.action, fn);
168
+ config.resume.register(ctx.action, fn, opts?.evidence);
149
169
  await config.resume.park({
150
170
  decisionId: auth.decisionId, receiptId: auth.receiptId,
151
171
  action: ctx.action, amount: ctx.amount, scope: ctx.scope, args,
@@ -169,17 +189,26 @@ export function withApproval(fn, describe, config) {
169
189
  }
170
190
  }
171
191
  const result = await fn(...args);
192
+ let reported = null;
193
+ let evidenceProblem;
194
+ try {
195
+ const evidenceOf = (opts?.evidence ?? ((r) => r));
196
+ reported = canonicalize(evidenceOf(result) ?? null, "evidence(result)");
197
+ }
198
+ catch (e) {
199
+ evidenceProblem = String(e?.message ?? e);
200
+ console.warn(`[decidio] ${auth.decisionId}: the action SUCCEEDED but its result could not be reported as evidence (${evidenceProblem}). Reporting an empty captured response so the execution is still recorded — map the return value with the \`evidence\` option (e.g. evidence: (r) => ({ recordId: String(r.id), success: true })) to earn a confirming tier. The action was NOT re-run.`);
201
+ }
172
202
  try {
173
203
  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.`);
204
+ const confirmBody = await post(config, "/agent/confirm", buildConfirmBody(config, auth.decisionId, reported, confirmIdentity));
205
+ const ev = readConfirmResponse(confirmBody, { signed: !!confirmIdentity, capturedResponse: reported });
206
+ if (!ev.reportRecorded)
207
+ console.warn(`[decidio] ${auth.decisionId}: ${ev.diagnostic}`);
208
+ else if (!ev.executionConfirmed || ev.executionReportVerified === false)
209
+ console.warn(`[decidio] execution evidence for ${auth.decisionId} recorded as ${ev.evidenceTier} — ${ev.diagnostic}`);
210
+ if (evidenceProblem)
211
+ console.warn(`[decidio] ${auth.decisionId}: the execution IS recorded, but with an empty captured response — see the evidence note above.`);
183
212
  }
184
213
  catch (e) {
185
214
  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,60 @@ 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, when the gate reported on the SIGNED execution report: whether the
146
+ * attestation proof VERIFIED (C3, 0.1.12). */
147
+ executionReportVerified?: boolean;
148
+ /** executed only: why the evidence landed where it did, when that is worth explaining. */
149
+ diagnostic?: string;
95
150
  }
96
151
  /** Minimal config the controller needs to confirm outcomes + poll status. Shared
97
152
  * with GuardConfig (apiUrl/apiToken). */
@@ -110,6 +165,12 @@ export interface ResumeConfig {
110
165
  /** Shared secret Decidio signs the resume webhook with (HMAC-SHA256). Defaults to
111
166
  * the API token if unset. Verified before any re-execution. */
112
167
  webhookSecret?: string;
168
+ /** Where the execution journal lives (default `<pending dir>/reports`). Separate from the
169
+ * pending store by design: journal entries can only re-send a report, never re-run an action. */
170
+ reportDir?: string;
171
+ /** Map the action's natural return value to the evidence Decidio records — same option, same
172
+ * meaning as `protect`'s, so a durable resume reports exactly what the inline path would. */
173
+ evidence?: (result: any) => unknown;
113
174
  /** Escape hatch to accept UNSIGNED resume webhooks (no secret). Off by default:
114
175
  * with no secret and this unset, the webhook handler rejects every call (fail
115
176
  * closed) so a misconfigured agent can't be driven by forged callbacks. Setting it
@@ -133,7 +194,7 @@ export interface ResumeController extends ResumeConfig {
133
194
  /** Register a RAW fn under its action name so a (possibly cold) resume can replay
134
195
  * it. `withApproval` auto-registers on the hot path; a cold worker/service should
135
196
  * register at startup so it can resume actions it never originated. */
136
- register(action: string, fn: (...args: any[]) => Promise<any>): void;
197
+ register(action: string, fn: (...args: any[]) => Promise<any>, evidence?: (result: any) => unknown): void;
137
198
  /** Park a routed action WITH its integrity binding (prefer this over store.put — the
138
199
  * binding is what lets resolve() refuse a tampered payload). */
139
200
  park(p: Omit<PendingAction, "createdAt" | "argsSig">): Promise<void>;
@@ -158,6 +219,16 @@ export interface ResumeController extends ResumeConfig {
158
219
  }): {
159
220
  stop: () => void;
160
221
  };
222
+ /** Re-send a journalled execution report, IDEMPOTENTLY. Replays the exact request built before
223
+ * the first attempt — it can never re-run the business action, because the journal holds no
224
+ * handler binding. Returns the evidence outcome; a decision with nothing journalled returns
225
+ * `reportRecorded: false` with guidance rather than pretending. */
226
+ reconfirm(decisionId: string): Promise<EvidenceOutcome>;
227
+ /** Every durable item that needs a human's attention, with its state named — including the one
228
+ * state that CANNOT be resolved automatically: `indeterminate`, where a process died between
229
+ * claiming an action and journalling its result, so whether the action ran is unknown locally.
230
+ * This never executes or retries anything; it reports. */
231
+ recover(): Promise<RecoveryItem[]>;
161
232
  }
162
233
  declare function sign(secret: string, body: string): string;
163
234
  /** Verify Decidio's `x-decidio-signature` over the raw webhook body (HMAC-SHA256).
@@ -168,16 +239,6 @@ export declare function verifyResumeSignature(secret: string, body: string, head
168
239
  * tier is a FAILED confirm; a SIGNED confirm coming back below application_confirmed is
169
240
  * disclosed, never silent. Returns the recorded tier or throws. */
170
241
  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
242
  export declare function createDecidioResume(opts: ResumeConfig & {
182
243
  store: PendingStore;
183
244
  }): ResumeController;