@decidio/sdk 0.2.2 → 0.3.3

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
@@ -5,8 +5,8 @@ import { userInfo } from "node:os";
5
5
  import { execFileSync } from "node:child_process";
6
6
  import { signConfirm, canonicalize as jcs, assertSafeTransport, carriesCredential } from "./signing.js";
7
7
  import { canonicalize as toCanonical, DecidioUnsafeArgumentsError } from "./canonical.js";
8
- import { readConfirmResponse, buildConfirmBody } from "./evidence.js";
9
- import { DecidioHttpError } from "./errors.js";
8
+ import { readConfirmResponse, buildConfirmBody, tierConfirms, tombstoneTier } from "./evidence.js";
9
+ import { DecidioHttpError, decisionIdProblem } from "./errors.js";
10
10
  const argsBindingBytes = (p) => Buffer.from(jcs({ decisionId: p.decisionId, action: p.action, amount: p.amount, ...(p.scope !== undefined ? { scope: p.scope } : {}), args: p.args }), "utf8");
11
11
  function signArgs(p, agentKey, webhookSecret) {
12
12
  const bytes = argsBindingBytes(p);
@@ -78,9 +78,9 @@ export function verifyEvidence(rec, agentKey, webhookSecret) {
78
78
  }
79
79
  }
80
80
  export const EXECUTION_STATES = [
81
- "parked", "claimed_preinvoke", "invoking", "executed_unreported", "indeterminate", "reported",
81
+ "parked", "claimed_preinvoke", "discarded", "invoking", "executed_unreported", "indeterminate", "reported",
82
82
  ];
83
- export const NON_REPLAYABLE = ["invoking", "executed_unreported", "indeterminate", "reported"];
83
+ export const NON_REPLAYABLE = ["discarded", "invoking", "executed_unreported", "indeterminate", "reported"];
84
84
  export class DecidioStaleAttemptError extends Error {
85
85
  decisionId;
86
86
  owner;
@@ -101,16 +101,47 @@ export class DecidioAlreadySettledError extends Error {
101
101
  this.name = "DecidioAlreadySettledError";
102
102
  }
103
103
  }
104
+ export class DecidioRecoveryUnavailableError extends Error {
105
+ cause;
106
+ constructor(cause) {
107
+ super(`[decidio] recover(): the execution ledger could not be read (${cause?.message ?? cause}). Refusing to return an empty recovery list, which would read as "nothing is outstanding" — inspect the store; some actions may be awaiting a report.`);
108
+ this.cause = cause;
109
+ this.name = "DecidioRecoveryUnavailableError";
110
+ }
111
+ }
112
+ export class DecidioDeniedError extends Error {
113
+ decisionId;
114
+ verdict;
115
+ constructor(decisionId, verdict) {
116
+ super(`[decidio] ${decisionId}: this decision was DENIED (${verdict}) and a denial is terminal — refusing a state write that would move it back toward execution. Nothing may advance out of a denial; if this action should now proceed it needs a NEW request and a new approval.`);
117
+ this.decisionId = decisionId;
118
+ this.verdict = verdict;
119
+ this.name = "DecidioDeniedError";
120
+ }
121
+ }
122
+ function assertNotDenied(id, cur, next) {
123
+ if (cur.state === "discarded" && next !== "discarded")
124
+ throw new DecidioDeniedError(id, cur.verdict ?? "rejected");
125
+ }
126
+ function assertClaimedBy(id, cur, attemptId) {
127
+ if (cur.state !== "claimed_preinvoke" || cur.attemptId !== attemptId) {
128
+ throw new Error(`[decidio] ${id}: refusing to cross the invocation boundary — attempt ${attemptId} does not hold a claim on this decision (state ${cur.state}, held by ${cur.attemptId || "nobody"}). A claim is the only way to acquire ownership; call claim() and act on its result.`);
129
+ }
130
+ }
104
131
  function assertNotSettled(id, cur, next) {
105
132
  if (cur.reconciled?.outcome === "committed" && (next === "executed_unreported" || next === "indeterminate")) {
106
133
  throw new DecidioAlreadySettledError(id);
107
134
  }
108
135
  }
109
136
  export const SETTLE_OUTCOMES = ["committed", "not_committed"];
