@decidio/sdk 0.1.11 → 0.2.0

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.js CHANGED
@@ -1,9 +1,9 @@
1
- import { createHmac, timingSafeEqual, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify } from "node:crypto";
2
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync, renameSync } from "node:fs";
1
+ import { createHmac, timingSafeEqual, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify, randomUUID } from "node:crypto";
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync, renameSync, openSync, closeSync, fsyncSync, linkSync } from "node:fs";
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 as jcs, assertSafeTransport } from "./signing.js";
6
+ import { signConfirm, canonicalize as jcs, assertSafeTransport, carriesCredential } from "./signing.js";
7
7
  import { canonicalize as toCanonical, DecidioUnsafeArgumentsError } from "./canonical.js";
8
8
  import { readConfirmResponse, buildConfirmBody } from "./evidence.js";
9
9
  import { DecidioHttpError } from "./errors.js";
@@ -42,34 +42,202 @@ function verifyArgs(p, agentKey, webhookSecret) {
42
42
  return "mismatch";
43
43
  }
44
44
  }
45
+ const evidenceBindingBytes = (decisionId, attemptId, evidence) => Buffer.from(jcs({ decisionId, attemptId: attemptId ?? null, evidence: evidence ?? null }), "utf8");
46
+ export function signEvidence(decisionId, attemptId, evidence, agentKey, webhookSecret) {
47
+ const bytes = evidenceBindingBytes(decisionId, attemptId, evidence);
48
+ if (agentKey) {
49
+ try {
50
+ const key = createPrivateKey({ key: Buffer.from(agentKey, "base64"), format: "der", type: "pkcs8" });
51
+ return { kind: "ed25519", sig: Buffer.from(edSign(null, bytes, key)).toString("base64url") };
52
+ }
53
+ catch { }
54
+ }
55
+ if (webhookSecret)
56
+ return { kind: "hmac", sig: createHmac("sha256", webhookSecret).update(bytes).digest("hex") };
57
+ return undefined;
58
+ }
59
+ export function verifyEvidence(rec, agentKey, webhookSecret) {
60
+ if (!rec.evidenceSig)
61
+ return "unbound";
62
+ const bytes = evidenceBindingBytes(rec.decisionId, rec.attemptId, rec.evidence ?? null);
63
+ try {
64
+ if (rec.evidenceSig.kind === "ed25519") {
65
+ if (!agentKey)
66
+ return "unverifiable";
67
+ const pub = createPublicKey(createPrivateKey({ key: Buffer.from(agentKey, "base64"), format: "der", type: "pkcs8" }));
68
+ return edVerify(null, bytes, pub, Buffer.from(rec.evidenceSig.sig, "base64url")) ? "ok" : "mismatch";
69
+ }
70
+ if (!webhookSecret)
71
+ return "unverifiable";
72
+ const expected = createHmac("sha256", webhookSecret).update(bytes).digest("hex");
73
+ const x = Buffer.from(expected), y = Buffer.from(rec.evidenceSig.sig);
74
+ return x.length === y.length && timingSafeEqual(x, y) ? "ok" : "mismatch";
75
+ }
76
+ catch {
77
+ return "mismatch";
78
+ }
79
+ }
80
+ export const EXECUTION_STATES = [
81
+ "parked", "claimed_preinvoke", "invoking", "executed_unreported", "indeterminate", "reported",
82
+ ];
83
+ export const NON_REPLAYABLE = ["invoking", "executed_unreported", "indeterminate", "reported"];
84
+ export class DecidioStaleAttemptError extends Error {
85
+ decisionId;
86
+ owner;
87
+ state;
88
+ constructor(decisionId, owner, state) {
89
+ super(`[decidio] ${decisionId}: this attempt no longer owns the decision — attempt ${owner ?? "(unknown)"} holds it in state ${state}. Refusing a state write from a superseded attempt.`);
90
+ this.decisionId = decisionId;
91
+ this.owner = owner;
92
+ this.state = state;
93
+ this.name = "DecidioStaleAttemptError";
94
+ }
95
+ }
96
+ export class DecidioAlreadySettledError extends Error {
97
+ decisionId;
98
+ constructor(decisionId) {
99
+ super(`[decidio] ${decisionId}: an operator already settled this decision as committed and its report is recorded — refusing a second execution report. Nothing was re-run; this write is the duplicate, not the execution.`);
100
+ this.decisionId = decisionId;
101
+ this.name = "DecidioAlreadySettledError";
102
+ }
103
+ }
104
+ function assertNotSettled(id, cur, next) {
105
+ if (cur.reconciled?.outcome === "committed" && (next === "executed_unreported" || next === "indeterminate")) {
106
+ throw new DecidioAlreadySettledError(id);
107
+ }
108
+ }
109
+ export const SETTLE_OUTCOMES = ["committed", "not_committed"];
110
+ export function assertSettlable(id, state, outcome, hasObservedEvidence = true) {
111
+ if (!SETTLE_OUTCOMES.includes(outcome)) {
112
+ throw new Error(`[decidio] ${id}: outcome must be "committed" or "not_committed", not ${JSON.stringify(outcome)}.`);
113
+ }
114
+ if (outcome === "committed") {
115
+ if (state === "reported")
116
+ throw new Error(`[decidio] ${id}: already reported — there is nothing left to reconcile.`);
117
+ if (state === "executed_unreported" && hasObservedEvidence)
118
+ throw new Error(`[decidio] ${id}: this decision already holds evidence this SDK OBSERVED — an operator assertion must not replace a captured result. If the report simply has not reached Decidio, re-send the real one: reconfirm("${id}").`);
119
+ if (rank(state) < rank("invoking"))
120
+ throw new Error(`[decidio] ${id}: refusing to record a COMMITTED outcome from state ${state} — the handler was never invoked, so no downstream write can have happened. If you mean "run it", let it resume normally.`);
121
+ }
122
+ else {
123
+ if (state === "reported")
124
+ throw new Error(`[decidio] ${id}: already reported — releasing it would re-run an execution Decidio has already recorded.`);
125
+ if (state === "executed_unreported")
126
+ throw new Error(`[decidio] ${id}: this decision holds CAPTURED EVIDENCE of an execution — releasing it would re-run a proven write. Re-send the evidence with reconfirm("${id}") instead.`);
127
+ if (state === "parked")
128
+ throw new Error(`[decidio] ${id}: already parked — nothing to release.`);
129
+ }
130
+ }
131
+ const rank = (s) => EXECUTION_STATES.indexOf(s);
45
132
  export class MemoryPendingStore {
46
133
  m = new Map();
47
- claimed = new Set();
48
- async put(p) { this.m.set(p.decisionId, p); }
134
+ x = new Map();
135
+ rec(id) {
136
+ return this.x.get(id) ?? { decisionId: id, state: "parked", attemptId: null, at: new Date().toISOString() };
137
+ }
138
+ advance(id, attemptId, state, patch = {}) {
139
+ const cur = this.rec(id);
140
+ if (cur.attemptId && cur.attemptId !== attemptId && rank(cur.state) >= rank("invoking")) {
141
+ throw new DecidioStaleAttemptError(id, cur.attemptId, cur.state);
142
+ }
143
+ assertNotSettled(id, cur, state);
144
+ if (rank(state) < rank(cur.state)) {
145
+ throw new Error(`[decidio] ${id}: refusing to move ${cur.state} → ${state}; execution state only ever advances.`);
146
+ }
147
+ this.x.set(id, { ...cur, ...patch, decisionId: id, attemptId, state, at: new Date().toISOString() });
148
+ }
149
+ async put(p) {
150
+ this.m.set(p.decisionId, p);
151
+ if (!this.x.has(p.decisionId))
152
+ this.x.set(p.decisionId, { decisionId: p.decisionId, state: "parked", attemptId: null, action: p.action, at: new Date().toISOString() });
153
+ }
49
154
  async get(id) { return this.m.get(id) ?? null; }
50
- async delete(id) { this.m.delete(id); this.claimed.delete(id); }
51
- async list() { return [...this.m.values()].filter((p) => !this.claimed.has(p.decisionId)); }
52
- async claim(id) { if (this.claimed.has(id) || !this.m.has(id))
53
- return false; this.claimed.add(id); return true; }
54
- async release(id) { this.claimed.delete(id); }
55
- async listClaimed() { return [...this.claimed].map((id) => this.m.get(id)).filter(Boolean); }
155
+ async delete(id) { this.m.delete(id); }
156
+ async list() { return [...this.m.values()].filter((p) => this.rec(p.decisionId).state === "parked"); }
157
+ async claim(id, attemptId) {
158
+ if (!this.m.has(id))
159
+ return { ok: false, reason: "no parked entry" };
160
+ const cur = this.rec(id);
161
+ if (NON_REPLAYABLE.includes(cur.state))
162
+ return { ok: false, state: cur.state, reason: `already ${cur.state}` };
163
+ if (cur.state === "claimed_preinvoke")
164
+ return { ok: false, state: cur.state, reason: "claimed by another attempt" };
165
+ this.advance(id, attemptId, "claimed_preinvoke", { action: this.m.get(id)?.action });
166
+ return { ok: true };
167
+ }
168
+ async markInvoking(id, attemptId) { this.advance(id, attemptId, "invoking"); }
169
+ async recordExecuted(id, attemptId, evidence, evidenceSig, evidenceError) { this.advance(id, attemptId, "executed_unreported", { evidence, hasEvidence: true, evidenceSig, evidenceError }); }
170
+ async markIndeterminate(id, attemptId, error) { this.advance(id, attemptId, "indeterminate", { error }); }
171
+ async markReported(id, attemptId, confirmation) { this.advance(id, attemptId, "reported", { confirmation }); }
172
+ async getExecution(id) { return this.x.get(id) ?? null; }
173
+ async listExecutions() { return [...this.x.values()]; }
174
+ async settle(id, outcome, opts = {}) {
175
+ const cur = this.rec(id);
176
+ assertSettlable(id, cur.state, outcome, !cur.evidenceError);
177
+ const at = new Date().toISOString();
178
+ const reconciled = { outcome, reason: opts.reason, at };
179
+ this.x.set(id, outcome === "committed"
180
+ ? { ...cur, state: "executed_unreported", evidence: opts.evidence ?? null, hasEvidence: true, evidenceSig: opts.evidenceSig, evidenceError: undefined, reconciled, at }
181
+ : { ...cur, state: "parked", attemptId: null, error: undefined, reconciled, at });
182
+ }
183
+ async releasePreinvoke(id, attemptId) {
184
+ const cur = this.rec(id);
185
+ if (rank(cur.state) >= rank("invoking"))
186
+ throw new Error(`[decidio] ${id}: refusing to release a claim after invocation began (state ${cur.state}).`);
187
+ if (cur.attemptId && cur.attemptId !== attemptId)
188
+ return;
189
+ this.x.set(id, { ...cur, state: "parked", attemptId: null, at: new Date().toISOString() });
190
+ }
56
191
  }
