@decidio/sdk 0.3.0 → 0.3.5

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);
@@ -321,14 +321,40 @@ export class FilePendingStore {
321
321
  this.migrateLegacy();
322
322
  }
323
323
  catch (e) {
324
- 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.`);
325
325
  }
326
326
  }
327
327
  key(id) { return id.replace(/[^\w.-]/g, "_"); }
328
328
  path(id) { return join(this.dir, `${this.key(id)}.json`); }
329
- statePath(id, s) { return join(this.dir, `${this.key(id)}.${s}`); }
330
- writeAtomic(file, text) {
331
- 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`;
332
358
  const fd = openSync(tmp, "w", 0o600);
333
359
  try {
334
360
  writeFileSync(fd, text);
@@ -337,14 +363,83 @@ export class FilePendingStore {
337
363
  finally {
338
364
  closeSync(fd);
339
365
  }
366
+ return tmp;
367
+ }
368
+ writeAtomic(file, text) {
369
+ const tmp = this.writeTemp(file, text);
340
370
  renameSync(tmp, file);
341
371
  }
342
- 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) {
343
438
  const corrupt = [];
344
439
  let best = null;
345
440
  let denial = null;
346
441
  for (const s of EXECUTION_STATES) {
347
- const f = this.statePath(id, s);
442
+ const f = this.markerPath(key, s);
348
443
  if (!existsSync(f))
349
444
  continue;
350
445
  try {
@@ -363,39 +458,23 @@ export class FilePendingStore {
363
458
  const ran = best && rank(best.state) >= rank("invoking") ? best : null;
364
459
  return { rec: ran ? { ...denial, executionDespiteDenial: { state: ran.state, at: ran.at, hasEvidence: !!ran.hasEvidence } } : denial, corrupt };
365
460
  }
366
- setState(id, attemptId, state, patch = {}) {
367
- const { rec, corrupt } = this.resolve(id);
368
- if (corrupt.length)
369
- throw new DecidioStateCorruptError(id, corrupt);
370
- const cur = rec ?? { decisionId: id, state: "parked", attemptId: null, at: new Date().toISOString() };
371
- if (cur.attemptId && cur.attemptId !== attemptId) {
372
- throw new DecidioStaleAttemptError(id, cur.attemptId, cur.state);
373
- }
374
- assertNotDenied(id, cur, state);
375
- assertNotSettled(id, cur, state);
376
- if (rank(state) < rank(cur.state)) {
377
- throw new Error(`[decidio] ${id}: refusing to move ${cur.state} → ${state}; execution state only ever advances.`);
378
- }
379
- const next = { ...cur, ...patch, decisionId: id, attemptId, state, at: new Date().toISOString() };
380
- this.writeAtomic(this.statePath(id, state), JSON.stringify(next, null, 2));
381
- for (const s of EXECUTION_STATES) {
382
- if (s === state)
383
- continue;
384
- if (rank(s) >= rank(state))
385
- continue;
386
- const f = this.statePath(id, s);
387
- try {
388
- if (existsSync(f))
389
- rmSync(f);
390
- }
391
- catch { }
392
- }
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
+ });
393
473
  }
394
474
  async put(p) {
395
475
  this.writeAtomic(this.path(p.decisionId), JSON.stringify(p, null, 2));
396
- if (!this.resolve(p.decisionId).rec) {
397
- 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));
398
- }
476
+ this.transition(p.decisionId, "put", (cur) => cur ? null
477
+ : { decisionId: p.decisionId, state: "parked", attemptId: null, action: p.action, at: new Date().toISOString() });
399
478
  }