110
- export function assertSettlable(id, state, outcome, hasObservedEvidence = true) {
137
+ export function assertSettlable(id, state, outcome, opts = {}) {
138
+ const hasObservedEvidence = opts.hasObservedEvidence ?? true;
111
139
  if (!SETTLE_OUTCOMES.includes(outcome)) {
112
140
  throw new Error(`[decidio] ${id}: outcome must be "committed" or "not_committed", not ${JSON.stringify(outcome)}.`);
113
141
  }
142
+ if (state === "discarded") {
143
+ throw new Error(`[decidio] ${id}: this decision was DENIED by a human or a policy, and a denial is final — it cannot be reconciled, released or retried. Nothing ran, so there is no outcome to record. If this action should now proceed it needs a NEW request and a new approval.`);
144
+ }
114
145
  if (outcome === "committed") {
115
146
  if (state === "reported")
116
147
  throw new Error(`[decidio] ${id}: already reported — there is nothing left to reconcile.`);
@@ -126,6 +157,9 @@ export function assertSettlable(id, state, outcome, hasObservedEvidence = true)
126
157
  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
158
  if (state === "parked")
128
159
  throw new Error(`[decidio] ${id}: already parked — nothing to release.`);
160
+ if (state === "invoking" && opts.workerStopped !== true) {
161
+ throw new Error(`[decidio] ${id}: this decision is still INVOKING — the worker that claimed it may be running the action right now, and releasing it would let a second worker run the same approved action. Stop or confirm the death of that worker first, then say so explicitly: reconcile("${id}", { outcome: "not_committed", reason: …, workerStopped: true }). If you cannot confirm it stopped, treat the decision as possibly executed and reconcile it as "committed" once you have checked the downstream system.`);
162
+ }
129
163
  }
130
164
  }
131
165
  const rank = (s) => EXECUTION_STATES.indexOf(s);
@@ -135,11 +169,12 @@ export class MemoryPendingStore {
135
169
  rec(id) {
136
170
  return this.x.get(id) ?? { decisionId: id, state: "parked", attemptId: null, at: new Date().toISOString() };
137
171
  }
138
- advance(id, attemptId, state, patch = {}) {
172
+ advance(id, attemptId, state, patch = {}, takeover = false) {
139
173
  const cur = this.rec(id);
140
- if (cur.attemptId && cur.attemptId !== attemptId && rank(cur.state) >= rank("invoking")) {
174
+ if (!takeover && cur.attemptId && cur.attemptId !== attemptId) {
141
175
  throw new DecidioStaleAttemptError(id, cur.attemptId, cur.state);
142
176
  }
177
+ assertNotDenied(id, cur, state);
143
178
  assertNotSettled(id, cur, state);
144
179
  if (rank(state) < rank(cur.state)) {
145
180
  throw new Error(`[decidio] ${id}: refusing to move ${cur.state} → ${state}; execution state only ever advances.`);
@@ -152,7 +187,15 @@ export class MemoryPendingStore {
152
187
  this.x.set(p.decisionId, { decisionId: p.decisionId, state: "parked", attemptId: null, action: p.action, at: new Date().toISOString() });
153
188
  }
154
189
  async get(id) { return this.m.get(id) ?? null; }
155
- async delete(id) { this.m.delete(id); }
190
+ async delete(id, attemptId) {
191
+ if (attemptId && !this.ownsPayload(id, attemptId))
192
+ return;
193
+ this.m.delete(id);
194
+ }
195
+ ownsPayload(id, attemptId) {
196
+ const owner = this.x.get(id)?.attemptId;
197
+ return !owner || owner === attemptId;
198
+ }
156
199
  async list() { return [...this.m.values()].filter((p) => this.rec(p.decisionId).state === "parked"); }
157
200
  async claim(id, attemptId) {
158
201
  if (!this.m.has(id))
@@ -162,59 +205,87 @@ export class MemoryPendingStore {
162
205
  return { ok: false, state: cur.state, reason: `already ${cur.state}` };
163
206
  if (cur.state === "claimed_preinvoke")
164
207
  return { ok: false, state: cur.state, reason: "claimed by another attempt" };
165
- this.advance(id, attemptId, "claimed_preinvoke", { action: this.m.get(id)?.action });
208
+ this.advance(id, attemptId, "claimed_preinvoke", { action: this.m.get(id)?.action }, true);
166
209
  return { ok: true };
167
210
  }
168
- async markInvoking(id, attemptId) { this.advance(id, attemptId, "invoking"); }
211
+ async markInvoking(id, attemptId) {
212
+ const cur = this.rec(id);
213
+ assertNotDenied(id, cur, "invoking");
214
+ assertClaimedBy(id, cur, attemptId);
215
+ this.advance(id, attemptId, "invoking");
216
+ }
169
217
  async recordExecuted(id, attemptId, evidence, evidenceSig, evidenceError) { this.advance(id, attemptId, "executed_unreported", { evidence, hasEvidence: true, evidenceSig, evidenceError }); }
170
218
  async markIndeterminate(id, attemptId, error) { this.advance(id, attemptId, "indeterminate", { error }); }
171
219
  async markReported(id, attemptId, confirmation) { this.advance(id, attemptId, "reported", { confirmation }); }
220
+ async markDiscarded(id, verdict, reason) { this.advance(id, this.x.get(id)?.attemptId ?? "", "discarded", { verdict, ...(reason ? { discardReason: reason } : {}) }); }
172
221
  async getExecution(id) { return this.x.get(id) ?? null; }
173
222
  async listExecutions() { return [...this.x.values()]; }
174
223
  async settle(id, outcome, opts = {}) {
175
224
  const cur = this.rec(id);
176
- assertSettlable(id, cur.state, outcome, !cur.evidenceError);
225
+ assertSettlable(id, cur.state, outcome, { hasObservedEvidence: !cur.evidenceError, workerStopped: opts.workerStopped });
177
226
  const at = new Date().toISOString();
178
- const reconciled = { outcome, reason: opts.reason, at };
227
+ const reconciled = { outcome, reason: opts.reason, at, ...(opts.workerStopped ? { workerStopped: true } : {}) };
179
228
  this.x.set(id, outcome === "committed"
180
229
  ? { ...cur, state: "executed_unreported", evidence: opts.evidence ?? null, hasEvidence: true, evidenceSig: opts.evidenceSig, evidenceError: undefined, reconciled, at }
181
230
  : { ...cur, state: "parked", attemptId: null, error: undefined, reconciled, at });
182
231
  }
183
232
  async releasePreinvoke(id, attemptId) {
184
233
  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}).`);
234
+ if (NON_REPLAYABLE.includes(cur.state))
235
+ throw new Error(`[decidio] ${id}: refusing to release this decision — its durable state is ${cur.state}, which is terminal or past the invocation boundary. releasePreinvoke only ever releases a claim nothing has acted on.`);
187
236
  if (cur.attemptId && cur.attemptId !== attemptId)
188
237
  return;
189
238
  this.x.set(id, { ...cur, state: "parked", attemptId: null, at: new Date().toISOString() });
190
239
  }
191
240
  }
241
+ export function windowsPrincipal() {
242
+ const sysRoot = process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows";
243
+ for (const exe of [join(sysRoot, "System32", "whoami.exe"), "whoami"]) {
244
+ try {
245
+ const out = execFileSync(exe, ["/user", "/fo", "csv", "/nh"], { encoding: "utf8" });
246
+ const m = out.match(/S-1-[0-9-]+/);
247
+ if (m)
248
+ return `*${m[0]}`;
249
+ }
250
+ catch { }
251
+ }
252
+ try {
253
+ return userInfo().username;
254
+ }
255
+ catch {
256
+ return process.env.USERNAME ?? process.env.USER ?? "";
257
+ }
258
+ }
259
+ export function hardenFile(file) {
260
+ if (process.platform !== "win32")
261
+ return null;
262
+ try {
263
+ const principal = windowsPrincipal();
264
+ if (!principal)
265
+ return "could not determine the current user";
266
+ execFileSync("icacls", [file, "/inheritance:r", "/grant:r", `${principal}:F`, "/grant:r", "*S-1-5-18:F"], { stdio: "ignore" });
267
+ try {
268
+ readFileSync(file, "utf8");
269
+ }
270
+ catch (verifyError) {
271
+ try {
272
+ execFileSync("icacls", [file, "/inheritance:e"], { stdio: "ignore" });
273
+ }
274
+ catch { }
275
+ return `the restriction applied but this process could no longer read the file (${verifyError?.message ?? verifyError}), so it was rolled back`;
276
+ }
277
+ return null;
278
+ }
279
+ catch (e) {
280
+ return String(e?.message ?? e);
281
+ }
282
+ }
192
283
  export function hardenDir(dir) {
193
284
  if (process.platform !== "win32")
194
285
  return;
195
286
  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.`);
196
287
  try {
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
- })();
288
+ const principal = windowsPrincipal();
218
289
  if (!principal)
219
290
  throw new Error("could not determine the current user");
220
291
  execFileSync("icacls", [dir, "/inheritance:r", "/grant:r", `${principal}:(OI)(CI)F`, "/grant:r", "*S-1-5-18:(OI)(CI)F"], { stdio: "ignore" });
@@ -250,14 +321,40 @@ export class FilePendingStore {
250
321
  this.migrateLegacy();
251
322
  }
252
323
  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.`);
324
+ console.error(`[decidio] could not upgrade the older pending layout in ${dir} (${e?.message ?? e}). Entries written by an older SDK may not appear in recover() — inspect the directory before assuming it is empty.`);
254
325
  }
255
326
  }
256
327
  key(id) { return id.replace(/[^\w.-]/g, "_"); }
257
328
  path(id) { return join(this.dir, `${this.key(id)}.json`); }
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`;
329
+ revPath(key, n) { return join(this.dir, `${key}.rev.${n}`); }
330
+ markerPath(key, s) { return join(this.dir, `${key}.${s}`); }
331
+ listRevs(key, names) {
332
+ const prefix = `${key}.rev.`;
333
+ const out = [];
334
+ if (!names) {
335
+ try {
336
+ names = readdirSync(this.dir);
337
+ }
338
+ catch {
339
+ return out;
340
+ }
341
+ }
342
+ for (const name of names) {
343
+ if (!name.startsWith(prefix))
344
+ continue;
345
+ const tail = name.slice(prefix.length);
346
+ if (tail.length && /^\d+$/.test(tail))
347
+ out.push(Number(tail));
348
+ }
349
+ return out;
350
+ }
351
+ legacyMarkers(key, names) {
352
+ if (names)
353
+ return EXECUTION_STATES.filter((s) => names.includes(`${key}.${s}`));
354
+ return EXECUTION_STATES.filter((s) => existsSync(this.markerPath(key, s)));
355
+ }
356
+ writeTemp(target, text) {
357
+ const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
261
358
  const fd = openSync(tmp, "w", 0o600);
262
359
  try {
263
360
  writeFileSync(fd, text);
@@ -266,73 +363,141 @@ export class FilePendingStore {
266
363
  finally {
267
364
  closeSync(fd);
268
365
  }
366
+ return tmp;
367
+ }
368
+ writeAtomic(file, text) {
369
+ const tmp = this.writeTemp(file, text);
269
370
  renameSync(tmp, file);
270
371
  }
271
- resolve(id) {
372
+ readHead(id, names) {
373
+ const key = this.key(id);
374
+ for (let tries = 0; tries < 4; tries++) {
375
+ const revs = this.listRevs(key, names);
376
+ const markers = this.legacyMarkers(key, names);
377
+ names = undefined;
378
+ if (revs.length === 0) {
379
+ if (markers.length === 0)
380
+ return { rec: null, rev: 0, corrupt: [] };
381
+ const legacy = this.resolveLegacyMarkers(key);
382
+ return { rec: legacy.rec, rev: 0, corrupt: legacy.corrupt };
383
+ }
384
+ if (markers.length) {
385
+ return { rec: null, rev: Math.max(...revs), corrupt: markers.map((s) => `${this.markerPath(key, s)}: a legacy state marker beside the revision chain — written by an older SDK after this directory was upgraded, or left by an interrupted upgrade; the two cannot be reconciled automatically`) };
386
+ }
387
+ const top = Math.max(...revs);
388
+ const f = this.revPath(key, top);
389
+ try {
390
+ const r = JSON.parse(readFileSync(f, "utf8"));
391
+ if (!r || typeof r !== "object" || !EXECUTION_STATES.includes(r.state)) {
392
+ return { rec: null, rev: top, corrupt: [`${f}: the record carries no recognised state (${JSON.stringify(r?.state ?? null)})`] };
393
+ }
394
+ return { rec: r, rev: top, corrupt: [] };
395
+ }
396
+ catch (e) {
397
+ if (e?.code === "ENOENT")
398
+ continue;
399
+ return { rec: null, rev: top, corrupt: [`${f}: ${e?.message ?? e}`] };
400
+ }
401
+ }
402
+ return { rec: null, rev: 0, corrupt: [`${key}: the revision chain changed under every read attempt`] };
403
+ }
404
+ _barrier(_op, _id, _next) { }
405
+ transition(id, op, compute) {
406
+ const key = this.key(id);
407
+ for (let attempt = 0; attempt < 16; attempt++) {
408
+ const { rec, rev, corrupt } = this.readHead(id);
409
+ if (corrupt.length)
410
+ throw new DecidioStateCorruptError(id, corrupt);
411
+ const next = compute(rec, rev);
412
+ if (next === null)
413
+ return null;
414
+ this._barrier(op, id, next.state);
415
+ const target = this.revPath(key, rev + 1);
416
+ const tmp = this.writeTemp(target, JSON.stringify(next, null, 2));
417
+ try {
418
+ linkSync(tmp, target);
419
+ }
420
+ catch (e) {
421
+ try {
422
+ rmSync(tmp);
423
+ }
424
+ catch { }
425
+ if (e?.code === "EEXIST")
426
+ continue;
427
+ throw e;
428
+ }
429
+ try {
430
+ rmSync(tmp);
431
+ }
432
+ catch { }
433
+ return next;
434
+ }
435
+ throw new Error(`[decidio] ${id}: could not publish a state transition after 16 attempts — another writer kept winning the next revision. Retry, or look for a process writing this decision in a loop.`);
436
+ }
437
+ resolveLegacyMarkers(key) {
272
438
  const corrupt = [];
273
439
  let best = null;
440
+ let denial = null;
274
441
  for (const s of EXECUTION_STATES) {
275
- const f = this.statePath(id, s);
442
+ const f = this.markerPath(key, s);
276
443
  if (!existsSync(f))
277
444
  continue;
278
445
  try {
279
446
  const r = JSON.parse(readFileSync(f, "utf8"));
280
447
  if (!best || rank(s) >= rank(best.state))
281
448
  best = { ...r, state: s };
449
+ if (s === "discarded")
450
+ denial = { ...r, state: "discarded" };
282
451
  }
283
452
  catch (e) {
284
453
  corrupt.push(`${f}: ${e.message}`);
285
454
  }
286
455
  }
287
- return { rec: best, corrupt };
456
+ if (!denial)
457
+ return { rec: best, corrupt };
458
+ const ran = best && rank(best.state) >= rank("invoking") ? best : null;
459
+ return { rec: ran ? { ...denial, executionDespiteDenial: { state: ran.state, at: ran.at, hasEvidence: !!ran.hasEvidence } } : denial, corrupt };
288
460
  }
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
- }
461
+ setState(id, attemptId, state, patch = {}, pre) {
462
+ this.transition(id, "advance", (rec) => {
463
+ const cur = rec ?? { decisionId: id, state: "parked", attemptId: null, at: new Date().toISOString() };
464
+ pre?.(cur);
465
+ if (cur.attemptId && cur.attemptId !== attemptId)
466
+ throw new DecidioStaleAttemptError(id, cur.attemptId, cur.state);
467
+ assertNotDenied(id, cur, state);
468
+ assertNotSettled(id, cur, state);
469
+ if (rank(state) < rank(cur.state))
470
+ throw new Error(`[decidio] ${id}: refusing to move ${cur.state} → ${state}; execution state only ever advances.`);
471
+ return { ...cur, ...patch, decisionId: id, attemptId, state, at: new Date().toISOString() };
472
+ });
315
473
  }
316
474
  async put(p) {
317
475
  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
- }
476
+ this.transition(p.decisionId, "put", (cur) => cur ? null
477
+ : { decisionId: p.decisionId, state: "parked", attemptId: null, action: p.action, at: new Date().toISOString() });
321
478
  }
322
479
  async get(id) {
323
480
  const f = this.path(id);
324
481
  return existsSync(f) ? JSON.parse(readFileSync(f, "utf8")) : null;
325
482
  }
326
- async delete(id) { try {
327
- if (existsSync(this.path(id)))
328
- rmSync(this.path(id));
483
+ async delete(id, attemptId) {
484
+ if (attemptId) {
485
+ const owner = this.readHead(id).rec?.attemptId;
486
+ if (owner && owner !== attemptId)
487
+ return;
488
+ }
489
+ try {
490
+ if (existsSync(this.path(id)))
491
+ rmSync(this.path(id));
492
+ }
493
+ catch { }
329
494
  }
330
- catch { } }
331
495
  async list() {
332
496
  if (!existsSync(this.dir))
333
497
  return [];
334
498
  const out = [];
335
- for (const f of readdirSync(this.dir).filter((f) => f.endsWith(".json"))) {
499
+ const names = readdirSync(this.dir);
500
+ for (const f of names.filter((f) => f.endsWith(".json"))) {
336
501
  let p;
337
502
  try {
338
503
  p = JSON.parse(readFileSync(join(this.dir, f), "utf8"));
@@ -341,7 +506,7 @@ export class FilePendingStore {
341
506
  console.error(`[decidio] skipping unreadable parked entry ${f} (${e.message}) — the other parked actions still resume; inspect or delete this file.`);
342
507
  continue;
343
508
  }
344
- const { rec } = this.resolve(p.decisionId);
509
+ const { rec } = this.readHead(p.decisionId, names);
345
510
  if (!rec || rec.state === "parked")
346
511
  out.push(p);
347
512
  }
@@ -350,53 +515,41 @@ export class FilePendingStore {
350
515
  async claim(id, attemptId) {
351
516
  if (!existsSync(this.path(id)))
352
517
  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
- }
372
- try {
373
- linkSync(tmp, target);
374
- }
375
- catch {
376
- try {
377
- rmSync(tmp);
518
+ let refusal = null;
519
+ this.transition(id, "claim", (cur) => {
520
+ const state = cur?.state ?? "parked";
521
+ if (NON_REPLAYABLE.includes(state)) {
522
+ refusal = { ok: false, state, reason: `already ${state}` };
523
+ return null;
378
524
  }
379
- catch { }
380
- return { ok: false, state: "claimed_preinvoke", reason: "lost the claim race" };
381
- }
382
- try {
383
- rmSync(tmp);
384
- }
385
- catch { }
386
- try {
387
- const p = this.statePath(id, "parked");
388
- if (existsSync(p))
389
- rmSync(p);
390
- }
391
- catch { }
392
- return { ok: true };
525
+ if (state === "claimed_preinvoke") {
526
+ refusal = { ok: false, state, reason: "claimed by another attempt" };
527
+ return null;
528
+ }
529
+ return { ...(cur ?? {}), decisionId: id, state: "claimed_preinvoke", attemptId, action: cur?.action, at: new Date().toISOString() };
530
+ });
531
+ return refusal ?? { ok: true };
532
+ }
533
+ async markInvoking(id, attemptId) {
534
+ this.setState(id, attemptId, "invoking", {}, (cur) => {
535
+ assertNotDenied(id, cur, "invoking");
536
+ assertClaimedBy(id, cur, attemptId);
537
+ });
393
538
  }
394
- async markInvoking(id, attemptId) { this.setState(id, attemptId, "invoking"); }
395
539
  async recordExecuted(id, attemptId, evidence, evidenceSig, evidenceError) { this.setState(id, attemptId, "executed_unreported", { evidence, hasEvidence: true, evidenceSig, evidenceError }); }
396
540
  async markIndeterminate(id, attemptId, error) { this.setState(id, attemptId, "indeterminate", { error }); }
397
541
  async markReported(id, attemptId, confirmation) { this.setState(id, attemptId, "reported", { confirmation }); }
542
+ async markDiscarded(id, verdict, reason) {
543
+ this.transition(id, "discard", (rec) => {
544
+ const cur = rec ?? { decisionId: id, state: "parked", attemptId: null, at: new Date().toISOString() };
545
+ assertNotSettled(id, cur, "discarded");
546
+ if (rank("discarded") < rank(cur.state))
547
+ throw new Error(`[decidio] ${id}: refusing to move ${cur.state} → discarded; execution state only ever advances.`);
548
+ return { ...cur, decisionId: id, attemptId: cur.attemptId ?? "", state: "discarded", verdict, ...(reason ? { discardReason: reason } : {}), at: new Date().toISOString() };
549
+ });
550
+ }
398
551
  async getExecution(id) {
399
- const { rec, corrupt } = this.resolve(id);
552
+ const { rec, corrupt } = this.readHead(id);
400
553
  if (corrupt.length)
401
554
  throw new DecidioStateCorruptError(id, corrupt);
402
555
  return rec;
@@ -404,21 +557,26 @@ export class FilePendingStore {
404
557
  async listExecutions() {
405
558
  if (!existsSync(this.dir))
406
559
  return [];
407
- const ids = new Set();
408
- for (const name of readdirSync(this.dir)) {
560
+ const keys = new Set();
561
+ const names = readdirSync(this.dir);
562
+ for (const name of names) {
563
+ const m = /^(.*)\.rev\.\d+$/.exec(name);
564
+ if (m) {
565
+ keys.add(m[1]);
566
+ continue;
567
+ }
409
568
  const dot = name.lastIndexOf(".");
410
569
  if (dot < 0)
411
570
  continue;
412
571
  const suffix = name.slice(dot + 1);
413
- if (!EXECUTION_STATES.includes(suffix))
414
- continue;
415
- ids.add(name.slice(0, dot));
572
+ if (EXECUTION_STATES.includes(suffix))
573
+ keys.add(name.slice(0, dot));
416
574
  }
417
575
  const out = [];
418
- for (const key of ids) {
419
- const { rec, corrupt } = this.resolve(key);
576
+ for (const key of keys) {
577
+ const { rec, corrupt } = this.readHead(key, names);
420
578
  if (corrupt.length) {
421
- out.push({ decisionId: key, state: "indeterminate", attemptId: null, error: `state files unreadable — ${corrupt.join("; ")}`, at: new Date().toISOString() });
579
+ out.push({ decisionId: rec?.decisionId ?? key, state: "indeterminate", attemptId: null, error: `state files unreadable — ${corrupt.join("; ")}`, at: new Date().toISOString() });
422
580
  continue;
423
581
  }
424
582
  if (rec)
@@ -427,54 +585,92 @@ export class FilePendingStore {
427
585
  return out;
428
586
  }
429
587
  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
- }
588
+ this.transition(id, "settle", (rec) => {
589
+ const cur = rec ?? { decisionId: id, state: "parked", attemptId: null, at: new Date().toISOString() };
590
+ assertSettlable(id, cur.state, outcome, { hasObservedEvidence: !cur.evidenceError, workerStopped: opts.workerStopped });
591
+ const at = new Date().toISOString();
592
+ const reconciled = { outcome, reason: opts.reason, at, ...(opts.workerStopped ? { workerStopped: true } : {}) };
593
+ return outcome === "committed"
594
+ ? { ...cur, state: "executed_unreported", evidence: opts.evidence ?? null, hasEvidence: true, evidenceSig: opts.evidenceSig, evidenceError: undefined, reconciled, at }
595
+ : { ...cur, state: "parked", attemptId: null, error: undefined, reconciled, at };
596
+ });
450
597
  }
451
598
  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 { }
599
+ this.transition(id, "release", (rec) => {
600
+ const state = rec?.state ?? "parked";
601
+ if (NON_REPLAYABLE.includes(state))
602
+ throw new Error(`[decidio] ${id}: refusing to release this decision — its durable state is ${state}, which is terminal or past the invocation boundary. releasePreinvoke only ever releases a claim nothing has acted on.`);
603
+ if (rec?.attemptId && rec.attemptId !== attemptId)
604
+ return null;
605
+ if (state === "parked")
606
+ return null;
607
+ return { ...(rec ?? {}), decisionId: id, state: "parked", attemptId: null, action: rec?.action, at: new Date().toISOString() };
608
+ });
465
609
  }
466
610
  migrateLegacy() {
467
611
  if (!existsSync(this.dir))
468
- return { migrated: 0, indeterminate: 0, reports: 0 };
469
- let migrated = 0, indeterminate = 0;
612
+ return { migrated: 0, indeterminate: 0, reports: 0, chained: 0 };
613
+ let migrated = 0, indeterminate = 0, chained = 0;
614
+ const markerKeys = new Set();
615
+ for (const name of readdirSync(this.dir)) {
616
+ const dot = name.lastIndexOf(".");
617
+ if (dot < 0)
618
+ continue;
619
+ if (EXECUTION_STATES.includes(name.slice(dot + 1)))
620
+ markerKeys.add(name.slice(0, dot));
621
+ }
622
+ for (const key of markerKeys) {
623
+ if (this.listRevs(key).length)
624
+ continue;
625
+ const present = this.legacyMarkers(key);
626
+ const { rec, corrupt } = this.resolveLegacyMarkers(key);
627
+ if (corrupt.length || !rec) {
628
+ console.error(`[decidio] ${key}: the older state marker(s) for this decision could not be read (${corrupt.join("; ") || "no readable marker"}) — left in place; the decision reads as unreadable until a person inspects it.`);
629
+ continue;
630
+ }
631
+ const target = this.revPath(key, 1);
632
+ const tmp = this.writeTemp(target, JSON.stringify(rec, null, 2));
633
+ try {
634
+ linkSync(tmp, target);
635
+ chained++;
636
+ }
637
+ catch (e) {
638
+ if (e?.code !== "EEXIST") {
639
+ try {
640
+ rmSync(tmp);
641
+ }
642
+ catch { }
643
+ throw e;
644
+ }
645
+ }
646
+ try {
647
+ rmSync(tmp);
648
+ }
649
+ catch { }
650
+ for (const s of present) {
651
+ try {
652
+ rmSync(this.markerPath(key, s));
653
+ }
654
+ catch { }
655
+ }
656
+ }
657
+ if (chained)
658
+ console.error(`[decidio] upgraded ${chained} decision${chained === 1 ? "" : "s"} from the one-marker-per-state layout to the revision chain. Do not run an older SDK against this directory again: it would write markers this layout refuses to read.`);
470
659
  for (const name of readdirSync(this.dir)) {
471
660
  if (name.endsWith(".claimed")) {
472
661
  const key = name.slice(0, -".claimed".length);
473
662
  try {
474
663
  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));
664
+ const published = this.transition(p.decisionId, "migrate", (cur) => cur && NON_REPLAYABLE.includes(cur.state) ? null : ({
665
+ ...(cur ?? {}), decisionId: p.decisionId, state: "indeterminate", attemptId: null, action: p.action,
666
+ error: "upgraded from 0.1.x while claimed — whether it ran is not knowable; reconcile it", at: new Date().toISOString(),
667
+ }));
668
+ if (published === null) {
669
+ renameSync(join(this.dir, name), join(this.dir, `${name}.migrated`));
670
+ continue;
671
+ }
476
672
  this.writeAtomic(this.path(p.decisionId), JSON.stringify(p, null, 2));
477
- rmSync(join(this.dir, name));
673
+ renameSync(join(this.dir, name), join(this.dir, `${name}.migrated`));
478
674
  indeterminate++;
479
675
  }
480
676
  catch {
@@ -488,9 +684,10 @@ export class FilePendingStore {
488
684
  const p = JSON.parse(readFileSync(join(this.dir, name), "utf8"));
489
685
  if (!p?.decisionId)
490
686
  continue;
491
- if (this.resolve(p.decisionId).rec)
687
+ if (this.readHead(p.decisionId).rec)
492
688
  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));
689
+ this.transition(p.decisionId, "migrate", (cur) => cur ? null
690
+ : { decisionId: p.decisionId, state: "parked", attemptId: null, action: p.action, at: new Date().toISOString() });
494
691
  migrated++;
495
692
  }
496
693
  catch { }
@@ -498,7 +695,7 @@ export class FilePendingStore {
498
695
  if (indeterminate)
499
696
  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
697
  const reports = this.migrateLegacyReports();
501
- return { migrated, indeterminate, reports };
698
+ return { migrated, indeterminate, reports, chained };
502
699
  }
503
700
  migrateLegacyReports() {
504
701
  const reportsDir = join(this.dir, "reports");
@@ -513,17 +710,18 @@ export class FilePendingStore {
513
710
  const e = JSON.parse(readFileSync(file, "utf8"));
514
711
  if (!e?.decisionId)
515
712
  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,
713
+ const published = this.transition(e.decisionId, "migrate", (cur) => cur && (cur.state === "discarded" || rank(cur.state) >= rank("executed_unreported")) ? null : ({
714
+ ...(cur ?? {}), decisionId: e.decisionId, state: "executed_unreported", attemptId: null, action: e.action,
523
715
  evidence: e.reported, hasEvidence: true, migratedFrom: "0.1.x report journal",
524
716
  at: e.at ?? new Date().toISOString(),
525
- }, null, 2));
717
+ }));
526
718
  renameSync(file, `${file}.migrated`);
719
+ if (published === null) {
720
+ const head = this.readHead(e.decisionId).rec;
721
+ if (head?.state === "discarded")
722
+ console.error(`[decidio] ${e.decisionId}: a 0.1.x execution report was found for a decision that was DENIED (${head.verdict ?? "rejected"}). It has NOT been added to the ledger — a denial is terminal and this report will not be sent. The file is kept as ${file}.migrated: if that action really ran, check the downstream system and correct it there.`);
723
+ continue;
724
+ }
527
725
  moved++;
528
726
  }
529
727
  catch {
@@ -615,6 +813,7 @@ async function statusOf(cfg, decisionId) {
615
813
  const V2_STORE_METHODS = [
616
814
  "put", "get", "delete", "list",
617
815
  "claim", "markInvoking", "recordExecuted", "markIndeterminate", "markReported",
816
+ "markDiscarded",
618
817
  "getExecution", "listExecutions", "releasePreinvoke", "settle",
619
818
  ];
620
819
  function assertStoreV2(store) {
@@ -622,7 +821,7 @@ function assertStoreV2(store) {
622
821
  if (!missing.length)
623
822
  return;
624
823
  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` +
824
+ ` The durable execution state machine is MANDATORY (parked → claimed_preinvoke → invoking → executed_unreported | indeterminate → reported, plus the terminal "discarded" that a denial writes).\n` +
626
825
  ` 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
826
  ` Fix: implement the missing methods (see the PendingStore docs), or use the shipped FilePendingStore / MemoryPendingStore. Nothing was started.`);
628
827
  }
@@ -632,6 +831,7 @@ function webhookBody(o) {
632
831
  ok: true,
633
832
  outcome: {
634
833
  status: o.status, decisionId: o.decisionId,
834
+ ...(o.reason ? { reason: o.reason } : {}),
635
835
  ...(o.reportRecorded !== undefined ? { reportRecorded: o.reportRecorded } : {}),
636
836
  ...(o.executionConfirmed !== undefined ? { executionConfirmed: o.executionConfirmed } : {}),
637
837
  ...(o.evidenceTier ? { evidenceTier: o.evidenceTier } : {}),
@@ -679,7 +879,7 @@ export function createDecidioResume(opts) {
679
879
  if (e instanceof DecidioStaleAttemptError) {
680
880
  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
881
  try {
682
- await ctrl.store.delete(decisionId);
882
+ await ctrl.store.delete(decisionId, attemptId);
683
883
  }
684
884
  catch { }
685
885
  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.` };
@@ -687,11 +887,15 @@ export function createDecidioResume(opts) {
687
887
  if (e instanceof DecidioAlreadySettledError) {
688
888
  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
889
  try {
690
- await ctrl.store.delete(decisionId);
890
+ await ctrl.store.delete(decisionId, attemptId);
691
891
  }
692
892
  catch { }
693
893
  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
894
  }
895
+ if (e instanceof DecidioDeniedError) {
896
+ console.error(`[decidio] ${decisionId}: this decision is DENIED, so no execution report will be sent. If your action nevertheless ran, your store let a refused decision reach the handler — treat that as a defect in the store, and correct the downstream effect by hand.`);
897
+ return { ...base, reportRecorded: false, executionConfirmed: false, confirmationRetryable: false, reportJournalled: false, diagnostic: `${decisionId} is denied; no report was sent.` };
898
+ }
695
899
  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
900
  }
697
901
  }
@@ -704,7 +908,7 @@ export function createDecidioResume(opts) {
704
908
  }
705
909
  const deletePending = async () => {
706
910
  try {
707
- await ctrl.store.delete(decisionId);
911
+ await ctrl.store.delete(decisionId, attemptId);
708
912
  }
709
913
  catch (e) {
710
914
  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.`);
@@ -732,7 +936,7 @@ export function createDecidioResume(opts) {
732
936
  };
733
937
  }
734
938
  if (ev.reportRecorded)
735
- await ctrl.store.markReported(decisionId, attemptId, ev.evidenceTier ?? "recorded").catch(() => { });
939
+ await ctrl.store.markReported(decisionId, attemptId, tombstoneTier(ev)).catch(() => { });
736
940
  const diagnostic = buildProblem
737
941
  ? `${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.`
738
942
  : ev.diagnostic;
@@ -743,11 +947,11 @@ export function createDecidioResume(opts) {
743
947
  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 } : {}) };
744
948
  }
745
949
  const refusalWarned = new Set();
746
- const refuseOnce = (decisionId, msg) => {
950
+ const refuseOnce = (decisionId, msg, tail) => {
747
951
  if (refusalWarned.has(decisionId))
748
952
  return;
749
953
  refusalWarned.add(decisionId);
750
- console.error(msg + " (This prints once — the entry stays parked until you act.)");
954
+ console.error(msg + (tail ?? " (This prints once — the entry stays parked until you act.)"));
751
955
  };
752
956
  const ctrl = {
753
957
  ...opts,
@@ -766,6 +970,9 @@ export function createDecidioResume(opts) {
766
970
  registry.set(action, { fn, evidence: evidence ?? prev?.evidence, withContext: withContext ?? prev?.withContext });
767
971
  },
768
972
  async park(p) {
973
+ const idProblem = decisionIdProblem(p.decisionId);
974
+ if (idProblem)
975
+ throw new DecidioUnsafeArgumentsError(`[decidio] refusing to park ${JSON.stringify(p.decisionId ?? null)}: it is not a decision id this SDK will act on (${idProblem}). Nothing was parked and nothing will execute - a decision id comes from the gate's answer, verbatim.`, typeof p.decisionId === "string" ? p.decisionId : "");
769
976
  try {
770
977
  toCanonical(p.args, "args");
771
978
  }
@@ -780,16 +987,16 @@ export function createDecidioResume(opts) {
780
987
  async resolve({ decisionId, verdict, reason }) {
781
988
  const finish = (o) => { if (o.status !== "unknown")
782
989
  opts.onResolved?.(o); return o; };
990
+ const idProblem = decisionIdProblem(decisionId);
991
+ if (idProblem)
992
+ return { status: "unknown", decisionId: String(decisionId ?? ""), reason: `not a decision id this SDK will act on (${idProblem})` };
783
993
  const pending = await ctrl.store.get(decisionId);
784
994
  if (!pending)
785
995
  return { status: "unknown", decisionId };
786
- if (verdict === "rejected") {
787
- await ctrl.store.delete(decisionId);
788
- return finish({ status: "rejected", decisionId, action: pending.action, reason });
789
- }
790
- if (verdict === "blocked") {
996
+ if (verdict === "rejected" || verdict === "blocked") {
997
+ await ctrl.store.markDiscarded(decisionId, verdict, reason);
791
998
  await ctrl.store.delete(decisionId);
792
- return finish({ status: "blocked", decisionId, action: pending.action, reason });
999
+ return finish({ status: verdict, decisionId, action: pending.action, reason });
793
1000
  }
794
1001
  if (verdict !== "approved" && verdict !== "auto_approved")
795
1002
  return { status: "unknown", decisionId };
@@ -827,7 +1034,9 @@ export function createDecidioResume(opts) {
827
1034
  const claim = await ctrl.store.claim(decisionId, attemptId);
828
1035
  if (!claim.ok) {
829
1036
  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}", …).`);
1037
+ refuseOnce(decisionId, claim.state === "discarded"
1038
+ ? `[decidio] REFUSING to execute ${decisionId}: it was DENIED (${(await ctrl.store.getExecution(decisionId))?.verdict ?? "rejected"}) and that answer is final. Nothing ran. A denial cannot be reconciled or retried — if this action should now proceed, it needs a NEW request and a new approval.`
1039
+ : `[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}", …).`, claim.state === "discarded" ? " (This prints once. The decision is closed — no action is needed.)" : undefined);
831
1040
  return finish({ status: claim.state === "indeterminate" ? "indeterminate" : "unknown", decisionId, action: pending.action, reason: claim.reason });
832
1041
  }
833
1042
  return { status: "unknown", decisionId };
@@ -877,7 +1086,7 @@ export function createDecidioResume(opts) {
877
1086
  }
878
1087
  const body = JSON.parse(raw || "{}");
879
1088
  const verdict = body.verdict;
880
- const outcome = await ctrl.resolve({ decisionId: String(body.decisionId), verdict, reason: body.reason });
1089
+ const outcome = await ctrl.resolve({ decisionId: body.decisionId, verdict, reason: body.reason });
881
1090
  res.statusCode = 200;
882
1091
  res.setHeader("content-type", "application/json");
883
1092
  res.end(JSON.stringify(webhookBody(outcome)));
@@ -903,7 +1112,7 @@ export function createDecidioResume(opts) {
903
1112
  }
904
1113
  try {
905
1114
  const body = JSON.parse(raw || "{}");
906
- const outcome = await ctrl.resolve({ decisionId: String(body.decisionId), verdict: body.verdict, reason: body.reason });
1115
+ const outcome = await ctrl.resolve({ decisionId: body.decisionId, verdict: body.verdict, reason: body.reason });
907
1116
  return new Response(JSON.stringify(webhookBody(outcome)), { status: 200, headers: { "content-type": "application/json" } });
908
1117
  }
909
1118
  catch (e) {
@@ -912,6 +1121,9 @@ export function createDecidioResume(opts) {
912
1121
  };
913
1122
  },
914
1123
  async reconcile(decisionId, opts) {
1124
+ const idProblem = decisionIdProblem(decisionId);
1125
+ if (idProblem)
1126
+ return { reportRecorded: false, executionConfirmed: false, diagnostic: `${JSON.stringify(decisionId ?? null)} is not a decision id this SDK will act on (${idProblem}). Nothing was settled.` };
915
1127
  let entry;
916
1128
  try {
917
1129
  entry = await ctrl.store.getExecution(decisionId);
@@ -933,7 +1145,7 @@ export function createDecidioResume(opts) {
933
1145
  ? signEvidence(decisionId, entry.attemptId ?? null, settleEvidence, ctrl.agentKey, ctrl.webhookSecret)
934
1146
  : undefined;
935
1147
  try {
936
- await ctrl.store.settle(decisionId, opts.outcome, { evidence: settleEvidence, reason: opts.reason, evidenceSig: settleSig });
1148
+ await ctrl.store.settle(decisionId, opts.outcome, { evidence: settleEvidence, reason: opts.reason, evidenceSig: settleSig, workerStopped: opts.workerStopped });
937
1149
  }
938
1150
  catch (e) {
939
1151
  return { reportRecorded: false, executionConfirmed: false, diagnostic: `${e?.message ?? e}` };
@@ -957,13 +1169,16 @@ export function createDecidioResume(opts) {
957
1169
  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
1170
  }
959
1171
  if (ev.reportRecorded)
960
- await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconcile", ev.evidenceTier ?? "recorded").catch(() => { });
1172
+ await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconcile", tombstoneTier(ev)).catch(() => { });
961
1173
  return {
962
1174
  ...ev,
963
1175
  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
1176
  };
965
1177
  },
966
1178
  async reconfirm(decisionId, opts) {
1179
+ const idProblem = decisionIdProblem(decisionId);
1180
+ if (idProblem)
1181
+ return { reportRecorded: false, executionConfirmed: false, diagnostic: `${JSON.stringify(decisionId ?? null)} is not a decision id this SDK will act on (${idProblem}). Nothing was sent.` };
967
1182
  let entry;
968
1183
  try {
969
1184
  entry = await ctrl.store.getExecution(decisionId);
@@ -972,11 +1187,20 @@ export function createDecidioResume(opts) {
972
1187
  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
1188
  }
974
1189
  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.` };
1190
+ const confirmed = tierConfirms(entry.confirmation);
1191
+ return { reportRecorded: true, executionConfirmed: confirmed, evidenceTier: entry.confirmation, diagnostic: `${decisionId} was already reported (${entry.confirmation ?? "recorded"}${confirmed ? ", a confirming tier" : ", not a confirming tier"}) — nothing to re-send.` };
976
1192
  }
977
1193
  if (entry?.state === "indeterminate") {
978
1194
  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
1195
  }
1196
+ if (entry?.state === "discarded") {
1197
+ return {
1198
+ reportRecorded: false, executionConfirmed: false,
1199
+ diagnostic: entry.executionDespiteDenial
1200
+ ? `${decisionId} was DENIED (${entry.verdict ?? "rejected"}), and this store ALSO holds an execution marker for it (${entry.executionDespiteDenial.state} at ${entry.executionDespiteDenial.at}). A denial is terminal, so no report will be sent for it — but something may have run. Check the downstream system and correct it there; do NOT re-run the action.`
1201
+ : `${decisionId} was DENIED (${entry.verdict ?? "rejected"}) and nothing ran, so there is no execution report to send. A denial cannot be reconciled or retried.`,
1202
+ };
1203
+ }
980
1204
  if (!entry || !entry.hasEvidence) {
981
1205
  return {
982
1206
  reportRecorded: false, executionConfirmed: false,
@@ -1013,17 +1237,18 @@ export function createDecidioResume(opts) {
1013
1237
  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
1238
  }
1015
1239
  if (ev.reportRecorded)
1016
- await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconfirm", ev.evidenceTier ?? "recorded").catch(() => { });
1240
+ await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconfirm", tombstoneTier(ev)).catch(() => { });
1017
1241
  return ev;
1018
1242
  },
1019
1243
  async recover() {
1020
1244
  const items = [];
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;
1245
+ let execs;
1246
+ try {
1247
+ execs = await ctrl.store.listExecutions();
1248
+ }
1249
+ catch (e) {
1250
+ throw new DecidioRecoveryUnavailableError(e);
1251
+ }
1027
1252
  for (const r of execs) {
1028
1253
  const a = r.action ?? "(unknown action)";
1029
1254
  switch (r.state) {
@@ -1037,7 +1262,7 @@ export function createDecidioResume(opts) {
1037
1262
  break;
1038
1263
  case "invoking":
1039
1264
  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.` });
1265
+ guidance: `"${a}" was invoked at ${r.at} by attempt ${r.attemptId ?? "(unknown)"} and no outcome has been recorded since. That does NOT mean the worker died — it may still be running this action. It will not be retried automatically. Check that worker first: if it is gone, settle it with { outcome: "committed", evidence: … } once you have checked the downstream system, or release it with { outcome: "not_committed", reason: …, workerStopped: true }.` });
1041
1266
  break;
1042
1267
  case "claimed_preinvoke":
1043
1268
  items.push({ decisionId: r.decisionId, action: r.action, state: "claimed_stalled",
@@ -1046,6 +1271,12 @@ export function createDecidioResume(opts) {
1046
1271
  case "parked":
1047
1272
  items.push({ decisionId: r.decisionId, action: r.action, state: "parked", guidance: `"${a}" is parked awaiting a human decision. Nothing has executed.` });
1048
1273
  break;
1274
+ case "discarded":
1275
+ if (r.executionDespiteDenial) {
1276
+ items.push({ decisionId: r.decisionId, action: r.action, state: "denied_with_evidence",
1277
+ guidance: `"${a}" was DENIED (${r.verdict ?? "rejected"})${r.discardReason ? ` — ${r.discardReason}` : ""}, but this store also holds an execution marker for it (${r.executionDespiteDenial.state} at ${r.executionDespiteDenial.at}${r.executionDespiteDenial.hasEvidence ? ", with a captured result" : ""}). Nothing will run it and nothing will report it — a denial is terminal and cannot be reconciled. CHECK THE DOWNSTREAM SYSTEM: if the action really ran, correct it there.` });
1278
+ }
1279
+ break;
1049
1280
  case "reported":
1050
1281
  break;
1051
1282
  }