@decidio/sdk 0.1.12 → 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,5 +1,5 @@
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";
@@ -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 };
393
+ }
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;
123
403
  }
124
- async listClaimed() {
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);
@@ -202,64 +612,49 @@ 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 (e) {
214
- console.error(`[decidio] could not create the execution-report journal directory ${dir} (${e?.message ?? e}) — reports will NOT be locally recoverable until this is fixed. Point it somewhere writable with reportDir (or DECIDIO_PENDING_DIR).`);
215
- }
216
- }
217
- p(id) { return join(this.dir, `${id.replace(/[^\w.-]/g, "_")}.json`); }
218
- write(e) {
219
- try {
220
- writeFileSync(this.p(e.decisionId), JSON.stringify(e, null, 2), { mode: 0o600 });
221
- return true;
222
- }
223
- catch (err) {
224
- console.error(`[decidio] ${e.decisionId}: the action RAN but its execution report could NOT be journalled to ${this.dir} (${err?.message ?? err}). If the report also fails to send, this execution is NOT locally recoverable — record the outcome from your own system. Fix the journal location with reportDir (or DECIDIO_PENDING_DIR).`);
225
- return false;
226
- }
227
- }
228
- read(id) { try {
229
- return existsSync(this.p(id)) ? JSON.parse(readFileSync(this.p(id), "utf8")) : null;
230
- }
231
- catch {
232
- return null;
233
- } }
234
- clear(id) { try {
235
- if (existsSync(this.p(id)))
236
- rmSync(this.p(id));
237
- }
238
- catch { } }
239
- list() {
240
- try {
241
- if (!existsSync(this.dir))
242
- return [];
243
- return readdirSync(this.dir).filter((f) => f.endsWith(".json")).map((f) => { try {
244
- return JSON.parse(readFileSync(join(this.dir, f), "utf8"));
245
- }
246
- catch {
247
- return null;
248
- } }).filter(Boolean);
249
- }
250
- catch {
251
- return [];
252
- }
253
- }
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
+ };
254
646
  }
255
647
  export function createDecidioResume(opts) {
648
+ assertStoreV2(opts.store);
256
649
  const registry = new Map();
257
- const inFlight = new Set();
258
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
+ }
259
655
  if (opts.allowUnsigned)