57
192
  export function hardenDir(dir) {
58
193
  if (process.platform !== "win32")
59
194
  return;
195
+ const warn = (why) => console.warn(`[decidio] WARNING: could not restrict ${dir} to owner-only (${why}). 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.`);
60
196
  try {
61
- const user = (() => { try {
62
- return userInfo().username;
63
- }
64
- catch {
65
- return process.env.USERNAME ?? process.env.USER ?? "";
66
- } })();
67
- if (!user)
197
+ const sid = (() => {
198
+ const sysRoot = process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows";
199
+ for (const exe of [join(sysRoot, "System32", "whoami.exe"), "whoami"]) {
200
+ try {
201
+ const out = execFileSync(exe, ["/user", "/fo", "csv", "/nh"], { encoding: "utf8" });
202
+ const m = out.match(/S-1-[0-9-]+/);
203
+ if (m)
204
+ return m[0];
205
+ }
206
+ catch { }
207
+ }
208
+ return null;
209
+ })();
210
+ const principal = sid ? `*${sid}` : (() => {
211
+ try {
212
+ return userInfo().username;
213
+ }
214
+ catch {
215
+ return process.env.USERNAME ?? process.env.USER ?? "";
216
+ }
217
+ })();
218
+ if (!principal)
68
219
  throw new Error("could not determine the current user");
69
- execFileSync("icacls", [dir, "/inheritance:r", "/grant:r", `${user}:(OI)(CI)F`], { stdio: "ignore" });
220
+ execFileSync("icacls", [dir, "/inheritance:r", "/grant:r", `${principal}:(OI)(CI)F`, "/grant:r", "*S-1-5-18:(OI)(CI)F"], { stdio: "ignore" });
221
+ const probe = join(dir, `.decidio-acl-probe-${process.pid}`);
222
+ try {
223
+ writeFileSync(probe, "probe", { mode: 0o600 });
224
+ readFileSync(probe, "utf8");
225
+ rmSync(probe, { force: true });
226
+ }
227
+ catch (verifyError) {
228
+ try {
229
+ execFileSync("icacls", [dir, "/inheritance:e"], { stdio: "ignore" });
230
+ }
231
+ catch { }
232
+ try {
233
+ rmSync(probe, { force: true });
234
+ }
235
+ catch { }
236
+ warn(`the restriction applied but this process could no longer write there (${verifyError?.message ?? verifyError}), so it was rolled back`);
237
+ }
70
238
  }
71
239
  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.`);
240
+ warn(String(e?.message ?? e));
73
241
  }
74
242
  }
75
243
  export class FilePendingStore {
@@ -78,62 +246,303 @@ export class FilePendingStore {
78
246
  this.dir = dir;
79
247
  mkdirSync(dir, { recursive: true });
80
248
  hardenDir(dir);
249
+ try {
250
+ this.migrateLegacy();
251
+ }
252
+ catch (e) {
253
+ console.error(`[decidio] could not upgrade the 0.1.x pending layout in ${dir} (${e?.message ?? e}). Entries written by 0.1.x may not appear in recover() — inspect the directory before assuming it is empty.`);
254
+ }
81
255
  }
82
256
  key(id) { return id.replace(/[^\w.-]/g, "_"); }
83
257
  path(id) { return join(this.dir, `${this.key(id)}.json`); }
84
- claimedPath(id) { return join(this.dir, `${this.key(id)}.claimed`); }
85
- async put(p) { writeFileSync(this.path(p.decisionId), JSON.stringify(p, null, 2), { mode: 0o600 }); }
258
+ statePath(id, s) { return join(this.dir, `${this.key(id)}.${s}`); }
259
+ writeAtomic(file, text) {
260
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
261
+ const fd = openSync(tmp, "w", 0o600);
262
+ try {
263
+ writeFileSync(fd, text);
264
+ fsyncSync(fd);
265
+ }
266
+ finally {
267
+ closeSync(fd);
268
+ }
269
+ renameSync(tmp, file);
270
+ }
271
+ resolve(id) {
272
+ const corrupt = [];
273
+ let best = null;
274
+ for (const s of EXECUTION_STATES) {
275
+ const f = this.statePath(id, s);
276
+ if (!existsSync(f))
277
+ continue;
278
+ try {
279
+ const r = JSON.parse(readFileSync(f, "utf8"));
280
+ if (!best || rank(s) >= rank(best.state))
281
+ best = { ...r, state: s };
282
+ }
283
+ catch (e) {
284
+ corrupt.push(`${f}: ${e.message}`);
285
+ }
286
+ }
287
+ return { rec: best, corrupt };
288
+ }
289
+ setState(id, attemptId, state, patch = {}) {
290
+ const { rec, corrupt } = this.resolve(id);
291
+ if (corrupt.length)
292
+ throw new DecidioStateCorruptError(id, corrupt);
293
+ const cur = rec ?? { decisionId: id, state: "parked", attemptId: null, at: new Date().toISOString() };
294
+ if (cur.attemptId && cur.attemptId !== attemptId && rank(cur.state) >= rank("invoking")) {
295
+ throw new DecidioStaleAttemptError(id, cur.attemptId, cur.state);
296
+ }
297
+ assertNotSettled(id, cur, state);
298
+ if (rank(state) < rank(cur.state)) {
299
+ throw new Error(`[decidio] ${id}: refusing to move ${cur.state} → ${state}; execution state only ever advances.`);
300
+ }
301
+ const next = { ...cur, ...patch, decisionId: id, attemptId, state, at: new Date().toISOString() };
302
+ this.writeAtomic(this.statePath(id, state), JSON.stringify(next, null, 2));
303
+ for (const s of EXECUTION_STATES) {
304
+ if (s === state)
305
+ continue;
306
+ if (rank(s) >= rank(state))
307
+ continue;
308
+ const f = this.statePath(id, s);
309
+ try {
310
+ if (existsSync(f))
311
+ rmSync(f);
312
+ }
313
+ catch { }
314
+ }
315
+ }
316
+ async put(p) {
317
+ this.writeAtomic(this.path(p.decisionId), JSON.stringify(p, null, 2));
318
+ if (!this.resolve(p.decisionId).rec) {
319
+ this.writeAtomic(this.statePath(p.decisionId, "parked"), JSON.stringify({ decisionId: p.decisionId, state: "parked", attemptId: null, action: p.action, at: new Date().toISOString() }, null, 2));
320
+ }
321
+ }
86
322
  async get(id) {
87
- for (const f of [this.path(id), this.claimedPath(id)])
88
- if (existsSync(f))
89
- return JSON.parse(readFileSync(f, "utf8"));
90
- return null;
91
- }
92
- async delete(id) { for (const f of [this.path(id), this.claimedPath(id)])
93
- if (existsSync(f))
94
- rmSync(f); }
323
+ const f = this.path(id);
324
+ return existsSync(f) ? JSON.parse(readFileSync(f, "utf8")) : null;
325
+ }
326
+ async delete(id) { try {
327
+ if (existsSync(this.path(id)))
328
+ rmSync(this.path(id));
329
+ }
330
+ catch { } }
95
331
  async list() {
96
332
  if (!existsSync(this.dir))
97
333
  return [];
98
334
  const out = [];
99
335
  for (const f of readdirSync(this.dir).filter((f) => f.endsWith(".json"))) {
336
+ let p;
100
337
  try {
101
- out.push(JSON.parse(readFileSync(join(this.dir, f), "utf8")));
338
+ p = JSON.parse(readFileSync(join(this.dir, f), "utf8"));
102
339
  }
103
340
  catch (e) {
104
341
  console.error(`[decidio] skipping unreadable parked entry ${f} (${e.message}) — the other parked actions still resume; inspect or delete this file.`);
342
+ continue;
105
343
  }
344
+ const { rec } = this.resolve(p.decisionId);
345
+ if (!rec || rec.state === "parked")
346
+ out.push(p);
106
347
  }
107
348
  return out;
108
349
  }
109
- async claim(id) {
350
+ async claim(id, attemptId) {
351
+ if (!existsSync(this.path(id)))
352
+ return { ok: false, reason: "no parked entry" };
353
+ const { rec, corrupt } = this.resolve(id);
354
+ if (corrupt.length)
355
+ throw new DecidioStateCorruptError(id, corrupt);
356
+ const state = rec?.state ?? "parked";
357
+ if (NON_REPLAYABLE.includes(state))
358
+ return { ok: false, state, reason: `already ${state}` };
359
+ if (state === "claimed_preinvoke")
360
+ return { ok: false, state, reason: "claimed by another attempt" };
361
+ const target = this.statePath(id, "claimed_preinvoke");
362
+ const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
363
+ const payload = JSON.stringify({ decisionId: id, state: "claimed_preinvoke", attemptId, action: rec?.action, at: new Date().toISOString() }, null, 2);
364
+ const fd = openSync(tmp, "w", 0o600);
365
+ try {
366
+ writeFileSync(fd, payload);
367
+ fsyncSync(fd);
368
+ }
369
+ finally {
370
+ closeSync(fd);
371
+ }
110
372
  try {
111
- renameSync(this.path(id), this.claimedPath(id));
112
- return true;
373
+ linkSync(tmp, target);
113
374
  }
114
375
  catch {
115
- return false;
376
+ try {
377
+ rmSync(tmp);
378
+ }
379
+ catch { }
380
+ return { ok: false, state: "claimed_preinvoke", reason: "lost the claim race" };
116
381
  }
117
- }
118
- async release(id) {
119
382
  try {
120
- renameSync(this.claimedPath(id), this.path(id));
383
+ rmSync(tmp);
384
+ }
385
+ catch { }
386
+ try {
387
+ const p = this.statePath(id, "parked");
388
+ if (existsSync(p))
389
+ rmSync(p);
121
390
  }
122
391
  catch { }
392
+ return { ok: true };
123
393
  }
124
- async listClaimed() {
394
+ async markInvoking(id, attemptId) { this.setState(id, attemptId, "invoking"); }
395
+ async recordExecuted(id, attemptId, evidence, evidenceSig, evidenceError) { this.setState(id, attemptId, "executed_unreported", { evidence, hasEvidence: true, evidenceSig, evidenceError }); }
396
+ async markIndeterminate(id, attemptId, error) { this.setState(id, attemptId, "indeterminate", { error }); }
397
+ async markReported(id, attemptId, confirmation) { this.setState(id, attemptId, "reported", { confirmation }); }
398
+ async getExecution(id) {
399
+ const { rec, corrupt } = this.resolve(id);
400
+ if (corrupt.length)
401
+ throw new DecidioStateCorruptError(id, corrupt);
402
+ return rec;
403
+ }
404
+ async listExecutions() {
125
405
  if (!existsSync(this.dir))
126
406
  return [];
407
+ const ids = new Set();
408
+ for (const name of readdirSync(this.dir)) {
409
+ const dot = name.lastIndexOf(".");
410
+ if (dot < 0)
411
+ continue;
412
+ const suffix = name.slice(dot + 1);
413
+ if (!EXECUTION_STATES.includes(suffix))
414
+ continue;
415
+ ids.add(name.slice(0, dot));
416
+ }
127
417
  const out = [];
418
+ for (const key of ids) {
419
+ const { rec, corrupt } = this.resolve(key);
420
+ if (corrupt.length) {
421
+ out.push({ decisionId: key, state: "indeterminate", attemptId: null, error: `state files unreadable — ${corrupt.join("; ")}`, at: new Date().toISOString() });
422
+ continue;
423
+ }
424
+ if (rec)
425
+ out.push(rec);
426
+ }
427
+ return out;
428
+ }
429
+ async settle(id, outcome, opts = {}) {
430
+ const { rec, corrupt } = this.resolve(id);
431
+ if (corrupt.length)
432
+ throw new DecidioStateCorruptError(id, corrupt);
433
+ const cur = rec ?? { decisionId: id, state: "parked", attemptId: null, at: new Date().toISOString() };
434
+ assertSettlable(id, cur.state, outcome, !cur.evidenceError);
435
+ const at = new Date().toISOString();
436
+ const next = outcome === "committed"
437
+ ? { ...cur, state: "executed_unreported", evidence: opts.evidence ?? null, hasEvidence: true, evidenceSig: opts.evidenceSig, evidenceError: undefined, reconciled: { outcome, reason: opts.reason, at }, at }
438
+ : { ...cur, state: "parked", attemptId: null, error: undefined, reconciled: { outcome, reason: opts.reason, at }, at };
439
+ this.writeAtomic(this.statePath(id, next.state), JSON.stringify(next, null, 2));
440
+ for (const st of EXECUTION_STATES) {
441
+ if (st === next.state)
442
+ continue;
443
+ try {
444
+ const f = this.statePath(id, st);
445
+ if (existsSync(f))
446
+ rmSync(f);
447
+ }
448
+ catch { }
449
+ }
450
+ }
451
+ async releasePreinvoke(id, attemptId) {
452
+ const { rec } = this.resolve(id);
453
+ const state = rec?.state ?? "parked";
454
+ if (rank(state) >= rank("invoking"))
455
+ throw new Error(`[decidio] ${id}: refusing to release a claim after invocation began (state ${state}).`);
456
+ if (rec?.attemptId && rec.attemptId !== attemptId)
457
+ return;
458
+ this.writeAtomic(this.statePath(id, "parked"), JSON.stringify({ decisionId: id, state: "parked", attemptId: null, action: rec?.action, at: new Date().toISOString() }, null, 2));
459
+ try {
460
+ const c = this.statePath(id, "claimed_preinvoke");
461
+ if (existsSync(c))
462
+ rmSync(c);
463
+ }
464
+ catch { }
465
+ }
466
+ migrateLegacy() {
467
+ if (!existsSync(this.dir))
468
+ return { migrated: 0, indeterminate: 0, reports: 0 };
469
+ let migrated = 0, indeterminate = 0;
128
470
  for (const name of readdirSync(this.dir)) {
129
- if (!name.endsWith(".claimed"))
471
+ if (name.endsWith(".claimed")) {
472
+ const key = name.slice(0, -".claimed".length);
473
+ try {
474
+ const p = JSON.parse(readFileSync(join(this.dir, name), "utf8"));
475
+ this.writeAtomic(this.statePath(p.decisionId, "indeterminate"), JSON.stringify({ decisionId: p.decisionId, state: "indeterminate", attemptId: null, action: p.action, error: "upgraded from 0.1.x while claimed — whether it ran is not knowable; reconcile it", at: new Date().toISOString() }, null, 2));
476
+ this.writeAtomic(this.path(p.decisionId), JSON.stringify(p, null, 2));
477
+ rmSync(join(this.dir, name));
478
+ indeterminate++;
479
+ }
480
+ catch {
481
+ console.error(`[decidio] could not migrate legacy claimed entry ${key} — inspect it by hand.`);
482
+ }
483
+ continue;
484
+ }
485
+ if (!name.endsWith(".json"))
130
486
  continue;
131
487
  try {
132
- out.push(JSON.parse(readFileSync(join(this.dir, name), "utf8")));
488
+ const p = JSON.parse(readFileSync(join(this.dir, name), "utf8"));
489
+ if (!p?.decisionId)
490
+ continue;
491
+ if (this.resolve(p.decisionId).rec)
492
+ continue;
493
+ this.writeAtomic(this.statePath(p.decisionId, "parked"), JSON.stringify({ decisionId: p.decisionId, state: "parked", attemptId: null, action: p.action, at: new Date().toISOString() }, null, 2));
494
+ migrated++;
133
495
  }
134
496
  catch { }
135
497
  }
136
- return out;
498
+ if (indeterminate)
499
+ console.error(`[decidio] upgraded ${indeterminate} in-flight 0.1.x entr${indeterminate === 1 ? "y" : "ies"} to INDETERMINATE — they will not auto-run; review them with guard.resume.recover().`);
500
+ const reports = this.migrateLegacyReports();
501
+ return { migrated, indeterminate, reports };
502
+ }
503
+ migrateLegacyReports() {
504
+ const reportsDir = join(this.dir, "reports");
505
+ if (!existsSync(reportsDir))
506
+ return 0;
507
+ let moved = 0;
508
+ for (const name of readdirSync(reportsDir)) {
509
+ if (!name.endsWith(".json"))
510
+ continue;
511
+ const file = join(reportsDir, name);
512
+ try {
513
+ const e = JSON.parse(readFileSync(file, "utf8"));
514
+ if (!e?.decisionId)
515
+ continue;
516
+ const existing = this.resolve(e.decisionId).rec;
517
+ if (existing && rank(existing.state) >= rank("executed_unreported")) {
518
+ renameSync(file, `${file}.migrated`);
519
+ continue;
520
+ }
521
+ this.writeAtomic(this.statePath(e.decisionId, "executed_unreported"), JSON.stringify({
522
+ decisionId: e.decisionId, state: "executed_unreported", attemptId: null, action: e.action,
523
+ evidence: e.reported, hasEvidence: true, migratedFrom: "0.1.x report journal",
524
+ at: e.at ?? new Date().toISOString(),
525
+ }, null, 2));
526
+ renameSync(file, `${file}.migrated`);
527
+ moved++;
528
+ }
529
+ catch {
530
+ console.error(`[decidio] could not migrate the 0.1.x execution report ${file} — it records an action that RAN. Inspect it by hand before deleting it.`);
531
+ }
532
+ }
533
+ if (moved)
534
+ console.error(`[decidio] recovered ${moved} execution report${moved === 1 ? "" : "s"} written by 0.1.x into the execution ledger — re-send them with guard.resume.recover() / reconfirm(). The original files are kept alongside as .migrated.`);
535
+ return moved;
536
+ }
537
+ }
538
+ export class DecidioStateCorruptError extends Error {
539
+ decisionId;
540
+ details;
541
+ constructor(decisionId, details) {
542
+ super(`[decidio] ${decisionId}: durable execution state is unreadable (${details.join("; ")}). Refusing to guess whether this action ran — inspect these files; reconcile() once you know.`);
543
+ this.decisionId = decisionId;
544
+ this.details = details;
545
+ this.name = "DecidioStateCorruptError";
137
546
  }
138
547
  }
139
548
  export class DecidioSuspendedError extends Error {
@@ -158,13 +567,14 @@ export function verifyResumeSignature(secret, body, header) {
158
567
  return a.length === b.length && timingSafeEqual(a, b);
159
568
  }
160
569
  export function assertConfirmRecorded(body, decisionId, signed) {
161
- if (!body || body.ok !== true || typeof body.confirmation !== "string") {
162
- throw new Error(`confirm answered 2xx but reported no evidence tier — treating the confirmation as NOT recorded`);
570
+ const ev = readConfirmResponse(body, { signed, journalled: false });
571
+ if (!ev.reportRecorded || !ev.evidenceTier) {
572
+ throw new Error(`[decidio] ${decisionId}: ${ev.diagnostic ?? "confirm answered 2xx but reported no recorded evidence tier — treating the confirmation as NOT recorded"}`);
163
573
  }
164
- if (signed && body.confirmation !== "application_confirmed") {
165
- 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.`);
574
+ if (signed && ev.evidenceTier !== "application_confirmed") {
575
+ console.warn(`[decidio] execution evidence for ${decisionId} was recorded as "${ev.evidenceTier}", 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.`);
166
576
  }