400
479
  async get(id) {
401
480
  const f = this.path(id);
@@ -403,7 +482,7 @@ export class FilePendingStore {
403
482
  }
404
483
  async delete(id, attemptId) {
405
484
  if (attemptId) {
406
- const owner = this.resolve(id).rec?.attemptId;
485
+ const owner = this.readHead(id).rec?.attemptId;
407
486
  if (owner && owner !== attemptId)
408
487
  return;
409
488
  }
@@ -417,7 +496,8 @@ export class FilePendingStore {
417
496
  if (!existsSync(this.dir))
418
497
  return [];
419
498
  const out = [];
420
- 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"))) {
421
501
  let p;
422
502
  try {
423
503
  p = JSON.parse(readFileSync(join(this.dir, f), "utf8"));
@@ -426,7 +506,7 @@ export class FilePendingStore {
426
506
  console.error(`[decidio] skipping unreadable parked entry ${f} (${e.message}) — the other parked actions still resume; inspect or delete this file.`);
427
507
  continue;
428
508
  }
429
- const { rec } = this.resolve(p.decisionId);
509
+ const { rec } = this.readHead(p.decisionId, names);
430
510
  if (!rec || rec.state === "parked")
431
511
  out.push(p);
432
512
  }
@@ -435,62 +515,41 @@ export class FilePendingStore {
435
515
  async claim(id, attemptId) {
436
516
  if (!existsSync(this.path(id)))
437
517
  return { ok: false, reason: "no parked entry" };
438
- const { rec, corrupt } = this.resolve(id);
439
- if (corrupt.length)
440
- throw new DecidioStateCorruptError(id, corrupt);
441
- const state = rec?.state ?? "parked";
442
- if (NON_REPLAYABLE.includes(state))
443
- return { ok: false, state, reason: `already ${state}` };
444
- if (state === "claimed_preinvoke")
445
- return { ok: false, state, reason: "claimed by another attempt" };
446
- const target = this.statePath(id, "claimed_preinvoke");
447
- const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
448
- const payload = JSON.stringify({ ...(rec ?? {}), decisionId: id, state: "claimed_preinvoke", attemptId, action: rec?.action, at: new Date().toISOString() }, null, 2);
449
- const fd = openSync(tmp, "w", 0o600);
450
- try {
451
- writeFileSync(fd, payload);
452
- fsyncSync(fd);
453
- }
454
- finally {
455
- closeSync(fd);
456
- }
457
- try {
458
- linkSync(tmp, target);
459
- }
460
- catch {
461
- try {
462
- 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;
463
524
  }
464
- catch { }
465
- return { ok: false, state: "claimed_preinvoke", reason: "lost the claim race" };
466
- }
467
- try {
468
- rmSync(tmp);
469
- }
470
- catch { }
471
- try {
472
- const p = this.statePath(id, "parked");
473
- if (existsSync(p))
474
- rmSync(p);
475
- }
476
- catch { }
477
- 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 };
478
532
  }
479
533
  async markInvoking(id, attemptId) {
480
- const { rec, corrupt } = this.resolve(id);
481
- if (corrupt.length)
482
- throw new DecidioStateCorruptError(id, corrupt);
483
- const cur = rec ?? { decisionId: id, state: "parked", attemptId: null, at: new Date().toISOString() };
484
- assertNotDenied(id, cur, "invoking");
485
- assertClaimedBy(id, cur, attemptId);
486
- this.setState(id, attemptId, "invoking");
534
+ this.setState(id, attemptId, "invoking", {}, (cur) => {
535
+ assertNotDenied(id, cur, "invoking");
536
+ assertClaimedBy(id, cur, attemptId);
537
+ });
487
538
  }
488
539
  async recordExecuted(id, attemptId, evidence, evidenceSig, evidenceError) { this.setState(id, attemptId, "executed_unreported", { evidence, hasEvidence: true, evidenceSig, evidenceError }); }
489
540
  async markIndeterminate(id, attemptId, error) { this.setState(id, attemptId, "indeterminate", { error }); }
490
541
  async markReported(id, attemptId, confirmation) { this.setState(id, attemptId, "reported", { confirmation }); }
491
- async markDiscarded(id, verdict, reason) { this.setState(id, this.resolve(id).rec?.attemptId ?? "", "discarded", { verdict, ...(reason ? { discardReason: reason } : {}) }); }
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
+ }
492
551
  async getExecution(id) {
493
- const { rec, corrupt } = this.resolve(id);
552
+ const { rec, corrupt } = this.readHead(id);
494
553
  if (corrupt.length)
495
554
  throw new DecidioStateCorruptError(id, corrupt);
496
555
  return rec;
@@ -498,21 +557,26 @@ export class FilePendingStore {
498
557
  async listExecutions() {
499
558
  if (!existsSync(this.dir))
500
559
  return [];
501
- const ids = new Set();
502
- 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
+ }
503
568
  const dot = name.lastIndexOf(".");
504
569
  if (dot < 0)
505
570
  continue;
506
571
  const suffix = name.slice(dot + 1);
507
- if (!EXECUTION_STATES.includes(suffix))
508
- continue;
509
- ids.add(name.slice(0, dot));
572
+ if (EXECUTION_STATES.includes(suffix))
573
+ keys.add(name.slice(0, dot));
510
574
  }
511
575
  const out = [];
512
- for (const key of ids) {
513
- const { rec, corrupt } = this.resolve(key);
576
+ for (const key of keys) {
577
+ const { rec, corrupt } = this.readHead(key, names);
514
578
  if (corrupt.length) {
515
- 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() });
516
580
  continue;
517
581
  }
518
582
  if (rec)
@@ -521,60 +585,92 @@ export class FilePendingStore {
521
585
  return out;
522
586
  }
523
587
  async settle(id, outcome, opts = {}) {
524
- const { rec, corrupt } = this.resolve(id);
525
- if (corrupt.length)
526
- throw new DecidioStateCorruptError(id, corrupt);
527
- const cur = rec ?? { decisionId: id, state: "parked", attemptId: null, at: new Date().toISOString() };
528
- assertSettlable(id, cur.state, outcome, { hasObservedEvidence: !cur.evidenceError, workerStopped: opts.workerStopped });
529
- const at = new Date().toISOString();
530
- const reconciled = { outcome, reason: opts.reason, at, ...(opts.workerStopped ? { workerStopped: true } : {}) };
531
- const next = outcome === "committed"
532
- ? { ...cur, state: "executed_unreported", evidence: opts.evidence ?? null, hasEvidence: true, evidenceSig: opts.evidenceSig, evidenceError: undefined, reconciled, at }
533
- : { ...cur, state: "parked", attemptId: null, error: undefined, reconciled, at };
534
- this.writeAtomic(this.statePath(id, next.state), JSON.stringify(next, null, 2));
535
- for (const st of EXECUTION_STATES) {
536
- if (st === next.state)
537
- continue;
538
- try {
539
- const f = this.statePath(id, st);
540
- if (existsSync(f))
541
- rmSync(f);
542
- }
543
- catch { }
544
- }
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
+ });
545
597
  }
546
598
  async releasePreinvoke(id, attemptId) {
547
- const { rec } = this.resolve(id);
548
- const state = rec?.state ?? "parked";
549
- if (NON_REPLAYABLE.includes(state))
550
- 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.`);
551
- if (rec?.attemptId && rec.attemptId !== attemptId)
552
- return;
553
- this.writeAtomic(this.statePath(id, "parked"), JSON.stringify({ ...(rec ?? {}), decisionId: id, state: "parked", attemptId: null, action: rec?.action, at: new Date().toISOString() }, null, 2));
554
- try {
555
- const c = this.statePath(id, "claimed_preinvoke");
556
- if (existsSync(c))
557
- rmSync(c);
558
- }
559
- 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
+ });
560
609
  }
561
610
  migrateLegacy() {
562
611
  if (!existsSync(this.dir))
563
- return { migrated: 0, indeterminate: 0, reports: 0 };
564
- 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.`);
565
659
  for (const name of readdirSync(this.dir)) {
566
660
  if (name.endsWith(".claimed")) {
567
661
  const key = name.slice(0, -".claimed".length);
568
662
  try {
569
663
  const p = JSON.parse(readFileSync(join(this.dir, name), "utf8"));
570
- const existing = this.resolve(p.decisionId).rec;
571
- if (existing && NON_REPLAYABLE.includes(existing.state)) {
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) {
572
669
  renameSync(join(this.dir, name), join(this.dir, `${name}.migrated`));
573
670
  continue;
574
671
  }
575
- 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));
576
672
  this.writeAtomic(this.path(p.decisionId), JSON.stringify(p, null, 2));
577
- rmSync(join(this.dir, name));
673
+ renameSync(join(this.dir, name), join(this.dir, `${name}.migrated`));
578
674
  indeterminate++;
579
675
  }
580
676
  catch {
@@ -588,9 +684,10 @@ export class FilePendingStore {
588
684
  const p = JSON.parse(readFileSync(join(this.dir, name), "utf8"));
589
685
  if (!p?.decisionId)
590
686
  continue;
591
- if (this.resolve(p.decisionId).rec)
687
+ if (this.readHead(p.decisionId).rec)
592
688
  continue;
593
- 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() });
594
691
  migrated++;
595
692
  }
