@decidio/sdk 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/resume.js CHANGED
@@ -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" });
@@ -271,6 +342,7 @@ export class FilePendingStore {
271
342
  resolve(id) {
272
343
  const corrupt = [];
273
344
  let best = null;
345
+ let denial = null;
274
346
  for (const s of EXECUTION_STATES) {
275
347
  const f = this.statePath(id, s);
276
348
  if (!existsSync(f))
@@ -279,21 +351,27 @@ export class FilePendingStore {
279
351
  const r = JSON.parse(readFileSync(f, "utf8"));
280
352
  if (!best || rank(s) >= rank(best.state))
281
353
  best = { ...r, state: s };
354
+ if (s === "discarded")
355
+ denial = { ...r, state: "discarded" };
282
356
  }
283
357
  catch (e) {
284
358
  corrupt.push(`${f}: ${e.message}`);
285
359
  }
286
360
  }
287
- return { rec: best, corrupt };
361
+ if (!denial)
362
+ return { rec: best, corrupt };
363
+ const ran = best && rank(best.state) >= rank("invoking") ? best : null;
364
+ return { rec: ran ? { ...denial, executionDespiteDenial: { state: ran.state, at: ran.at, hasEvidence: !!ran.hasEvidence } } : denial, corrupt };
288
365
  }
289
366
  setState(id, attemptId, state, patch = {}) {
290
367
  const { rec, corrupt } = this.resolve(id);
291
368
  if (corrupt.length)
292
369
  throw new DecidioStateCorruptError(id, corrupt);
293
370
  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")) {
371
+ if (cur.attemptId && cur.attemptId !== attemptId) {
295
372
  throw new DecidioStaleAttemptError(id, cur.attemptId, cur.state);
296
373
  }
374
+ assertNotDenied(id, cur, state);
297
375
  assertNotSettled(id, cur, state);
298
376
  if (rank(state) < rank(cur.state)) {
299
377
  throw new Error(`[decidio] ${id}: refusing to move ${cur.state} → ${state}; execution state only ever advances.`);
@@ -323,11 +401,18 @@ export class FilePendingStore {
323
401
  const f = this.path(id);
324
402
  return existsSync(f) ? JSON.parse(readFileSync(f, "utf8")) : null;
325
403
  }
326
- async delete(id) { try {
327
- if (existsSync(this.path(id)))
328
- rmSync(this.path(id));
404
+ async delete(id, attemptId) {
405
+ if (attemptId) {
406
+ const owner = this.resolve(id).rec?.attemptId;
407
+ if (owner && owner !== attemptId)
408
+ return;
409
+ }
410
+ try {
411
+ if (existsSync(this.path(id)))
412
+ rmSync(this.path(id));
413
+ }
414
+ catch { }
329
415
  }
330
- catch { } }
331
416
  async list() {
332
417
  if (!existsSync(this.dir))
333
418
  return [];
@@ -360,7 +445,7 @@ export class FilePendingStore {
360
445
  return { ok: false, state, reason: "claimed by another attempt" };
361
446
  const target = this.statePath(id, "claimed_preinvoke");
362
447
  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);
448
+ const payload = JSON.stringify({ ...(rec ?? {}), decisionId: id, state: "claimed_preinvoke", attemptId, action: rec?.action, at: new Date().toISOString() }, null, 2);
364
449
  const fd = openSync(tmp, "w", 0o600);
365
450
  try {
366
451
  writeFileSync(fd, payload);
@@ -391,10 +476,19 @@ export class FilePendingStore {
391
476
  catch { }
392
477
  return { ok: true };
393
478
  }
394
- async markInvoking(id, attemptId) { this.setState(id, attemptId, "invoking"); }
479
+ 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");
487
+ }
395
488
  async recordExecuted(id, attemptId, evidence, evidenceSig, evidenceError) { this.setState(id, attemptId, "executed_unreported", { evidence, hasEvidence: true, evidenceSig, evidenceError }); }
396
489
  async markIndeterminate(id, attemptId, error) { this.setState(id, attemptId, "indeterminate", { error }); }
397
490
  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 } : {}) }); }