167
- return body.confirmation;
577
+ return ev.evidenceTier;
168
578
  }
169
579
  function buildResumeConfirmRequest(cfg, decisionId, canonicalResult) {
170
580
  const requesterIdentity = signConfirm(decisionId, cfg);
@@ -172,9 +582,9 @@ function buildResumeConfirmRequest(cfg, decisionId, canonicalResult) {
172
582
  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
583
  return buildConfirmBody({ agentId: cfg.agentId ?? "agent", agentKey: cfg.agentKey, agentDid: cfg.agentDid, workspaceId: cfg.workspaceId }, decisionId, canonicalResult, requesterIdentity);
174
584
  }
175
- async function postConfirm(cfg, decisionId, body) {
585
+ async function postConfirm(cfg, decisionId, body, journalled = true) {
176
586
  const f = cfg.fetchImpl ?? fetch;
177
- assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
587
+ assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
178
588
  const r = await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
179
589
  method: "POST",
180
590
  headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
@@ -190,11 +600,11 @@ async function postConfirm(cfg, decisionId, body) {
190
600
  throw new DecidioHttpError(r.status, "/agent/confirm", parsed);
191
601
  }
192
602
  const parsed = await r.json().catch(() => null);
193
- return readConfirmResponse(parsed, { signed: !!body.requesterIdentity, capturedResponse: body.capturedResponse, journalled: true });
603
+ return readConfirmResponse(parsed, { signed: !!body.requesterIdentity, capturedResponse: body.capturedResponse, journalled });
194
604
  }
195
605
  async function statusOf(cfg, decisionId) {
196
606
  const f = cfg.fetchImpl ?? fetch;
197
- assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
607
+ assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
198
608
  const r = await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/status/" + encodeURIComponent(decisionId), {
199
609
  headers: { ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
200
610
  });
@@ -202,62 +612,56 @@ async function statusOf(cfg, decisionId) {
202
612
  throw new DecidioHttpError(r.status, `/agent/status/${decisionId}`);
203
613
  return r.json();
204
614
  }
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
- }
615
+ const V2_STORE_METHODS = [
616
+ "put", "get", "delete", "list",
617
+ "claim", "markInvoking", "recordExecuted", "markIndeterminate", "markReported",
618
+ "getExecution", "listExecutions", "releasePreinvoke", "settle",
619
+ ];
620
+ function assertStoreV2(store) {
621
+ const missing = V2_STORE_METHODS.filter((m) => typeof store[m] !== "function");
622
+ if (!missing.length)
623
+ return;
624
+ throw new Error(`[decidio] this PendingStore is missing ${missing.length} required method${missing.length === 1 ? "" : "s"}: ${missing.join(", ")}.\n` +
625
+ ` 0.2.0 makes the durable execution state machine MANDATORY (parked → claimed_preinvoke → invoking → executed_unreported | indeterminate → reported).\n` +
626
+ ` The 0.1.x contract left claim() optional and fell back to a process-local guard, which cannot survive a restart — so it never carried the single-execution guarantee it appeared to. A store that cannot record that invocation BEGAN cannot prevent a committed action from running twice.\n` +
627
+ ` Fix: implement the missing methods (see the PendingStore docs), or use the shipped FilePendingStore / MemoryPendingStore. Nothing was started.`);
628
+ }
629
+ let reportDirWarned = false;
630
+ function webhookBody(o) {
631
+ return {
632
+ ok: true,
633
+ outcome: {
634
+ status: o.status, decisionId: o.decisionId,
635
+ ...(o.reportRecorded !== undefined ? { reportRecorded: o.reportRecorded } : {}),
636
+ ...(o.executionConfirmed !== undefined ? { executionConfirmed: o.executionConfirmed } : {}),
637
+ ...(o.evidenceTier ? { evidenceTier: o.evidenceTier } : {}),
638
+ ...(o.executionReportVerified !== undefined ? { executionReportVerified: o.executionReportVerified } : {}),
639
+ ...(o.executionReportReason ? { executionReportReason: o.executionReportReason } : {}),
640
+ ...(o.confirmationRetryable !== undefined ? { confirmationRetryable: o.confirmationRetryable } : {}),
641
+ ...(o.reportJournalled !== undefined ? { reportJournalled: o.reportJournalled } : {}),
642
+ ...(o.evidenceError ? { evidenceError: o.evidenceError } : {}),
643
+ ...(o.diagnostic ? { diagnostic: o.diagnostic } : {}),
644
+ },
645
+ };
246
646
  }
247
647
  export function createDecidioResume(opts) {
648
+ assertStoreV2(opts.store);
248
649
  const registry = new Map();
249
- const inFlight = new Set();
250
650
  const secret = opts.webhookSecret ?? opts.apiToken ?? "";
651
+ if (opts.reportDir && !reportDirWarned) {
652
+ reportDirWarned = true;
653
+ console.warn(`[decidio] reportDir is ignored since 0.2.0 — execution evidence now lives in the PendingStore's own execution ledger, beside the state that makes a decision non-replayable. Reports written by 0.1.x under <pendingDir>/reports are migrated into that ledger on first use of a FilePendingStore, so nothing is lost; remove the option when convenient.`);
654
+ }
251
655
  if (opts.allowUnsigned)
252
656
  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) {
657
+ async function reportAfterExecution(pending, decisionId, result, attemptId) {
255
658
  const base = { status: "executed", decisionId, action: pending.action, result };
256
659
  let reported;
257
660
  let haveReported = false;
258
661
  let buildProblem;
662
+ const evidenceMapper = registry.get(pending.action)?.evidence ?? ctrl.evidence;
259
663
  try {
260
- reported = toCanonical((ctrl.evidence ? ctrl.evidence(result) : result) ?? null, "evidence(result)");
664
+ reported = toCanonical((evidenceMapper ? evidenceMapper(result) : result) ?? null, "evidence(result)");
261
665
  haveReported = true;
262
666
  }
263
667
  catch (e) {
@@ -265,8 +669,32 @@ export function createDecidioResume(opts) {
265
669
  reported = null;
266
670
  haveReported = true;
267
671
  }
268
- if (haveReported)
269
- journal.write({ decisionId, action: pending.action, reported, at: new Date().toISOString() });
672
+ let journalled = false;
673
+ if (haveReported) {
674
+ try {
675
+ await ctrl.store.recordExecuted(decisionId, attemptId, reported, signEvidence(decisionId, attemptId, reported, ctrl.agentKey, ctrl.webhookSecret), buildProblem);
676
+ journalled = true;
677
+ }
678
+ catch (e) {
679
+ if (e instanceof DecidioStaleAttemptError) {
680
+ console.warn(`[decidio] ${decisionId}: this action finished, but the decision is now owned by a newer attempt — it was released for retry while this one was still running. This attempt's report is NOT being sent; the owning attempt reports. Your action DID run: if that means it ran twice, the release was premature.`);
681
+ try {
682
+ await ctrl.store.delete(decisionId);
683
+ }
684
+ catch { }
685
+ return { ...base, reportRecorded: false, executionConfirmed: false, confirmationRetryable: false, reportJournalled: false, diagnostic: `${decisionId} was released to a newer attempt while this one was running; this attempt's report was not sent. If your action is not idempotent, note that it has now run more than once.` };
686
+ }
687
+ if (e instanceof DecidioAlreadySettledError) {
688
+ console.warn(`[decidio] ${decisionId}: this action finished, but an operator had already reconciled the decision as committed and Decidio has that report. This attempt's own report is NOT being sent — one decision, one report. If your result differs from what was reconciled, correct it in Decidio; do NOT re-run the action.`);
689
+ try {
690
+ await ctrl.store.delete(decisionId);
691
+ }
692
+ catch { }
693
+ return { ...base, reportRecorded: true, executionConfirmed: false, reportJournalled: true, diagnostic: `an operator settled ${decisionId} as committed while this attempt was running; their report stands and this one was not sent.` };
694
+ }
695
+ console.error(`[decidio] ${decisionId}: the action RAN but its execution report could NOT be recorded (${e?.message ?? e}). If the report also fails to send, this execution is NOT locally recoverable — record the outcome from your own system.`);
696
+ }
697
+ }
270
698
  let confirmRequest;
271
699
  try {
272
700
  confirmRequest = buildResumeConfirmRequest(ctrl, decisionId, reported);
@@ -274,30 +702,45 @@ export function createDecidioResume(opts) {
274
702
  catch {
275
703
  confirmRequest = undefined;
276
704
  }
705
+ const deletePending = async () => {
706
+ try {
707
+ await ctrl.store.delete(decisionId);
708
+ }
709
+ catch (e) {
710
+ console.warn(`[decidio] ${decisionId}: the parked payload could not be removed (${e?.message ?? e}). This is cleanup only — the execution ledger already records this decision as executed, so no process can run it again; remove the row at your convenience.`);
711
+ }
712
+ };
277
713
  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.`;
714
+ await deletePending();
715
+ const diagnostic = `"${pending.action}" EXECUTED, but no execution report could be built (${buildProblem ?? "unknown"}). Nothing will re-run it. ` + (journalled
716
+ ? `The reported value IS recorded, so fix the agent's signing config (DECIDIO_AGENT_KEY/DID) and re-send it with reconfirm("${decisionId}").`
717
+ : `The reported value could not be recorded either, so nothing here can re-send it — record the outcome from your own system.`);
280
718
  console.warn(`[decidio] ${decisionId}: ${diagnostic}`);
281
- return { ...base, reportRecorded: false, executionConfirmed: false, diagnostic };
719
+ return { ...base, reportRecorded: false, executionConfirmed: false, confirmationRetryable: false, reportJournalled: journalled, ...(buildProblem ? { evidenceError: buildProblem } : {}), diagnostic };
282
720
  }
283
- await ctrl.store.delete(decisionId).catch(() => { });
721
+ await deletePending();
284
722
  let ev;
285
723
  try {
286
- ev = await postConfirm(ctrl, decisionId, confirmRequest);
724
+ ev = await postConfirm(ctrl, decisionId, confirmRequest, journalled);
287
725
  }
288
726
  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}").` };
727
+ ev = {
728
+ reportRecorded: false, executionConfirmed: false,
729
+ diagnostic: journalled
730
+ ? `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}").`
731
+ : `the execution report did not reach Decidio (${e?.message ?? e}) AND could not be journalled locally, so nothing here can re-send it. Your action DID run - record the outcome from your own system.`,
732
+ };
290
733
  }
291
734
  if (ev.reportRecorded)
292
- journal.clear(decisionId);
735
+ await ctrl.store.markReported(decisionId, attemptId, ev.evidenceTier ?? "recorded").catch(() => { });
293
736
  const diagnostic = buildProblem
294
737
  ? `${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
738
  : ev.diagnostic;
296
739
  if (!ev.reportRecorded)
297
740
  console.warn(`[decidio] ${decisionId}: ${diagnostic}`);
298
- else if (!ev.executionConfirmed)
741
+ else if (!ev.executionConfirmed || ev.executionReportVerified === false)
299
742
  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 } : {}) };
743
+ return { ...base, reportRecorded: ev.reportRecorded, executionConfirmed: ev.executionConfirmed, ...(ev.evidenceTier ? { evidenceTier: ev.evidenceTier } : {}), ...(ev.executionReportVerified !== undefined ? { executionReportVerified: ev.executionReportVerified } : {}), ...(ev.executionReportReason ? { executionReportReason: ev.executionReportReason } : {}), ...(ev.confirmationRetryable !== undefined ? { confirmationRetryable: ev.confirmationRetryable } : {}), reportJournalled: journalled, ...(buildProblem ? { evidenceError: buildProblem } : {}), ...(diagnostic ? { diagnostic } : {}) };
301
744
  }
302
745
  const refusalWarned = new Set();
303
746
  const refuseOnce = (decisionId, msg) => {
@@ -314,11 +757,13 @@ export function createDecidioResume(opts) {
314
757
  workspaceId: opts.workspaceId ?? process.env.DECIDIO_WORKSPACE_ID,
315
758
  webhookSecret: secret,
316
759
  store: opts.store,
317
- register(action, fn) {
760
+ register(action, fn, evidence, withContext) {
318
761
  const prev = registry.get(action);
319
- if (prev && prev !== fn)
762
+ if (prev && prev.fn !== fn)
320
763
  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.`);
321
- registry.set(action, fn);
764
+ if (prev && prev.evidence && evidence && prev.evidence !== evidence)
765
+ console.warn(`[decidio] action "${action}" is being registered again with a DIFFERENT evidence mapper — the latest one wins, and a resumed decision's record would carry ITS output. Give each protected function its own action name.`);
766
+ registry.set(action, { fn, evidence: evidence ?? prev?.evidence, withContext: withContext ?? prev?.withContext });
322
767
  },