596
693
  catch { }
@@ -598,7 +695,7 @@ export class FilePendingStore {
598
695
  if (indeterminate)
599
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().`);
600
697
  const reports = this.migrateLegacyReports();
601
- return { migrated, indeterminate, reports };
698
+ return { migrated, indeterminate, reports, chained };
602
699
  }
603
700
  migrateLegacyReports() {
604
701
  const reportsDir = join(this.dir, "reports");
@@ -613,22 +710,18 @@ export class FilePendingStore {
613
710
  const e = JSON.parse(readFileSync(file, "utf8"));
614
711
  if (!e?.decisionId)
615
712
  continue;
616
- const existing = this.resolve(e.decisionId).rec;
617
- if (existing && rank(existing.state) >= rank("executed_unreported")) {
618
- renameSync(file, `${file}.migrated`);
619
- continue;
620
- }
621
- if (existing?.state === "discarded") {
622
- renameSync(file, `${file}.migrated`);
623
- console.error(`[decidio] ${e.decisionId}: a 0.1.x execution report was found for a decision that was DENIED (${existing.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.`);
624
- continue;
625
- }
626
- this.writeAtomic(this.statePath(e.decisionId, "executed_unreported"), JSON.stringify({
627
- 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,
628
715
  evidence: e.reported, hasEvidence: true, migratedFrom: "0.1.x report journal",
629
716
  at: e.at ?? new Date().toISOString(),
630
- }, null, 2));
717
+ }));
631
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
+ }
632
725
  moved++;
633
726
  }
634
727
  catch {
@@ -738,6 +831,7 @@ function webhookBody(o) {
738
831
  ok: true,
739
832
  outcome: {
740
833
  status: o.status, decisionId: o.decisionId,
834
+ ...(o.reason ? { reason: o.reason } : {}),
741
835
  ...(o.reportRecorded !== undefined ? { reportRecorded: o.reportRecorded } : {}),
742
836
  ...(o.executionConfirmed !== undefined ? { executionConfirmed: o.executionConfirmed } : {}),
743
837
  ...(o.evidenceTier ? { evidenceTier: o.evidenceTier } : {}),
@@ -842,7 +936,7 @@ export function createDecidioResume(opts) {
842
936
  };
843
937
  }
844
938
  if (ev.reportRecorded)
845
- await ctrl.store.markReported(decisionId, attemptId, ev.evidenceTier ?? "recorded").catch(() => { });
939
+ await ctrl.store.markReported(decisionId, attemptId, tombstoneTier(ev)).catch(() => { });
846
940
  const diagnostic = buildProblem
847
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.`
848
942
  : ev.diagnostic;
@@ -876,6 +970,9 @@ export function createDecidioResume(opts) {
876
970
  registry.set(action, { fn, evidence: evidence ?? prev?.evidence, withContext: withContext ?? prev?.withContext });
877
971
  },
878
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 : "");
879
976
  try {
880
977
  toCanonical(p.args, "args");
881
978
  }
@@ -890,6 +987,9 @@ export function createDecidioResume(opts) {
890
987
  async resolve({ decisionId, verdict, reason }) {
891
988
  const finish = (o) => { if (o.status !== "unknown")
892
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})` };
893
993
  const pending = await ctrl.store.get(decisionId);
894
994
  if (!pending)
895
995
  return { status: "unknown", decisionId };
@@ -937,7 +1037,7 @@ export function createDecidioResume(opts) {
937
1037
  refuseOnce(decisionId, claim.state === "discarded"
938
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.`
939
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);
940
- return finish({ status: claim.state === "indeterminate" ? "indeterminate" : "unknown", decisionId, action: pending.action, reason: claim.reason });
1040
+ return finish({ status: claim.state === "indeterminate" ? "indeterminate" : "already_resolved", decisionId, action: pending.action, reason: claim.state === "discarded" ? `this decision was already DENIED (${(await ctrl.store.getExecution(decisionId))?.verdict ?? "rejected"}); that answer is final and this signal lost the race to it` : `this decision already ran (durable state ${claim.state}); this signal lost the race to it` });
941
1041
  }
942
1042
  return { status: "unknown", decisionId };
943
1043
  }