398
492
  async getExecution(id) {
399
493
  const { rec, corrupt } = this.resolve(id);
400
494
  if (corrupt.length)
@@ -431,11 +525,12 @@ export class FilePendingStore {
431
525
  if (corrupt.length)
432
526
  throw new DecidioStateCorruptError(id, corrupt);
433
527
  const cur = rec ?? { decisionId: id, state: "parked", attemptId: null, at: new Date().toISOString() };
434
- assertSettlable(id, cur.state, outcome, !cur.evidenceError);
528
+ assertSettlable(id, cur.state, outcome, { hasObservedEvidence: !cur.evidenceError, workerStopped: opts.workerStopped });
435
529
  const at = new Date().toISOString();
530
+ const reconciled = { outcome, reason: opts.reason, at, ...(opts.workerStopped ? { workerStopped: true } : {}) };
436
531
  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 };
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 };
439
534
  this.writeAtomic(this.statePath(id, next.state), JSON.stringify(next, null, 2));
440
535
  for (const st of EXECUTION_STATES) {
441
536
  if (st === next.state)
@@ -451,11 +546,11 @@ export class FilePendingStore {
451
546
  async releasePreinvoke(id, attemptId) {
452
547
  const { rec } = this.resolve(id);
453
548
  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}).`);
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.`);
456
551
  if (rec?.attemptId && rec.attemptId !== attemptId)
457
552
  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));
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));
459
554
  try {
460
555
  const c = this.statePath(id, "claimed_preinvoke");
461
556
  if (existsSync(c))
@@ -472,6 +567,11 @@ export class FilePendingStore {
472
567
  const key = name.slice(0, -".claimed".length);
473
568
  try {
474
569
  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)) {
572
+ renameSync(join(this.dir, name), join(this.dir, `${name}.migrated`));
573
+ continue;
574
+ }
475
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));
476
576
  this.writeAtomic(this.path(p.decisionId), JSON.stringify(p, null, 2));
477
577
  rmSync(join(this.dir, name));