323
768
  async park(p) {
324
769
  try {
@@ -368,7 +813,7 @@ export function createDecidioResume(opts) {
368
813
  }
369
814
  if (integrity === "unbound")
370
815
  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.`);
371
- const fn = registry.get(pending.action);
816
+ const fn = registry.get(pending.action)?.fn;
372
817
  if (!fn) {
373
818
  console.error(`[decidio] cannot resume ${decisionId}: no function is registered for action "${pending.action}" in THIS process.`);
374
819
  console.error(` A worker only re-executes actions registered where it runs. At startup, either declare the action on the wrapper:`);
@@ -378,21 +823,37 @@ export function createDecidioResume(opts) {
378
823
  console.error(` then start the worker. The approved action stays parked until then — nothing is lost.`);
379
824
  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` });
380
825
  }
381
- const claimed = ctrl.store.claim ? await ctrl.store.claim(decisionId) : (!inFlight.has(decisionId) && (inFlight.add(decisionId), true));
382
- if (!claimed)
826
+ const attemptId = randomUUID();
827
+ const claim = await ctrl.store.claim(decisionId, attemptId);
828
+ if (!claim.ok) {
829
+ if (claim.state && NON_REPLAYABLE.includes(claim.state)) {
830
+ refuseOnce(decisionId, `[decidio] REFUSING to execute ${decisionId}: its durable state is ${claim.state} — invocation already began, so re-running it could duplicate a committed effect. Inspect it with guard.resume.recover(); settle it with guard.resume.reconcile("${decisionId}", …).`);
831
+ return finish({ status: claim.state === "indeterminate" ? "indeterminate" : "unknown", decisionId, action: pending.action, reason: claim.reason });
832
+ }
383
833
  return { status: "unknown", decisionId };
834
+ }
384
835
  try {
385
- const result = await fn(...pending.args);
386
- return finish(await reportAfterExecution(pending, decisionId, result));
836
+ await ctrl.store.markInvoking(decisionId, attemptId);
387
837
  }