@@ -986,7 +1086,7 @@ export function createDecidioResume(opts) {
986
1086
  }
987
1087
  const body = JSON.parse(raw || "{}");
988
1088
  const verdict = body.verdict;
989
- 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 });
990
1090
  res.statusCode = 200;
991
1091
  res.setHeader("content-type", "application/json");
992
1092
  res.end(JSON.stringify(webhookBody(outcome)));
@@ -1012,7 +1112,7 @@ export function createDecidioResume(opts) {
1012
1112
  }
1013
1113
  try {
1014
1114
  const body = JSON.parse(raw || "{}");
1015
- 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 });
1016
1116
  return new Response(JSON.stringify(webhookBody(outcome)), { status: 200, headers: { "content-type": "application/json" } });
1017
1117
  }
1018
1118
  catch (e) {
@@ -1021,6 +1121,9 @@ export function createDecidioResume(opts) {
1021
1121
  };
1022
1122
  },
1023
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.` };
1024
1127
  let entry;
1025
1128
  try {
1026
1129
  entry = await ctrl.store.getExecution(decisionId);
@@ -1066,13 +1169,16 @@ export function createDecidioResume(opts) {
1066
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.` };
1067
1170
  }
1068
1171
  if (ev.reportRecorded)
1069
- await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconcile", ev.evidenceTier ?? "recorded").catch(() => { });
1172
+ await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconcile", tombstoneTier(ev)).catch(() => { });
1070
1173
  return {
1071
1174
  ...ev,
1072
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.`,
1073
1176
  };
1074
1177
  },
1075
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.` };
1076
1182
  let entry;