@@ -518,6 +618,11 @@ export class FilePendingStore {
518
618
  renameSync(file, `${file}.migrated`);
519
619
  continue;
520
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
+ }
521
626
  this.writeAtomic(this.statePath(e.decisionId, "executed_unreported"), JSON.stringify({
522
627
  decisionId: e.decisionId, state: "executed_unreported", attemptId: null, action: e.action,
523
628
  evidence: e.reported, hasEvidence: true, migratedFrom: "0.1.x report journal",
@@ -615,6 +720,7 @@ async function statusOf(cfg, decisionId) {
615
720
  const V2_STORE_METHODS = [
616
721
  "put", "get", "delete", "list",
617
722
  "claim", "markInvoking", "recordExecuted", "markIndeterminate", "markReported",
723
+ "markDiscarded",
618
724
  "getExecution", "listExecutions", "releasePreinvoke", "settle",
619
725
  ];
620
726
  function assertStoreV2(store) {
@@ -622,7 +728,7 @@ function assertStoreV2(store) {
622
728
  if (!missing.length)
623
729
  return;
624
730
  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` +
731
+ ` 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
732
  ` 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
733
  ` Fix: implement the missing methods (see the PendingStore docs), or use the shipped FilePendingStore / MemoryPendingStore. Nothing was started.`);
628
734
  }
@@ -679,7 +785,7 @@ export function createDecidioResume(opts) {
679
785
  if (e instanceof DecidioStaleAttemptError) {
680
786
  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
787
  try {
682
- await ctrl.store.delete(decisionId);
788
+ await ctrl.store.delete(decisionId, attemptId);
683
789
  }
684
790
  catch { }
685
791
  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 +793,15 @@ export function createDecidioResume(opts) {
687
793
  if (e instanceof DecidioAlreadySettledError) {
688
794
  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
795
  try {
690
- await ctrl.store.delete(decisionId);
796
+ await ctrl.store.delete(decisionId, attemptId);
691
797
  }
692
798
  catch { }
693
799
  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
800
  }
801
+ if (e instanceof DecidioDeniedError) {
802
+ 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.`);
803
+ return { ...base, reportRecorded: false, executionConfirmed: false, confirmationRetryable: false, reportJournalled: false, diagnostic: `${decisionId} is denied; no report was sent.` };
804
+ }
695
805
  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
806
  }
697
807
  }
@@ -704,7 +814,7 @@ export function createDecidioResume(opts) {
704
814
  }
705
815
  const deletePending = async () => {
706
816
  try {
707
- await ctrl.store.delete(decisionId);
817
+ await ctrl.store.delete(decisionId, attemptId);
708
818
  }
709
819
  catch (e) {
710
820
  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.`);
@@ -743,11 +853,11 @@ export function createDecidioResume(opts) {
743
853
  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
854
  }
745
855
  const refusalWarned = new Set();
746
- const refuseOnce = (decisionId, msg) => {
856
+ const refuseOnce = (decisionId, msg, tail) => {
747
857
  if (refusalWarned.has(decisionId))
748
858
  return;
749
859
  refusalWarned.add(decisionId);
750
- console.error(msg + " (This prints once — the entry stays parked until you act.)");
860
+ console.error(msg + (tail ?? " (This prints once — the entry stays parked until you act.)"));
751
861
  };
752
862
  const ctrl = {
753
863
  ...opts,
@@ -783,13 +893,10 @@ export function createDecidioResume(opts) {
783
893
  const pending = await ctrl.store.get(decisionId);
784
894
  if (!pending)
785
895
  return { status: "unknown", decisionId };
786
- if (verdict === "rejected") {
896
+ if (verdict === "rejected" || verdict === "blocked") {
897
+ await ctrl.store.markDiscarded(decisionId, verdict, reason);
787
898
  await ctrl.store.delete(decisionId);
788
- return finish({ status: "rejected", decisionId, action: pending.action, reason });
789
- }
790
- if (verdict === "blocked") {
791
- await ctrl.store.delete(decisionId);
792
- return finish({ status: "blocked", decisionId, action: pending.action, reason });
899
+ return finish({ status: verdict, decisionId, action: pending.action, reason });
793
900
  }
794
901
  if (verdict !== "approved" && verdict !== "auto_approved")
795
902
  return { status: "unknown", decisionId };
@@ -827,7 +934,9 @@ export function createDecidioResume(opts) {
827
934
  const claim = await ctrl.store.claim(decisionId, attemptId);
828
935
  if (!claim.ok) {
829
936
  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}", …).`);
937
+ refuseOnce(decisionId, claim.state === "discarded"
938
+ ? `[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
+ : `[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
940
  return finish({ status: claim.state === "indeterminate" ? "indeterminate" : "unknown", decisionId, action: pending.action, reason: claim.reason });
832
941
  }
833
942
  return { status: "unknown", decisionId };
@@ -933,7 +1042,7 @@ export function createDecidioResume(opts) {
933
1042
  ? signEvidence(decisionId, entry.attemptId ?? null, settleEvidence, ctrl.agentKey, ctrl.webhookSecret)
934
1043
  : undefined;
935
1044
  try {
936
- await ctrl.store.settle(decisionId, opts.outcome, { evidence: settleEvidence, reason: opts.reason, evidenceSig: settleSig });
1045
+ await ctrl.store.settle(decisionId, opts.outcome, { evidence: settleEvidence, reason: opts.reason, evidenceSig: settleSig, workerStopped: opts.workerStopped });
937
1046
  }
938
1047
  catch (e) {
939
1048
  return { reportRecorded: false, executionConfirmed: false, diagnostic: `${e?.message ?? e}` };
@@ -977,6 +1086,14 @@ export function createDecidioResume(opts) {
977
1086
  if (entry?.state === "indeterminate") {
978
1087
  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
1088
  }
1089
+ if (entry?.state === "discarded") {
1090
+ return {
1091
+ reportRecorded: false, executionConfirmed: false,
1092
+ diagnostic: entry.executionDespiteDenial
1093
+ ? `${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.`
1094
+ : `${decisionId} was DENIED (${entry.verdict ?? "rejected"}) and nothing ran, so there is no execution report to send. A denial cannot be reconciled or retried.`,
1095
+ };
1096
+ }
980
1097
  if (!entry || !entry.hasEvidence) {
981
1098
  return {
982
1099
  reportRecorded: false, executionConfirmed: false,
@@ -1018,12 +1135,13 @@ export function createDecidioResume(opts) {
1018
1135
  },
1019
1136
  async recover() {
1020
1137
  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;
1138
+ let execs;
1139
+ try {
1140
+ execs = await ctrl.store.listExecutions();
1141
+ }
1142
+ catch (e) {
1143
+ throw new DecidioRecoveryUnavailableError(e);
1144
+ }
1027
1145
  for (const r of execs) {
1028
1146
  const a = r.action ?? "(unknown action)";
1029
1147
  switch (r.state) {
@@ -1037,7 +1155,7 @@ export function createDecidioResume(opts) {
1037
1155
  break;
1038
1156
  case "invoking":
1039
1157
  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.` });
1158
+ 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
1159
  break;
1042
1160
  case "claimed_preinvoke":
1043
1161
  items.push({ decisionId: r.decisionId, action: r.action, state: "claimed_stalled",
@@ -1046,6 +1164,12 @@ export function createDecidioResume(opts) {
1046
1164
  case "parked":
1047
1165
  items.push({ decisionId: r.decisionId, action: r.action, state: "parked", guidance: `"${a}" is parked awaiting a human decision. Nothing has executed.` });
1048
1166
  break;
1167
+ case "discarded":
1168
+ if (r.executionDespiteDenial) {
1169
+ items.push({ decisionId: r.decisionId, action: r.action, state: "denied_with_evidence",
1170
+ 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.` });
1171
+ }
1172
+ break;
1049
1173
  case "reported":
1050
1174
  break;
1051
1175
  }
package/dist/signing.d.ts CHANGED
@@ -1,4 +1,11 @@
1
- /** Sorted-key JCS — must match apps/api/src/canonical.ts byte-for-byte. */
1
+ /** Sorted-key JCS — must match apps/api/src/canonical.ts byte-for-byte.
2
+ *
3
+ * ONE IMPLEMENTATION (F-006). This was a module-local copy that reached `JSON.stringify` for every
4
+ * primitive, so it applied NONE of the strict rules the exported canonicalizer enforces — most
5
+ * sharply, it emitted an escaped lone surrogate instead of refusing it. The bytes a bound proof
6
+ * SIGNS and the bytes the digest BINDS are the two places that must never disagree with the value
7
+ * model, and they were the two places that did. Python already delegated (`signing.canonicalize` →
8
+ * `canonical_json`); this is the twin-parity repair on the TypeScript side. */
2
9
  export declare function canonicalize(v: unknown): string;
3
10
  /** Generate a fresh Ed25519 keypair LOCALLY. Returns the public `did:key` (register this)
4
11
  * and the PKCS8 private key base64 (keep this in the agent host's env as DECIDIO_AGENT_KEY —
package/dist/signing.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { generateKeyPairSync, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify, createHash, randomUUID } from "node:crypto";
2
+ import { canonicalJson } from "./canonical.js";
2
3
  const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
3
4
  function b58encode(buf) {
4
5
  const digits = [0];
@@ -26,12 +27,7 @@ function b58encode(buf) {
26
27
  return str;
27
28
  }
28
29
  export function canonicalize(v) {
29
- if (v === null || typeof v !== "object")
30
- return JSON.stringify(v);
31
- if (Array.isArray(v))
32
- return `[${v.map(canonicalize).join(",")}]`;
33
- const o = v;
34
- return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${canonicalize(o[k])}`).join(",")}}`;
30
+ return canonicalJson(v, "canonicalize(...)");
35
31
  }
36
32
  export function didFromPrivateKey(privateKeyBase64) {
37
33
  const priv = createPrivateKey({ key: Buffer.from(privateKeyBase64, "base64"), format: "der", type: "pkcs8" });
@@ -111,20 +107,6 @@ export function assertJsonSafe(value, path = "args") {
111
107
  walk(value, path);
112
108
  }
113
109
  export function assertSafeTransport(url, hasCredential) {
114
- if (!hasCredential) {
115
- let probe = null;
116
- try {
117
- probe = new URL(url);
118
- }
119
- catch {
120
- probe = null;
121
- }
122
- if (probe && (probe.username || probe.password)) {
123
- throw new Error(`[decidio] the gate URL carries credentials in the URL itself (${probe.protocol}//user:***@${probe.host}). Decidio authenticates with a bearer token, so this is either a mistake or a secret you did not mean to put on the wire — and it is sent on every request regardless of whether an api token is configured. Remove the user:password from the URL. Nothing was sent.`);
124
- }
125
- }
126
- if (!hasCredential)
127
- return;
128
110
  let u;
129
111
  try {
130
112
  u = new URL(url);
@@ -132,9 +114,9 @@ export function assertSafeTransport(url, hasCredential) {
132
114
  catch {
133
115
  const spelled = url.replace(/[\t\r\n]/g, "").replace(/^[\s\u0000-\u001f\u0085\ufeff]+/, "");
134
116
  if (/^http:/i.test(spelled)) {
135
- throw new Error(`[decidio] refusing to send credentials over plaintext http to ${JSON.stringify(url)} — that is not a complete absolute URL, so where the request would actually connect is undefined (the path is appended to it, which can turn it into a remote host). Set a full https URL, or http://localhost for local dev. Nothing was sent.`);
117
+ throw new Error(`[decidio] refusing to ${hasCredential ? "send credentials" : "send a request"} over plaintext http to ${JSON.stringify(url)} — that is not a complete absolute URL, so where the request would actually connect is undefined (the path is appended to it, which can turn it into a remote host). Set a full https URL, or http://localhost for local dev. Nothing was sent.`);
136
118
  }
137
- return;
119
+ throw new Error(`[decidio] the gate URL ${JSON.stringify(url)} is not an absolute URL, so there is no host to send to — where a request would actually connect depends entirely on whichever transport resolves it. Decidio needs a full https URL (or http://localhost for local dev). Nothing was sent.`);
138
120
  }
139
121
  if (u.protocol !== "http:" && u.protocol !== "https:") {
140
122
  throw new Error(`[decidio] the gate URL uses the ${u.protocol.replace(":", "")} scheme, which is not a Decidio endpoint — use https (or http://localhost for local dev). Nothing was sent.`);
@@ -148,7 +130,9 @@ export function assertSafeTransport(url, hasCredential) {
148
130
  const local = host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1";
149
131
  if (local)
150
132
  return;
151
- throw new Error(`[decidio] refusing to send credentials over plaintext http to ${u.origin} — use https, or localhost for local dev. Nothing was sent.`);
133
+ throw new Error(hasCredential
134
+ ? `[decidio] refusing to send credentials over plaintext http to ${u.origin} — use https, or localhost for local dev. Nothing was sent.`
135
+ : `[decidio] refusing to talk to ${u.origin} over plaintext http — Decidio is https, and an unauthenticated plaintext reply cannot be trusted to carry an authority decision. Use https, or localhost for local dev. Nothing was sent.`);
152
136
  }
153
137
  export const carriesCredential = (c) => !!c.apiToken || !!(c.agentKey && c.agentDid);
154
138
  export function signExecutionReport(privateKeyBase64, fields) {