@decidio/sdk 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/resume.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { RequestListener } from "node:http";
2
+ /** A routed action parked on the agent side, awaiting human approval. The `args`
3
+ * are the original call arguments, replayed verbatim when the action resumes. */
2
4
  export interface PendingAction {
3
5
  decisionId: string;
4
6
  receiptId?: string;
@@ -7,17 +9,37 @@ export interface PendingAction {
7
9
  scope?: string;
8
10
  args: unknown[];
9
11
  createdAt: string;
12
+ /** Integrity binding over {decisionId, action, amount, scope, args} (round-6 acceptance
13
+ * catch: the parked payload was mutable between routing and execution). ed25519 = detached
14
+ * signature by the agent's key; hmac = keyed by the webhook secret. Verified before ANY
15
+ * re-execution; a mismatch REFUSES and leaves the file for inspection. When the controller
16
+ * holds an agent key, a MISSING signature is also refused (a stripped signature is
17
+ * indistinguishable from tampering) — including entries parked by pre-0.1.6 versions:
18
+ * drain pending approvals before upgrading, or delete + re-run them. Only a keyless
19
+ * controller executes unbound entries (with a loud warning). Honest scope: this defends
20
+ * against modification by anything lacking the agent's key material — a fully compromised
21
+ * agent host can bypass the guard entirely and is out of client-side scope (server-sealed
22
+ * args binding is the receipt-v2 design). */
10
23
  argsSig?: {
11
24
  kind: "ed25519" | "hmac";
12
25
  sig: string;
13
26
  };
14
27
  }
28
+ /** Where suspended actions live between request and approval. The default is a
29
+ * file store; swap in Redis/Postgres/your queue for production. AGENT-SIDE only —
30
+ * Decidio never sees this. */
15
31
  export interface PendingStore {
16
32
  put(p: PendingAction): Promise<void>;
17
33
  get(decisionId: string): Promise<PendingAction | null>;
18
34
  delete(decisionId: string): Promise<void>;
19
35
  list(): Promise<PendingAction[]>;
36
+ /** Atomically take ownership of a parked action for execution. Returns true if THIS
37
+ * caller claimed it, false if it's already claimed/gone — the cross-process single-use
38
+ * guarantee (a production store backs this with rename/SETNX/SELECT-FOR-UPDATE). A
39
+ * claimed entry is excluded from list(). Optional: if absent, the controller falls
40
+ * back to an in-process guard (same-process only). */
20
41
  claim?(decisionId: string): Promise<boolean>;
42
+ /** Undo a claim so a failed execution is retryable. */
21
43
  release?(decisionId: string): Promise<void>;
22
44
  }
23
45
  export declare class MemoryPendingStore implements PendingStore {
@@ -30,6 +52,11 @@ export declare class MemoryPendingStore implements PendingStore {
30
52
  claim(id: string): Promise<boolean>;
31
53
  release(id: string): Promise<void>;
32
54
  }
55
+ /** File-backed store so a COLD process (the original requester long gone) can still
56
+ * resume — the proof that nothing is "kept alive". One JSON file per decision. A claim
57
+ * renames `<id>.json` → `<id>.claimed` atomically (same-volume rename), so two processes
58
+ * sharing the dir can't both execute. (A stale `.claimed` from a crash mid-execute needs
59
+ * TTL recovery — tracked in the backlog.) */
33
60
  export declare class FilePendingStore implements PendingStore {
34
61
  private dir;
35
62
  constructor(dir: string);
@@ -43,6 +70,10 @@ export declare class FilePendingStore implements PendingStore {
43
70
  claim(id: string): Promise<boolean>;
44
71
  release(id: string): Promise<void>;
45
72
  }
73
+ /** Thrown by a `withApproval`-wrapped call when the action is routed and configured
74
+ * for async resume: the action is now SUSPENDED (parked in the store), not failed.
75
+ * The caller can stop here — the process may even exit; the resume transport will
76
+ * complete it later. */
46
77
  export declare class DecidioSuspendedError extends Error {
47
78
  decisionId: string;
48
79
  action: string;
@@ -55,31 +86,71 @@ export interface ResumeOutcome {
55
86
  action?: string;
56
87
  result?: unknown;
57
88
  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 (prodsec F-03:
91
+ * the two were conflated, and a lost confirmation read as success). */
92
+ confirmed?: boolean;
93
+ /** executed only: the evidence tier the server says it recorded (e.g. application_confirmed). */
94
+ evidenceTier?: string;
58
95
  }
96
+ /** Minimal config the controller needs to confirm outcomes + poll status. Shared
97
+ * with GuardConfig (apiUrl/apiToken). */
59
98
  export interface ResumeConfig {
60
99
  apiUrl: string;
61
100
  apiToken?: string;
101
+ /** Agent identity for SIGNING the confirm call on resume (so the durable path can earn the
102
+ * application_confirmed tier, not just the inline path). Default from DECIDIO_AGENT_ID/
103
+ * _KEY/_DID env when unset — a cold resume process that has the env signs automatically. */
62
104
  agentId?: string;
63
105
  agentKey?: string;
64
106
  agentDid?: string;
107
+ /** The workspace the agent acts in (DECIDIO_WORKSPACE_ID) — bound into the cold-resume confirm
108
+ * proof (D-B3.6) so it's tenant-scoped too. Default from env when unset. */
65
109
  workspaceId?: string;
110
+ /** Shared secret Decidio signs the resume webhook with (HMAC-SHA256). Defaults to
111
+ * the API token if unset. Verified before any re-execution. */
66
112
  webhookSecret?: string;
113
+ /** Escape hatch to accept UNSIGNED resume webhooks (no secret). Off by default:
114
+ * with no secret and this unset, the webhook handler rejects every call (fail
115
+ * closed) so a misconfigured agent can't be driven by forged callbacks. Setting it
116
+ * true REMOVES webhook authenticity — anyone who can reach the endpoint can signal
117
+ * work — and the controller says so loudly at startup (prodsec F-07: an absolute
118
+ * fail-closed claim must disclose its opt-out). Only behind a trusted network
119
+ * boundary (e.g. mTLS / private mesh), never on an internet-reachable endpoint. */
67
120
  allowUnsigned?: boolean;
121
+ /** The callback URL Decidio should POST the verdict to (webhook transport). Sent
122
+ * on /agent/authorize so the decision carries it; omit when using the poll worker. */
68
123
  resumeUrl?: string;
124
+ /** Fired after every terminal resolution (executed / rejected / blocked / no_handler),
125
+ * via either transport. Use it to log, close a listener, or exit a CLI. */
69
126
  onResolved?: (o: ResumeOutcome) => void;
70
127
  fetchImpl?: typeof fetch;
71
128
  }
129
+ /** Re-execute the agent's own fn on approval, confirm the captured outcome to
130
+ * Decidio, and clear the parked entry. Bound to a store + a registry of raw fns. */
72
131
  export interface ResumeController extends ResumeConfig {
73
132
  store: PendingStore;
133
+ /** Register a RAW fn under its action name so a (possibly cold) resume can replay
134
+ * it. `withApproval` auto-registers on the hot path; a cold worker/service should
135
+ * register at startup so it can resume actions it never originated. */
74
136
  register(action: string, fn: (...args: any[]) => Promise<any>): void;
137
+ /** Park a routed action WITH its integrity binding (prefer this over store.put — the
138
+ * binding is what lets resolve() refuse a tampered payload). */
75
139
  park(p: Omit<PendingAction, "createdAt" | "argsSig">): Promise<void>;
140
+ /** Drive a parked decision to completion once a verdict is known. Idempotent:
141
+ * a second call after success finds nothing parked → "unknown" (no double-write). */
76
142
  resolve(input: {
77
143
  decisionId: string;
78
144
  verdict: "approved" | "rejected" | "blocked";
79
145
  reason?: string;
80
146
  }): Promise<ResumeOutcome>;
147
+ /** node:http listener for Decidio's signed approval webhook (Express/Fastify/node). */
81
148
  webhookHandler(): RequestListener;
149
+ /** Web-Fetch handler (Request → Response) for Next.js route handlers, Hono,
150
+ * Cloudflare Workers, Bun, Deno. Same fail-closed signature check. */
82
151
  fetchHandler(): (req: Request) => Promise<Response>;
152
+ /** Poll-based resume for hosts with no inbound URL (CLI/laptop). A long-lived
153
+ * watcher — this is a durable WORKER, not the request path blocking. */
83
154
  worker(opts?: {
84
155
  intervalMs?: number;
85
156
  timeoutMs?: number;
@@ -89,7 +160,25 @@ export interface ResumeController extends ResumeConfig {
89
160
  };
90
161
  }
91
162
  declare function sign(secret: string, body: string): string;
163
+ /** Verify Decidio's `x-decidio-signature` over the raw webhook body (HMAC-SHA256).
164
+ * Exported so engine adapters (e.g. the Inngest bridge) reuse the exact check. */
92
165
  export declare function verifyResumeSignature(secret: string, body: string, header: string | undefined): boolean;
166
+ /** The ONE confirm-body rule, used by ALL THREE TS confirm doors (resume, inline withApproval,
167
+ * adapter gate — guard equivalent-path catch: it lived only on the resume door). Transport
168
+ * success is not confirmation (prodsec F-03): a 2xx whose body carries no recorded tier is a
169
+ * FAILED confirm; a SIGNED confirm coming back below application_confirmed is disclosed, never
170
+ * silent. Returns the recorded tier or throws. */
171
+ export declare function assertConfirmRecorded(body: any, decisionId: string, signed: boolean): string;
172
+ /**
173
+ * Build the resume controller. Spread it into your GuardConfig as `resume`, then
174
+ * either mount `webhookHandler()` (production) or run `worker()` (CLI/laptop).
175
+ *
176
+ * const resume = createDecidioResume({ apiUrl, apiToken, store: new FilePendingStore(".decidio-pending"), resumeUrl });
177
+ * const guard = { apiUrl, agentId, apiToken, resume };
178
+ * const createOpportunity = withApproval(createOpportunityRaw, describe, guard);
179
+ * // production: http.createServer(resume.webhookHandler()).listen(4100)
180
+ * // or CLI: resume.worker()
181
+ */
93
182
  export declare function createDecidioResume(opts: ResumeConfig & {
94
183
  store: PendingStore;
95
184
  }): ResumeController;
package/dist/resume.js CHANGED
@@ -1,7 +1,27 @@
1
+ // @decidio/sdk — durable async resume.
2
+ //
3
+ // The hard problem behind a "one-line" agent gate: a real human approval takes
4
+ // minutes to days. Nobody watches a 60-second polling window. So a routed action
5
+ // must SUSPEND (the requesting process may even exit) and RESUME later when the
6
+ // approval arrives — and the AGENT must perform its own action on resume. Decidio
7
+ // stays the approval layer above: it authorizes, records, and (on approval) SIGNALS
8
+ // the agent. It never holds the agent's downstream credentials and never executes
9
+ // the write itself. This is the same shape as HumanLayer's webhooks, Temporal
10
+ // Signals, Inngest waitForEvent, and Decidio's own A2A `input-required` state.
11
+ //
12
+ // Two resume transports, same core:
13
+ // • webhookHandler() — production: Decidio POSTs a signed verdict to the agent's
14
+ // resume URL; the handler re-executes the agent's own fn and confirms.
15
+ // • worker() — no inbound URL (laptop/CLI): a long-lived watcher polls
16
+ // /agent/status for each pending decision, then re-executes on approval.
17
+ // Either way the args are replayed from an AGENT-SIDE store (Decidio holds none of
18
+ // the downstream payload — only the authority decision), and execution is single-use
19
+ // (the pending entry is deleted on completion, so a duplicate signal can't double-write).
1
20
  import { createHmac, timingSafeEqual, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify } from "node:crypto";
2
21
  import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync, renameSync } from "node:fs";
3
22
  import { join } from "node:path";
4
- import { signConfirm, canonicalize } from "./signing.js";
23
+ import { signConfirm, canonicalize, assertJsonSafe } from "./signing.js";
24
+ // ---- parked-payload integrity (round-6 acceptance catch) -------------------------------------
5
25
  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
26
  function signArgs(p, agentKey, webhookSecret) {
7
27
  const bytes = argsBindingBytes(p);
@@ -10,7 +30,7 @@ function signArgs(p, agentKey, webhookSecret) {
10
30
  const key = createPrivateKey({ key: Buffer.from(agentKey, "base64"), format: "der", type: "pkcs8" });
11
31
  return { kind: "ed25519", sig: Buffer.from(edSign(null, bytes, key)).toString("base64url") };
12
32
  }
13
- catch { }
33
+ catch { /* fall through to hmac */ }
14
34
  }
15
35
  if (webhookSecret)
16
36
  return { kind: "hmac", sig: createHmac("sha256", webhookSecret).update(bytes).digest("hex") };
@@ -48,6 +68,11 @@ export class MemoryPendingStore {
48
68
  return false; this.claimed.add(id); return true; }
49
69
  async release(id) { this.claimed.delete(id); }
50
70
  }
71
+ /** File-backed store so a COLD process (the original requester long gone) can still
72
+ * resume — the proof that nothing is "kept alive". One JSON file per decision. A claim
73
+ * renames `<id>.json` → `<id>.claimed` atomically (same-volume rename), so two processes
74
+ * sharing the dir can't both execute. (A stale `.claimed` from a crash mid-execute needs
75
+ * TTL recovery — tracked in the backlog.) */
51
76
  export class FilePendingStore {
52
77
  dir;
53
78
  constructor(dir) {
@@ -70,8 +95,12 @@ export class FilePendingStore {
70
95
  async list() {
71
96
  if (!existsSync(this.dir))
72
97
  return [];
98
+ // ONE BAD FILE MUST NOT STALL THE QUEUE: an unparseable entry (partial write, disk corruption,
99
+ // a human editing it) used to throw out of list() and take every other parked action down with
100
+ // it — a worker that can never poll is a worker that never resumes anything. Skip it loudly
101
+ // and keep the rest moving; the bad file stays on disk for inspection.
73
102
  const out = [];
74
- for (const f of readdirSync(this.dir).filter((f) => f.endsWith(".json"))) {
103
+ for (const f of readdirSync(this.dir).filter((f) => f.endsWith(".json"))) { // .claimed excluded — in flight
75
104
  try {
76
105
  out.push(JSON.parse(readFileSync(join(this.dir, f), "utf8")));
77
106
  }
@@ -85,7 +114,7 @@ export class FilePendingStore {
85
114
  try {
86
115
  renameSync(this.path(id), this.claimedPath(id));
87
116
  return true;
88
- }
117
+ } // atomic; fails if already claimed/gone
89
118
  catch {
90
119
  return false;
91
120
  }
@@ -94,9 +123,13 @@ export class FilePendingStore {
94
123
  try {
95
124
  renameSync(this.claimedPath(id), this.path(id));
96
125
  }
97
- catch { }
126
+ catch { /* already released/gone */ }
98
127
  }
99
128
  }
129
+ /** Thrown by a `withApproval`-wrapped call when the action is routed and configured
130
+ * for async resume: the action is now SUSPENDED (parked in the store), not failed.
131
+ * The caller can stop here — the process may even exit; the resume transport will
132
+ * complete it later. */
100
133
  export class DecidioSuspendedError extends Error {
101
134
  decisionId;
102
135
  action;
@@ -111,6 +144,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
111
144
  function sign(secret, body) {
112
145
  return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
113
146
  }
147
+ /** Verify Decidio's `x-decidio-signature` over the raw webhook body (HMAC-SHA256).
148
+ * Exported so engine adapters (e.g. the Inngest bridge) reuse the exact check. */
114
149
  export function verifyResumeSignature(secret, body, header) {
115
150
  if (!header)
116
151
  return false;
@@ -118,17 +153,39 @@ export function verifyResumeSignature(secret, body, header) {
118
153
  const a = Buffer.from(expected), b = Buffer.from(header);
119
154
  return a.length === b.length && timingSafeEqual(a, b);
120
155
  }
156
+ /** The ONE confirm-body rule, used by ALL THREE TS confirm doors (resume, inline withApproval,
157
+ * adapter gate — guard equivalent-path catch: it lived only on the resume door). Transport
158
+ * success is not confirmation (prodsec F-03): a 2xx whose body carries no recorded tier is a
159
+ * FAILED confirm; a SIGNED confirm coming back below application_confirmed is disclosed, never
160
+ * silent. Returns the recorded tier or throws. */
161
+ export function assertConfirmRecorded(body, decisionId, signed) {
162
+ if (!body || body.ok !== true || typeof body.confirmation !== "string") {
163
+ throw new Error(`confirm answered 2xx but reported no evidence tier — treating the confirmation as NOT recorded`);
164
+ }
165
+ if (signed && body.confirmation !== "application_confirmed") {
166
+ console.warn(`[decidio] execution evidence for ${decisionId} was recorded as "${body.confirmation}", not application_confirmed, despite a signed report — the server did not accept the identity proof (key/DID/registration mismatch?). Run \`npx @decidio/sdk doctor\` and re-init if this persists.`);
167
+ }
168
+ return body.confirmation;
169
+ }
170
+ /** Resume-door confirm: POST + the shared body rule above. */
121
171
  async function confirm(cfg, decisionId, capturedResponse) {
122
172
  const f = cfg.fetchImpl ?? fetch;
173
+ // Sign the confirm so the DURABLE/resume path can earn application_confirmed too (bound to
174
+ // {action:"confirm", scope:decisionId}); without a key the server caps it at agent_asserted.
123
175
  const requesterIdentity = signConfirm(decisionId, cfg);
176
+ // Same no-silent-downgrade rule as the inline wrap (guard equivalent-path catch): the durable
177
+ // path is what production agents run, so it must disclose an agent_asserted cap too.
124
178
  if (!requesterIdentity)
125
179
  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.`);
126
- await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
180
+ const r = await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
127
181
  method: "POST",
128
182
  headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
129
183
  body: JSON.stringify({ decisionId, capturedResponse: capturedResponse ?? null, source: "wrapper", ...(requesterIdentity ? { requesterIdentity } : {}) }),
130
- }).then((r) => { if (!r.ok)
131
- throw new Error(`confirm ${r.status}`); });
184
+ });
185
+ if (!r.ok)
186
+ throw new Error(`confirm ${r.status}`);
187
+ const body = await r.json().catch(() => null);
188
+ return assertConfirmRecorded(body, decisionId, !!requesterIdentity);
132
189
  }
133
190
  async function statusOf(cfg, decisionId) {
134
191
  const f = cfg.fetchImpl ?? fetch;
@@ -139,25 +196,58 @@ async function statusOf(cfg, decisionId) {
139
196
  throw new Error(`status ${r.status}`);
140
197
  return r.json();
141
198
  }
199
+ /**
200
+ * Build the resume controller. Spread it into your GuardConfig as `resume`, then
201
+ * either mount `webhookHandler()` (production) or run `worker()` (CLI/laptop).
202
+ *
203
+ * const resume = createDecidioResume({ apiUrl, apiToken, store: new FilePendingStore(".decidio-pending"), resumeUrl });
204
+ * const guard = { apiUrl, agentId, apiToken, resume };
205
+ * const createOpportunity = withApproval(createOpportunityRaw, describe, guard);
206
+ * // production: http.createServer(resume.webhookHandler()).listen(4100)
207
+ * // or CLI: resume.worker()
208
+ */
142
209
  export function createDecidioResume(opts) {
143
210
  const registry = new Map();
144
- const inFlight = new Set();
211
+ const inFlight = new Set(); // concurrent-duplicate guard (same process)
145
212
  const secret = opts.webhookSecret ?? opts.apiToken ?? "";
213
+ // CONTROLLER-scoped, not resolve()-scoped (guard catch: declared inside resolve, this Set was
214
+ // reborn empty every poll — the refusal printed 3/3 times, each ending with a false "prints
215
+ // once"). Refusals repeat on every cycle while the file sits parked; the MESSAGE must not.
216
+ // The opt-out of webhook authenticity must never be silent (prodsec F-07).
217
+ if (opts.allowUnsigned)
218
+ 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.");
219
+ const refusalWarned = new Set();
220
+ const refuseOnce = (decisionId, msg) => {
221
+ if (refusalWarned.has(decisionId))
222
+ return;
223
+ refusalWarned.add(decisionId);
224
+ console.error(msg + " (This prints once — the entry stays parked until you act.)");
225
+ };
146
226
  const ctrl = {
147
227
  ...opts,
228
+ // Default the signing identity from env so a COLD resume process (a fresh worker that has
229
+ // the agent's env but wasn't passed config) still signs the confirm and earns the wrapper tier.
148
230
  agentId: opts.agentId ?? process.env.DECIDIO_AGENT_ID,
149
231
  agentKey: opts.agentKey ?? process.env.DECIDIO_AGENT_KEY,
150
232
  agentDid: opts.agentDid ?? process.env.DECIDIO_AGENT_DID,
151
- workspaceId: opts.workspaceId ?? process.env.DECIDIO_WORKSPACE_ID,
233
+ workspaceId: opts.workspaceId ?? process.env.DECIDIO_WORKSPACE_ID, // D-B3.6: tenant-bind the cold-resume confirm proof
152
234
  webhookSecret: secret,
153
235
  store: opts.store,
154
236
  register(action, fn) {
237
+ // Silent last-writer-wins is the wrong default for an EXECUTION registry: two wrappers
238
+ // sharing an action name would let a resumed decision replay its args into the other
239
+ // function. Say so loudly — the parked args and the fn must be the pair that was routed.
155
240
  const prev = registry.get(action);
156
241
  if (prev && prev !== fn)
157
242
  console.warn(`[decidio] action "${action}" is being registered again with a DIFFERENT function — the latest one wins, and a resumed decision would run IT. Give each protected function its own action name.`);
158
243
  registry.set(action, fn);
159
244
  },
160
245
  async park(p) {
246
+ // GATE, not serialization (prodsec F-01): a value that cannot survive JSON with its meaning
247
+ // intact must be REFUSED before it is signed or parked — the signature covers canonical
248
+ // bytes, and NaN/Map collide with null/{} in those bytes, so it would verify and execute
249
+ // with drifted semantics. Refusal here means no decision, no signature, no file.
250
+ assertJsonSafe(p.args, "args");
161
251
  const argsSig = signArgs(p, ctrl.agentKey, ctrl.webhookSecret);
162
252
  if (!argsSig)
163
253
  console.warn(`[decidio] parking ${p.decisionId} UNBOUND — no agent key or webhook secret to sign the payload with, so a modified pending file could not be detected. Run \`npx @decidio/sdk init\` to configure keys.`);
@@ -168,7 +258,7 @@ export function createDecidioResume(opts) {
168
258
  opts.onResolved?.(o); return o; };
169
259
  const pending = await ctrl.store.get(decisionId);
170
260
  if (!pending)
171
- return { status: "unknown", decisionId };
261
+ return { status: "unknown", decisionId }; // not ours / already resumed
172
262
  if (verdict === "rejected") {
173
263
  await ctrl.store.delete(decisionId);
174
264
  return finish({ status: "rejected", decisionId, action: pending.action, reason });
@@ -177,30 +267,47 @@ export function createDecidioResume(opts) {
177
267
  await ctrl.store.delete(decisionId);
178
268
  return finish({ status: "blocked", decisionId, action: pending.action, reason });
179
269
  }
270
+ // Fail CLOSED on any unexpected verdict — never execute on a garbage/unknown value.
180
271
  if (verdict !== "approved" && verdict !== "auto_approved")
181
272
  return { status: "unknown", decisionId };
273
+ // INTEGRITY BEFORE EXECUTION (round-6 acceptance catch: a locally edited pending file
274
+ // executed different arguments from those routed for approval).
275
+ // SLOT ASSERTION FIRST (guard catch): the store keys on the FILENAME while the signature
276
+ // covers the CONTENT's decisionId — without this line, copying validly-signed entry A into
277
+ // slot B executes A's args when B is approved. No forgery needed, so it must be checked
278
+ // even before signature verification.
182
279
  if (pending.decisionId !== decisionId) {
183
- console.error(`[decidio] REFUSING to execute ${decisionId}: the parked entry in this slot belongs to a DIFFERENT decision (${pending.decisionId}) — the pending file was moved or swapped. Delete it and re-run the action.`);
280
+ refuseOnce(decisionId, `[decidio] REFUSING to execute ${decisionId}: the parked entry in this slot belongs to a DIFFERENT decision (${pending.decisionId}) — the pending file was moved or swapped. Delete it and re-run the action.`);
184
281
  return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload decision-id mismatch" });
185
282
  }
283
+ // STRICT binding is keyed to the agent's ED25519 signing key (what `init` writes) — an
284
+ // init'd agent always signs via park(), so an unbound entry there is tampering (incl. a
285
+ // stripped signature). A bare store.put user with no agent key keeps the documented
286
+ // low-level behavior (execute+warn); the apiToken-derived HMAC secret does NOT trigger
287
+ // strict mode (it lives in .env next to the pending dir — not a local-tamper defense).
186
288
  const strictBinding = !!ctrl.agentKey;
187
289
  const integrity = verifyArgs(pending, ctrl.agentKey, ctrl.webhookSecret);
188
290
  if (integrity === "mismatch") {
189
- console.error(`[decidio] REFUSING to execute ${decisionId}: the parked payload FAILED its integrity check — the arguments on disk are not the arguments that were routed for approval. The pending file is untouched for inspection; delete it and re-run the action to request a fresh approval.`);
291
+ refuseOnce(decisionId, `[decidio] REFUSING to execute ${decisionId}: the parked payload FAILED its integrity check — the arguments on disk are not the arguments that were routed for approval. The pending file is untouched for inspection; delete it and re-run the action to request a fresh approval.`);
190
292
  return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload integrity check failed" });
191
293
  }
192
294
  if (integrity === "unverifiable") {
193
- console.error(`[decidio] REFUSING to execute ${decisionId}: the parked payload is signed but this process lacks the key material to verify it (DECIDIO_AGENT_KEY / DECIDIO_WEBHOOK_SECRET). Restore the agent's env and retry.`);
295
+ refuseOnce(decisionId, `[decidio] REFUSING to execute ${decisionId}: the parked payload is signed but this process lacks the key material to verify it (DECIDIO_AGENT_KEY / DECIDIO_WEBHOOK_SECRET). Restore the agent's env and retry.`);
194
296
  return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload unverifiable" });
195
297
  }
196
298
  if (integrity === "unbound" && strictBinding) {
197
- console.error(`[decidio] REFUSING to execute ${decisionId}: the parked payload has NO integrity signature, but this agent has keys and would have signed it — a missing signature means the file was tampered with. Delete it and re-run the action.`);
299
+ // A controller WITH key material would have signed at park time, so a MISSING signature is
300
+ // itself tampering — an attacker stripping argsSig to reach the legacy path. Refuse. Only a
301
+ // keyless controller (genuine legacy / no-key deploy) may execute an unbound entry.
302
+ refuseOnce(decisionId, `[decidio] REFUSING to execute ${decisionId}: the parked payload has NO integrity signature, but this agent has keys and would have signed it — a missing signature means the file was tampered with. Delete it and re-run the action.`);
198
303
  return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload unsigned (expected a signature)" });
199
304
  }
200
305
  if (integrity === "unbound")
201
306
  console.warn(`[decidio] executing ${decisionId} from an UNBOUND parked entry (this agent has no key material to bind or verify payloads) — its arguments cannot be integrity-checked. Run \`npx @decidio/sdk init\` to enable binding.`);
202
307
  const fn = registry.get(pending.action);
203
308
  if (!fn) {
309
+ // Actionable, because this is the failure a restarted worker actually hits: the registry
310
+ // is per-process, and a cold process never ran the original call that would have filled it.
204
311
  console.error(`[decidio] cannot resume ${decisionId}: no function is registered for action "${pending.action}" in THIS process.`);
205
312
  console.error(` A worker only re-executes actions registered where it runs. At startup, either declare the action on the wrapper:`);
206
313
  console.error(` const doIt = guard.protect(doItRaw, describe, { action: "${pending.action}" });`);
@@ -209,19 +316,34 @@ export function createDecidioResume(opts) {
209
316
  console.error(` then start the worker. The approved action stays parked until then — nothing is lost.`);
210
317
  return finish({ status: "no_handler", decisionId, action: pending.action, reason: `no fn registered for action "${pending.action}" in this process — declare { action } on protect(), or call guard.register("${pending.action}", fn) at startup` });
211
318
  }
319
+ // Atomically CLAIM before executing — the cross-process single-use guarantee. If
320
+ // the store has no claim primitive, fall back to an in-process guard (same-process).
212
321
  const claimed = ctrl.store.claim ? await ctrl.store.claim(decisionId) : (!inFlight.has(decisionId) && (inFlight.add(decisionId), true));
213
322
  if (!claimed)
214
- return { status: "unknown", decisionId };
323
+ return { status: "unknown", decisionId }; // another worker owns it
215
324
  try {
325
+ // Approved: the AGENT performs its own action FIRST. If it throws, RELEASE the claim so
326
+ // the entry stays parked + retryable — an approved action is never lost.
216
327
  const result = await fn(...pending.args);
328
+ // CONFIRM BEFORE DELETE. Deleting first meant a failed confirm lost the only local record
329
+ // that this action had executed — the evidence could never be re-sent. The entry stays
330
+ // CLAIMED throughout (claim, not presence, is what prevents double-execution), so keeping
331
+ // it a moment longer cannot cause a second run.
217
332
  let confirmed = true;
333
+ let tier;
218
334
  try {
219
- await confirm(ctrl, decisionId, result);
335
+ tier = await confirm(ctrl, decisionId, result);
220
336
  }
221
337
  catch (e) {
222
338
  confirmed = false;
223
339
  console.warn(`[decidio] resume confirm failed (your action DID succeed): ${e.message}`);
224
340
  }
341
+ // RETAIN-vs-DELETE on a failed confirm. Keeping the entry preserves local evidence that
342
+ // this action executed — but ONLY a store with claim() can guarantee a retained entry
343
+ // never runs again (its claim refuses it; the in-process fallback dies with the process).
344
+ // For a claim-LESS store the delete IS the single-use guarantee, so it must still happen:
345
+ // an undelivered confirmation is an evidence gap, a second execution is a double write,
346
+ // and we never trade the second away for the first.
225
347
  const retainedIsSafe = typeof ctrl.store.claim === "function";
226
348
  if (confirmed || !retainedIsSafe)
227
349
  await ctrl.store.delete(decisionId);
@@ -231,7 +353,7 @@ export function createDecidioResume(opts) {
231
353
  else
232
354
  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.`);
233
355
  }
234
- return finish({ status: "executed", decisionId, action: pending.action, result });
356
+ return finish({ status: "executed", decisionId, action: pending.action, result, confirmed, ...(tier ? { evidenceTier: tier } : {}) });
235
357
  }
236
358
  catch (e) {
237
359
  if (ctrl.store.release)
@@ -252,6 +374,9 @@ export function createDecidioResume(opts) {
252
374
  req.on("data", (c) => (raw += c));
253
375
  req.on("end", async () => {
254
376
  try {
377
+ // Fail CLOSED when no secret is configured: an agent that forgot to set a
378
+ // webhookSecret must NOT be drivable by forged callbacks. Opt out only with
379
+ // allowUnsigned (trusted network boundary).
255
380
  if (!secret) {
256
381
  if (!opts.allowUnsigned) {
257
382
  res.statusCode = 401;
@@ -318,8 +443,22 @@ export function createDecidioResume(opts) {
318
443
  st.state === "blocked" ? "blocked" : null;
319
444
  if (!verdict)
320
445
  continue;
446
+ // A throwing resolve (the agent's own fn failed) must NOT kill the watcher —
447
+ // the entry stays parked and is retried on the next poll.
321
448
  try {
322
449
  const outcome = await ctrl.resolve({ decisionId: p.decisionId, verdict, reason: st.reason });
450
+ // ONE line per lifecycle event, on stderr (round-3 blind catch: the
451
+ // production-recommended worker executed, restart-resumed and discarded actions in
452
+ // total silence — the only evidence was a file vanishing). onResolved remains the
453
+ // programmatic hook; this is the human-visible floor.
454
+ if (outcome.status === "executed" && outcome.confirmed)
455
+ console.error(`[decidio] resumed ${p.decisionId}: approved → "${p.action}" executed, evidence recorded as ${outcome.evidenceTier ?? "confirmed"}.`);
456
+ else if (outcome.status === "executed")
457
+ console.error(`[decidio] resumed ${p.decisionId}: approved → "${p.action}" executed, but its execution evidence did NOT reach Decidio — see the warning above.`);
458
+ else if (outcome.status === "rejected")
459
+ console.error(`[decidio] resumed ${p.decisionId}: REJECTED — the parked "${p.action}" was discarded, nothing executed.`);
460
+ else if (outcome.status === "blocked")
461
+ console.error(`[decidio] resumed ${p.decisionId}: BLOCKED by policy — the parked "${p.action}" was discarded, nothing executed.`);
323
462
  workerOpts?.onResolved?.(outcome);
324
463
  }
325
464
  catch (e) {
package/dist/signing.d.ts CHANGED
@@ -1,4 +1,8 @@
1
+ /** Sorted-key JCS — must match apps/api/src/canonical.ts byte-for-byte. */
1
2
  export declare function canonicalize(v: unknown): string;
3
+ /** Generate a fresh Ed25519 keypair LOCALLY. Returns the public `did:key` (register this)
4
+ * and the PKCS8 private key base64 (keep this in the agent host's env as DECIDIO_AGENT_KEY —
5
+ * it never goes back to Decidio). */
2
6
  export declare function generateKeypair(): {
3
7
  did: string;
4
8
  privateKeyBase64: string;
@@ -17,12 +21,20 @@ export interface RequesterIdentity {
17
21
  signature: string;
18
22
  };
19
23
  }
24
+ /** Sign the CONFIRM call (bound to {action:"confirm", scope:decisionId}) so the gate can
25
+ * prove the caller is the receipt's original requester before granting the wrapper tier
26
+ * (application_confirmed). Returns undefined when no signing key is configured (the server
27
+ * then caps the report at agent_asserted). Used by EVERY confirm path — inline, durable
28
+ * resume, and the engine adapters — so the production paths confirm with a proof too. Also
29
+ * binds `workspaceId` (D-B3.6) when configured, so the confirm proof is tenant-scoped too. */
20
30
  export declare function signConfirm(decisionId: string, cfg: {
21
31
  agentId?: string;
22
32
  agentKey?: string;
23
33
  agentDid?: string;
24
34
  workspaceId?: string;
25
35
  }): RequesterIdentity | undefined;
36
+ /** Sign a request-bound identity proof. The returned object is sent verbatim as the
37
+ * `requesterIdentity` field of /agent/authorize. */
26
38
  export declare function signBoundProof(privateKeyBase64: string, fields: {
27
39
  agentId: string;
28
40
  did: string;
@@ -34,3 +46,11 @@ export declare function signBoundProof(privateKeyBase64: string, fields: {
34
46
  now?: number;
35
47
  nonce?: string;
36
48
  }): RequesterIdentity;
49
+ /** The values that do not survive `JSON.stringify → JSON.parse` with their meaning intact.
50
+ * Refused at the GATE, before anything is routed, signed, or parked (prodsec F-01: `NaN`
51
+ * canonicalized to `null` and a `Map` to `{}`, so the integrity signature matched the drifted
52
+ * bytes and the function EXECUTED with arguments that meant something else — the exact wrong
53
+ * execution the parked-payload signature exists to refuse). Throws naming the offending path;
54
+ * returns nothing. The supported data model is plain JSON: null, boolean, string, finite
55
+ * number, plain array, plain object. */
56
+ export declare function assertJsonSafe(value: unknown, path?: string): void;