260
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.");
261
- const journal = new ReportJournal(opts.reportDir ?? join(process.env.DECIDIO_PENDING_DIR ?? ".decidio-pending", "reports"));
262
- async function reportAfterExecution(pending, decisionId, result) {
657
+ async function reportAfterExecution(pending, decisionId, result, attemptId) {
263
658
  const base = { status: "executed", decisionId, action: pending.action, result };
264
659
  let reported;
265
660
  let haveReported = false;
@@ -274,7 +669,32 @@ export function createDecidioResume(opts) {
274
669
  reported = null;
275
670
  haveReported = true;
276
671
  }
277
- const journalled = haveReported && 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
+ }
278
698
  let confirmRequest;
279
699
  try {
280
700
  confirmRequest = buildResumeConfirmRequest(ctrl, decisionId, reported);
@@ -287,14 +707,16 @@ export function createDecidioResume(opts) {
287
707
  await ctrl.store.delete(decisionId);
288
708
  }
289
709
  catch (e) {
290
- console.error(`[decidio] ${decisionId}: the action RAN but its pending entry could NOT be deleted (${e?.message ?? e}) — the entry SURVIVED. Remove it from the pending store by hand before another process or a restart can see it, or the committed action may be executed again.`);
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.`);
291
711
  }
292
712
  };
293
713
  if (!confirmRequest) {
294
714
  await deletePending();
295
- 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.`;
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.`);
296
718
  console.warn(`[decidio] ${decisionId}: ${diagnostic}`);
297
- return { ...base, reportRecorded: false, executionConfirmed: false, diagnostic };
719
+ return { ...base, reportRecorded: false, executionConfirmed: false, confirmationRetryable: false, reportJournalled: journalled, ...(buildProblem ? { evidenceError: buildProblem } : {}), diagnostic };
298
720
  }
299
721
  await deletePending();
300
722
  let ev;
@@ -310,7 +732,7 @@ export function createDecidioResume(opts) {
310
732
  };
311
733
  }
312
734
  if (ev.reportRecorded)
313
- journal.clear(decisionId);
735
+ await ctrl.store.markReported(decisionId, attemptId, ev.evidenceTier ?? "recorded").catch(() => { });
314
736
  const diagnostic = buildProblem
315
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.`
316
738
  : ev.diagnostic;
@@ -318,7 +740,7 @@ export function createDecidioResume(opts) {
318
740
  console.warn(`[decidio] ${decisionId}: ${diagnostic}`);
319
741
  else if (!ev.executionConfirmed || ev.executionReportVerified === false)
320
742
  console.warn(`[decidio] execution evidence for ${decisionId} recorded as ${ev.evidenceTier} - ${diagnostic}`);
321
- return { ...base, reportRecorded: ev.reportRecorded, executionConfirmed: ev.executionConfirmed, ...(ev.evidenceTier ? { evidenceTier: ev.evidenceTier } : {}), ...(ev.executionReportVerified !== undefined ? { executionReportVerified: ev.executionReportVerified } : {}), ...(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 } : {}) };
322
744
  }
323
745
  const refusalWarned = new Set();
324
746
  const refuseOnce = (decisionId, msg) => {
@@ -335,13 +757,13 @@ export function createDecidioResume(opts) {
335
757
  workspaceId: opts.workspaceId ?? process.env.DECIDIO_WORKSPACE_ID,
336
758
  webhookSecret: secret,
337
759
  store: opts.store,
338
- register(action, fn, evidence) {
760
+ register(action, fn, evidence, withContext) {
339
761
  const prev = registry.get(action);
340
762
  if (prev && prev.fn !== fn)
341
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.`);
342
764
  if (prev && prev.evidence && evidence && prev.evidence !== evidence)
343
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.`);
344
- registry.set(action, { fn, evidence: evidence ?? prev?.evidence });
766
+ registry.set(action, { fn, evidence: evidence ?? prev?.evidence, withContext: withContext ?? prev?.withContext });
345
767
  },
346
768
  async park(p) {
347
769
  try {
@@ -401,19 +823,37 @@ export function createDecidioResume(opts) {
401
823
  console.error(` then start the worker. The approved action stays parked until then — nothing is lost.`);
402
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` });
403
825
  }
404
- const claimed = ctrl.store.claim ? await ctrl.store.claim(decisionId) : (!inFlight.has(decisionId) && (inFlight.add(decisionId), true));
405
- 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
+ }
406
833
  return { status: "unknown", decisionId };
834
+ }
835
+ try {
836
+ await ctrl.store.markInvoking(decisionId, attemptId);
837
+ }
838
+ catch (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" });
842
+ }
843
+ let result;
407
844
  try {
408
- const result = await fn(...pending.args);
409
- return finish(await reportAfterExecution(pending, decisionId, result));
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);
410
849
  }
411
850
  catch (e) {
412
- inFlight.delete(decisionId);
413
- if (ctrl.store.release)
414
- await ctrl.store.release(decisionId).catch(() => { });
415
- throw 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 });
416
855
  }
856
+ return finish(await reportAfterExecution(pending, decisionId, result, attemptId));
417
857
  },
418
858
  webhookHandler() {
419
859
  return (req, res) => {
@@ -440,7 +880,7 @@ export function createDecidioResume(opts) {
440
880
  const outcome = await ctrl.resolve({ decisionId: String(body.decisionId), verdict, reason: body.reason });
441
881
  res.statusCode = 200;
442
882
  res.setHeader("content-type", "application/json");
443
- res.end(JSON.stringify({ ok: true, outcome: { status: outcome.status, decisionId: outcome.decisionId } }));
883
+ res.end(JSON.stringify(webhookBody(outcome)));
444
884
  }
445
885
  catch (e) {
446
886
  res.statusCode = 500;
@@ -464,52 +904,151 @@ export function createDecidioResume(opts) {
464
904
  try {
465
905
  const body = JSON.parse(raw || "{}");
466
906
  const outcome = await ctrl.resolve({ decisionId: String(body.decisionId), verdict: body.verdict, reason: body.reason });
467
- 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" } });
468
908
  }
469
909
  catch (e) {
470
910
  return new Response(JSON.stringify({ error: e?.message ?? "resume failed" }), { status: 500 });
471
911
  }
472
912
  };
473
913
  },
474
- async reconfirm(decisionId) {
475
- 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
+ }
476
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) {
477
981
  return {
478
982
  reportRecorded: false, executionConfirmed: false,
479
- 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.`,
480
984
  };
481
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.`);
482
1001
  let request;
483
1002
  try {
484
- request = buildResumeConfirmRequest(ctrl, decisionId, entry.reported);
1003
+ request = buildResumeConfirmRequest(ctrl, decisionId, entry.evidence);
485
1004
  }
486
1005
  catch (e) {
487
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.` };
488
1007
  }
489
- 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
+ }
490
1015
  if (ev.reportRecorded)
491
- journal.clear(decisionId);
1016
+ await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconfirm", ev.evidenceTier ?? "recorded").catch(() => { });
492
1017
  return ev;
493
1018
  },
494
1019
  async recover() {
495
1020
  const items = [];
496
- for (const e of journal.list()) {
497
- items.push({
498
- decisionId: e.decisionId, action: e.action, state: "executed_unreported",
499
- 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.`,
500
- });
501
- }
502
- const claimed = ctrl.store.listClaimed ? await ctrl.store.listClaimed().catch(() => []) : [];
503
- for (const p of claimed) {
504
- if (journal.read(p.decisionId))
505
- continue;
506
- items.push({
507
- decisionId: p.decisionId, action: p.action, state: "indeterminate",
508
- 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.`,
509
- });
510
- }
511
- for (const p of await ctrl.store.list().catch(() => [])) {
512
- 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
+ }
513
1052
  }
514
1053
  return items;
515
1054
  },