388
838
  catch (e) {
389
- if (ctrl.store.release)
390
- await ctrl.store.release(decisionId).catch(() => { });
391
- throw e;
839
+ await ctrl.store.releasePreinvoke(decisionId, attemptId).catch(() => { });
840
+ console.error(`[decidio] ${decisionId}: could not record the invocation boundary (${e?.message ?? e}) — refusing to run the action rather than run it unrecorded.`);
841
+ return finish({ status: "no_handler", decisionId, action: pending.action, reason: "could not durably record the invocation boundary" });
392
842
  }
393
- finally {
394
- inFlight.delete(decisionId);
843
+ let result;
844
+ try {
845
+ const entry = registry.get(pending.action);
846
+ result = entry?.withContext
847
+ ? await fn({ decisionId, attemptId, idempotencyKey: decisionId }, ...pending.args)
848
+ : await fn(...pending.args);
849
+ }
850
+ catch (e) {
851
+ const why = String(e?.message ?? e);
852
+ await ctrl.store.markIndeterminate(decisionId, attemptId, why).catch((se) => console.error(`[decidio] ${decisionId}: could not record INDETERMINATE (${se?.message ?? se}) — treat this decision as possibly executed.`));
853
+ console.error(`[decidio] ${decisionId}: "${pending.action}" was INVOKED and then threw (${why}). Whether the downstream effect committed is not knowable from here, so it will NOT be retried automatically. Check the downstream system, then: guard.resume.reconcile("${decisionId}", { outcome: "committed", evidence: … }) or { outcome: "not_committed", reason: … }.`);
854
+ return finish({ status: "indeterminate", decisionId, action: pending.action, reason: why });
395
855
  }
856
+ return finish(await reportAfterExecution(pending, decisionId, result, attemptId));
396
857
  },