1077
1183
  try {
1078
1184
  entry = await ctrl.store.getExecution(decisionId);
@@ -1081,7 +1187,8 @@ export function createDecidioResume(opts) {
1081
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.` };
1082
1188
  }
1083
1189
  if (entry?.state === "reported") {
1084
- 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.` };
1085
1192
  }
1086
1193
  if (entry?.state === "indeterminate") {
1087
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.` };
@@ -1130,7 +1237,7 @@ export function createDecidioResume(opts) {
1130
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.` };
1131
1238
  }
1132
1239
  if (ev.reportRecorded)
1133
- await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconfirm", ev.evidenceTier ?? "recorded").catch(() => { });
1240
+ await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconfirm", tombstoneTier(ev)).catch(() => { });
1134
1241
  return ev;
1135
1242
  },
1136
1243
  async recover() {
@@ -1162,7 +1269,12 @@ export function createDecidioResume(opts) {
1162
1269
  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" }).` });
1163
1270
  break;
1164
1271
  case "parked":
1165
- items.push({ decisionId: r.decisionId, action: r.action, state: "parked", guidance: `"${a}" is parked awaiting a human decision. Nothing has executed.` });
1272
+ if (!(await ctrl.store.get(r.decisionId))) {
1273
+ items.push({ decisionId: r.decisionId, action: r.action, state: "orphaned_state", guidance: `"${a}" is recorded as parked but its payload is gone, so nothing can execute it — an orphaned row (usually a legacy denial whose state was never written), not work awaiting a human. Nothing will run. It needs no action; remove its revision files if you want the listing clean.` });
1274
+ }
1275
+ else {
1276
+ items.push({ decisionId: r.decisionId, action: r.action, state: "parked", guidance: `"${a}" is parked awaiting a human decision. Nothing has executed.` });
1277
+ }
1166
1278
  break;
1167
1279
  case "discarded":
1168
1280
  if (r.executionDespiteDenial) {