397
858
  webhookHandler() {
398
859
  return (req, res) => {
@@ -419,7 +880,7 @@ export function createDecidioResume(opts) {
419
880
  const outcome = await ctrl.resolve({ decisionId: String(body.decisionId), verdict, reason: body.reason });
420
881
  res.statusCode = 200;
421
882
  res.setHeader("content-type", "application/json");
422
- res.end(JSON.stringify({ ok: true, outcome: { status: outcome.status, decisionId: outcome.decisionId } }));
883
+ res.end(JSON.stringify(webhookBody(outcome)));
423
884
  }
424
885
  catch (e) {
425
886
  res.statusCode = 500;
@@ -443,52 +904,151 @@ export function createDecidioResume(opts) {
443
904
  try {
444
905
  const body = JSON.parse(raw || "{}");
445
906
  const outcome = await ctrl.resolve({ decisionId: String(body.decisionId), verdict: body.verdict, reason: body.reason });
446
- return new Response(JSON.stringify({ ok: true, outcome: { status: outcome.status, decisionId: outcome.decisionId } }), { status: 200, headers: { "content-type": "application/json" } });
907
+ return new Response(JSON.stringify(webhookBody(outcome)), { status: 200, headers: { "content-type": "application/json" } });
447
908
  }
448
909
  catch (e) {
449
910
  return new Response(JSON.stringify({ error: e?.message ?? "resume failed" }), { status: 500 });
450
911
  }
451
912
  };
452
913
  },
453
- async reconfirm(decisionId) {
454
- const entry = journal.read(decisionId);
914
+ async reconcile(decisionId, opts) {
915
+ let entry;
916
+ try {
917
+ entry = await ctrl.store.getExecution(decisionId);
918
+ }
919
+ catch (e) {
920
+ return { reportRecorded: false, executionConfirmed: false, diagnostic: `the durable state for ${decisionId} could not be read (${e?.message ?? e}). Refusing to settle a decision whose state is unknown — inspect the store first.` };
921
+ }
455
922
  if (!entry) {
923
+ return { reportRecorded: false, executionConfirmed: false, diagnostic: `nothing is recorded for ${decisionId} in this store, so there is nothing to reconcile. Check the decision id, or the store this worker is configured with.` };
924
+ }
925
+ let settleEvidence;
926
+ try {
927
+ settleEvidence = opts.outcome === "committed" ? toCanonical(opts.evidence ?? null, "reconcile(evidence)") : null;
928
+ }
929
+ catch (e) {
930
+ return { reportRecorded: false, executionConfirmed: false, diagnostic: `the evidence you passed to reconcile("${decisionId}") is not reportable as-is (${e?.message ?? e}). Nothing was settled. Pass plain JSON — the same rule your action's return value follows.` };
931
+ }
932
+ const settleSig = opts.outcome === "committed"
933
+ ? signEvidence(decisionId, entry.attemptId ?? null, settleEvidence, ctrl.agentKey, ctrl.webhookSecret)
934
+ : undefined;
935
+ try {
936
+ await ctrl.store.settle(decisionId, opts.outcome, { evidence: settleEvidence, reason: opts.reason, evidenceSig: settleSig });
937
+ }
938
+ catch (e) {
939
+ return { reportRecorded: false, executionConfirmed: false, diagnostic: `${e?.message ?? e}` };
940
+ }
941
+ if (opts.outcome !== "committed") {
942
+ console.warn(`[decidio] ${decisionId}: released for one more attempt on your assertion that nothing committed${opts.reason ? ` (${opts.reason})` : ""}. The next resume signal will run it.`);
943
+ return { reportRecorded: false, executionConfirmed: false, diagnostic: `${decisionId} is parked again and may run once more. Nothing was reported, because on your account nothing executed.` };
944
+ }
945
+ let request;
946
+ try {
947
+ request = buildResumeConfirmRequest(ctrl, decisionId, settleEvidence);
948
+ }
949
+ catch (e) {
950
+ return { reportRecorded: false, executionConfirmed: false, diagnostic: `the reconciled report for ${decisionId} could not be signed (${e?.message ?? e}). The evidence IS recorded locally — fix the agent's signing config (DECIDIO_AGENT_KEY/DID) and re-send it with reconfirm("${decisionId}").` };
951
+ }
952
+ let ev;
953
+ try {
954
+ ev = await postConfirm(ctrl, decisionId, request);
955
+ }
956
+ catch (e) {
957
+ return { reportRecorded: false, executionConfirmed: false, diagnostic: `the reconciled report for ${decisionId} did not reach Decidio (${e?.message ?? e}). The evidence IS recorded locally — call reconfirm("${decisionId}") when the service is healthy. Do NOT re-run the action.` };
958
+ }
959
+ if (ev.reportRecorded)
960
+ await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconcile", ev.evidenceTier ?? "recorded").catch(() => { });
961
+ return {
962
+ ...ev,
963
+ diagnostic: `${ev.diagnostic ? `${ev.diagnostic} ` : ""}This report was RECONCILED BY AN OPERATOR${opts.reason ? ` (${opts.reason})` : ""}, not observed by the SDK: its tier reflects the evidence you supplied, not a return value this process saw.`,
964
+ };
965
+ },
966
+ async reconfirm(decisionId, opts) {
967
+ let entry;
968
+ try {
969
+ entry = await ctrl.store.getExecution(decisionId);
970
+ }
971
+ catch (e) {
972
+ return { reportRecorded: false, executionConfirmed: false, diagnostic: `the durable state for ${decisionId} could not be read (${e?.message ?? e}). Refusing to guess whether it executed — inspect the store; do NOT re-run the action to find out.` };
973
+ }
974
+ if (entry?.state === "reported") {
975
+ return { reportRecorded: true, executionConfirmed: false, evidenceTier: entry.confirmation, diagnostic: `${decisionId} was already reported (${entry.confirmation ?? "recorded"}) — nothing to re-send.` };
976
+ }
977
+ if (entry?.state === "indeterminate") {
978
+ return { reportRecorded: false, executionConfirmed: false, diagnostic: `${decisionId} is INDETERMINATE: it was invoked and its outcome is unknown, so there is no captured result to re-send. Settle it with reconcile("${decisionId}", …) once you have checked the downstream system.` };
979
+ }
980
+ if (!entry || !entry.hasEvidence) {
456
981
  return {
457
982
  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.`,
983
+ diagnostic: `nothing is recorded 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
984
  };
460
985
  }
986
+ const evStrict = !!ctrl.agentKey;
987
+ const evStatus = verifyEvidence(entry, ctrl.agentKey, ctrl.webhookSecret);
988
+ if (evStatus === "mismatch") {
989
+ return { reportRecorded: false, executionConfirmed: false, diagnostic: `the recorded evidence for ${decisionId} does not match its integrity binding — it was altered after this agent wrote it. REFUSING to attest it. Inspect the pending store; if you know the true outcome, settle it with reconcile("${decisionId}", …).` };
990
+ }
991
+ if (evStatus !== "ok" && evStrict && !opts?.acceptUnbound) {
992
+ const why = entry.migratedFrom
993
+ ? `it was migrated from ${entry.migratedFrom}, which predates evidence binding — that is expected, and it is why this call has an override`
994
+ : `this agent signs its evidence, so a record without a usable binding did not come from this agent`;
995
+ return { reportRecorded: false, executionConfirmed: false, diagnostic: `the recorded evidence for ${decisionId} carries ${evStatus === "unbound" ? "NO integrity binding" : "a binding this agent's key cannot verify"} (${why}). REFUSING to attest it by default. If you have checked what this row claims and want it sent anyway: reconfirm("${decisionId}", { acceptUnbound: true }). If you do not trust it, settle the decision instead: reconcile("${decisionId}", …).` };
996
+ }
997
+ if (evStatus !== "ok" && evStrict)
998
+ console.warn(`[decidio] ${decisionId}: attesting evidence with ${evStatus === "unbound" ? "NO integrity binding" : "an unverifiable binding"} because acceptUnbound was passed${entry.migratedFrom ? ` (row migrated from ${entry.migratedFrom})` : ""}. You are vouching for this row; the agent's signature will say it came from here.`);
999
+ if (evStatus !== "ok")
1000
+ console.warn(`[decidio] ${decisionId}: re-sending an execution report whose evidence is ${evStatus === "unbound" ? "UNBOUND (written without agent key material, so it cannot be integrity-checked)" : "UNVERIFIABLE with this agent's current key material"}${entry.migratedFrom ? ` — it was migrated from ${entry.migratedFrom}, which predates evidence binding` : ""}. Run \`npx @decidio/sdk init\` to enable binding.`);
461
1001
  let request;
462
1002
  try {
463
- request = buildResumeConfirmRequest(ctrl, decisionId, entry.reported);
1003
+ request = buildResumeConfirmRequest(ctrl, decisionId, entry.evidence);
464
1004
  }
465
1005
  catch (e) {
466
1006
  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
1007
  }
468
- const ev = await postConfirm(ctrl, decisionId, request);
1008
+ let ev;
1009
+ try {
1010
+ ev = await postConfirm(ctrl, decisionId, request);
1011
+ }
1012
+ catch (e) {
1013
+ return { reportRecorded: false, executionConfirmed: false, diagnostic: `the execution report for ${decisionId} still did not reach Decidio (${e?.message ?? e}). The recorded evidence is KEPT — call reconfirm("${decisionId}") again when the service is healthy. Your action already ran; do NOT re-run it.` };
1014
+ }
469
1015
  if (ev.reportRecorded)
470
- journal.clear(decisionId);
1016
+ await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconfirm", ev.evidenceTier ?? "recorded").catch(() => { });
471
1017
  return ev;
472
1018
  },
473
1019
  async recover() {
474
1020
  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.` });
1021
+ const execs = await ctrl.store.listExecutions().catch((e) => {
1022
+ console.error(`[decidio] recover(): the execution ledger could not be read (${e?.message ?? e}). Nothing is reported rather than reporting a falsely empty recovery list.`);
1023
+ return null;
1024
+ });
1025
+ if (execs === null)
1026
+ return items;
1027
+ for (const r of execs) {
1028
+ const a = r.action ?? "(unknown action)";
1029
+ switch (r.state) {
1030
+ case "executed_unreported":
1031
+ items.push({ decisionId: r.decisionId, action: r.action, state: "executed_unreported",
1032
+ guidance: `"${a}" executed at ${r.at} and its execution report has not been recorded. Re-send it safely: guard.resume.reconfirm("${r.decisionId}") — this replays the saved report and cannot re-run the action.` });
1033
+ break;
1034
+ case "indeterminate":
1035
+ items.push({ decisionId: r.decisionId, action: r.action, state: "indeterminate",
1036
+ guidance: `"${a}" was INVOKED and its outcome is unknown${r.error ? ` (${r.error})` : ""}. It will NOT be retried automatically. Check the downstream system, then settle it: guard.resume.reconcile("${r.decisionId}", { outcome: "committed", evidence: … }) or { outcome: "not_committed", reason: … }.` });
1037
+ break;
1038
+ case "invoking":
1039
+ items.push({ decisionId: r.decisionId, action: r.action, state: "indeterminate",
1040
+ guidance: `"${a}" was invoked at ${r.at} and this process died before recording the outcome, so whether it ran is unknown locally. It will NOT be retried automatically — reconcile it as above.` });
1041
+ break;
1042
+ case "claimed_preinvoke":
1043
+ items.push({ decisionId: r.decisionId, action: r.action, state: "claimed_stalled",
1044
+ guidance: `"${a}" was claimed at ${r.at} by attempt ${r.attemptId ?? "(unknown)"} which never reached the invocation boundary — the handler provably did not run, and nothing will retry it on its own. Release it: guard.resume.reconcile("${r.decisionId}", { outcome: "not_committed", reason: "claimed but never invoked" }).` });
1045
+ break;
1046
+ case "parked":
1047
+ items.push({ decisionId: r.decisionId, action: r.action, state: "parked", guidance: `"${a}" is parked awaiting a human decision. Nothing has executed.` });
1048
+ break;
1049
+ case "reported":
1050
+ break;
1051
+ }
492
1052
  }
493
1053
  return items;
494
1054
  },