@decidio/sdk 0.3.0 → 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/README.md CHANGED
@@ -148,8 +148,12 @@ evidence is yours, not ours. To also prove *who* issued it, pin your workspace's
148
148
  record verify panel — or simply read it off the `receipt` command's output, which prints your
149
149
  workspace's issuer DID from the authenticated response so you never have to hunt for it).
150
150
 
151
- **What VALID means — and what it does not.** A pinned `VALID` proves the receipt's bytes were
152
- sealed by your workspace's issuer and are unaltered since. It does **not** prove the approved
151
+ **What VALID means — and what it does not.** A pinned `VALID` proves the receipt's **claims** were
152
+ sealed by your workspace's issuer and that none of them has changed since. Not its *bytes*: the
153
+ signature covers the record's canonical form (JCS), so reformatting the file — reindenting it,
154
+ minifying it, reordering keys — still verifies, and should, because none of that alters a claim.
155
+ If you need byte-for-byte custody, hash the file and keep that hash yourself; this proves the
156
+ content, which is what an auditor is asking about. It does **not** prove the approved
153
157
  action ran. Execution is confirmed *after* the seal, and a sealed record is immutable, so the
154
158
  receipt's own `authority.result` and `confirmation.status` are a snapshot taken at seal time — a
155
159
  receipt for an approved-but-never-executed action verifies `VALID`, correctly. The verifier now
@@ -445,6 +449,88 @@ for engines you don't. Each adapter's shipped `.d.ts` header documents its wirin
445
449
  @decidio/sdk quickstart` is the fastest runnable end-to-end.
446
450
 
447
451
 
452
+
453
+
454
+ ## The durable store is a revision chain (0.3.2)
455
+
456
+ `FilePendingStore` keeps each decision's execution record as an **append-only revision chain** —
457
+ `<id>.rev.1`, `<id>.rev.2`, … — and the head is the highest number. Every state change reads the
458
+ head, validates against exactly that record, writes the successor to a temp file, and publishes it
459
+ with `link()` to the next revision number. `link()` fails if that name already exists, so **exactly
460
+ one writer wins each revision**, with no lock, no timeout and no daemon — on any volume that
461
+ supports hard links (NTFS, ext4, APFS, XFS; not FAT/exFAT, and not every network or FUSE mount —
462
+ on those every write fails closed rather than silently degrading). A loser re-reads the new head
463
+ and validates again — against what actually happened, not a snapshot. **No revision file is ever
464
+ deleted:** a name that could be reused is a race that could be lost, so the chain is genuinely
465
+ append-only and a decision's whole history stays readable in place.
466
+
467
+ This replaced the 0.2/0.3 one-marker-per-state layout after an external retest showed why it had to:
468
+ that layout made a transition in three separate steps (read the markers, write the new one, sweep
469
+ the weaker ones), so a process holding `claimed_preinvoke` could validate its snapshot, lose to a
470
+ second process that durably wrote *and returned* a denial, then write `invoking` anyway — and its
471
+ sweep deleted the tombstone. The SDK reported a denial as final and ran the denied action. Every
472
+ *sequential* schedule was safe; the window was inside one transition. The chain has no such window:
473
+ nothing ever deletes the head, and the rule that refuses a denied decision now runs against the
474
+ denial that just landed.
475
+
476
+ **Upgrading.** A directory in the older layout is migrated once, at the first construction of a
477
+ 0.3.2 store: each decision's markers become the head of a fresh chain and the markers are removed.
478
+ Corrupt markers are left exactly as found and the decision reads as unreadable until a person
479
+ inspects it. **Stop every older worker that shares the directory BEFORE the first 0.3.2 process
480
+ constructs its store, then upgrade them together.** The one-time migration reads the markers it
481
+ finds and removes exactly those; an older SDK still writing leaves a marker beside a chain, and
482
+ that decision is then *unreadable* by design — neither reading can be trusted over the other, so
483
+ the store refuses to pick one.
484
+
485
+ **Custom stores.** The `PendingStore` contract has said since 0.2.0 that every method must be atomic
486
+ against concurrent callers. The shipped file store did not honor that until now; a custom store must.
487
+ The deterministic two-process schedules that proved the defect live in
488
+ `packages/conformance/acceptance.mjs` (criteria 7a, 7b and 7c): a denial landing while an invoker
489
+ is paused between validating and publishing; two conflicting settlements from one head; and a denier
490
+ paused while the approver publishes four revisions past it. A store that cannot pass them is not
491
+ atomic, whatever its methods say.
492
+
493
+ ## Reading a receipt
494
+
495
+ The cryptography is only half the evidence. These are the fields that an outside reader has
496
+ actually misread on a first run, and what each one means.
497
+
498
+ **`ratified: yes` next to `deciders: 0` is not a contradiction.** `deciders` is the list of people
499
+ nominated **in advance** to decide. When an agent's request is *routed* rather than pre-assigned,
500
+ that list is empty — nobody was nominated, because the request had not happened yet. The person who
501
+ actually decided is the **ratifier** (`ratifiedBy`). So a receipt for a human-approved action reads
502
+ `deciders: 0, ratified: yes`, and the human is recorded, in the field named for the role they
503
+ played. The verifier prints this explanation whenever the pair appears.
504
+
505
+ **`appliedLimits` is what the request was MEASURED AGAINST, not a rule that fired.** It holds up to
506
+ three entries, and only those that have a value: `requested_amount` (the amount actually asked for),
507
+ `auto_approve_threshold` and `hard_cap` (the ceilings on the agent's own token). So a decision routed
508
+ to a human because *no rule matched* can still list an `auto_approve_threshold` — that is the bar the
509
+ request was compared to and did not clear, not a limit that was applied to it. Read
510
+ `requested_amount` as the ask, the other two as ceilings.
511
+
512
+ **`firedRules` is what the policy engine actually matched — for any verdict.** It is not an
513
+ auto-approve indicator: a **blocked** request carries the deny-by-default backstop rule here, so
514
+ non-empty does not mean "approved" and empty does not mean "blocked". The field that states the
515
+ disposition is `authority.result`; `firedRules` tells you which named, versioned rules produced it.
516
+
517
+ **`payloadSchemaUri` is a `urn:`, not a URL.** It is content-addressed —
518
+ `urn:decidio:sealed-payload:1:sha256:<hash>` — so it *identifies* the exact schema the record was
519
+ sealed under and pins it against substitution. It does not locate a document, and nothing will
520
+ resolve it. (Shipping the schema alongside the verifier so you can check the hash yourself is on
521
+ the roadmap; today the URN is an identifier only, and this note exists so it does not look broken.)
522
+
523
+ **`prevHash` proves a pointer, not an ancestry.** A non-null `prevHash` shows this record was
524
+ sealed after a specific predecessor, but a single-file verification cannot check that predecessor
525
+ exists or is what it claims. Verify a chain (`verify a.json b.json c.json`) when the sequence
526
+ matters.
527
+
528
+ **Pinning is trust-on-first-use.** The first time you fetch a receipt, the record *and* the issuer
529
+ DID arrive in the same authenticated response — so that first check trusts the connection, not the
530
+ key. What pinning buys is everything afterwards: record the DID once, out of band if you can, and
531
+ every later verification is checked against a key you chose rather than one you were handed. The
532
+ verifier says so on every unpinned run.
533
+
448
534
  ## Upgrading from 0.2.x to 0.3.0
449
535
 
450
536
  0.3.0 is a breaking release. Every item below changes behaviour a 0.2.x caller may depend on, so
@@ -14,7 +14,7 @@ export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
14
14
  return a;
15
15
  }
16
16
  export async function gateAuthorize(config, ctx) {
17
- canonicalize(ctx, "describe(...)");
17
+ ctx = canonicalize(ctx, "describe(...)");
18
18
  assertSafeTransport(config.apiUrl, carriesCredential(config));
19
19
  const f = config.fetchImpl ?? fetch;
20
20
  const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
package/dist/cli.js CHANGED
@@ -119,6 +119,18 @@ function writeSecretFile(path, body) {
119
119
  console.warn(`⚠ could not restrict ${path} to owner-only on this Windows volume (${why}) — it keeps the directory's inherited permissions. For production, move these values to a secret manager.`);
120
120
  }
121
121
  }
122
+ async function revokeFreshMint(jti, bearer, writeError, target) {
123
+ err(`✘ could not write ${target} (${writeError?.message ?? writeError}). The token minted a moment ago (jti ${jti}) is LIVE until revoked - revoking it now.`);
124
+ const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, bearer);
125
+ if (rv.status >= 200 && rv.status < 300) {
126
+ err(` ✔ the token minted a moment ago (jti ${jti}) was REVOKED again, so nothing live was left behind. Fix the cause above and re-run - it will mint a fresh one.`);
127
+ return true;
128
+ }
129
+ err(` ⚠ the token minted a moment ago (jti ${jti}) is LIVE and could NOT be revoked (${rv.status}). Revoke it by hand, or re-run init - the server keeps one active token per agent, so a fresh mint supersedes it:`);
130
+ err(` npx @decidio/sdk tokens revoke ${jti}`);
131
+ err(` npx @decidio/sdk tokens list --mine`);
132
+ return false;
133
+ }
122
134
  function upsertEnv(updates) {
123
135
  const remaining = { ...updates };
124
136
  const updatedKeys = new Set();
@@ -341,8 +353,19 @@ async function cmdInit(agentId, opts = {}) {
341
353
  envOut.DECIDIO_WORKSPACE_ID = workspaceId;
342
354
  if (tokenJti)
343
355
  envOut.DECIDIO_TOKEN_JTI = tokenJti;
344
- prepareEnvForWrite();
345
- upsertEnv(envOut);
356
+ try {
357
+ prepareEnvForWrite();
358
+ upsertEnv(envOut);
359
+ }
360
+ catch (e) {
361
+ if (agentToken && tokenJti)
362
+ await revokeFreshMint(tokenJti, mintBearer, e, ENV_PATH);
363
+ else if (agentToken)
364
+ err(`✘ could not write ${ENV_PATH} (${e?.message ?? e}). The token minted a moment ago is LIVE and the server returned no revocation id - find and revoke it: npx @decidio/sdk tokens list --mine`);
365
+ else
366
+ err(`✘ could not write ${ENV_PATH} (${e?.message ?? e}). No token was minted this run.`);
367
+ return 1;
368
+ }
346
369
  for (const [k, v] of Object.entries(envOut))
347
370
  process.env[k] = v;
348
371
  log(`✔ wrote ${ENV_PATH} (owner-only where the OS supports it) — every decidio command reads it from this directory (your script loads it with node --env-file=.env)`);
@@ -597,10 +620,13 @@ async function cmdReceipt(id, jsonMode = false) {
597
620
  }
598
621
  log(` Verify it offline — no Decidio account or network needed (the verifier is built in):`);
599
622
  if (issuer) {
623
+ log(` Record your workspace's issuer DID — copy this line, and pin it forever:`);
624
+ log(``);
625
+ log(`${issuer}`);
626
+ log(``);
627
+ log(` Then verify, pinned to it (the same DID, inline — copy from the line above, not this one,`);
628
+ log(` which your terminal may wrap):`);
600
629
  log(` npx @decidio/sdk verify --issuer ${issuer} ${name}`);
601
- log(` That pins the check to your workspace's issuer DID (read from the authenticated`);
602
- log(` response above — record it once, then pin it forever):`);
603
- log(` ${issuer}`);
604
630
  log(` Without --issuer the verifier proves the receipt is internally consistent, but NOT who`);
605
631
  log(` issued it; it says so in its output.`);
606
632
  }
@@ -621,6 +647,10 @@ function cmdVerify(argsIn) {
621
647
  err("✘ --issuer needs a did:key value (a bare or EMPTY --issuer must not silently verify unpinned).");
622
648
  return 2;
623
649
  }
650
+ if (expectedIssuer !== undefined && expectedIssuer !== v) {
651
+ err(`✘ --issuer given twice with different values (${expectedIssuer} then ${v}) — refusing to choose which one you meant to pin to.`);
652
+ return 2;
653
+ }
624
654
  expectedIssuer = v;
625
655
  i++;
626
656
  }
@@ -630,6 +660,10 @@ function cmdVerify(argsIn) {
630
660
  err("✘ --issuer needs a did:key value.");
631
661
  return 2;
632
662
  }
663
+ if (expectedIssuer !== undefined && expectedIssuer !== v) {
664
+ err(`✘ --issuer given twice with different values (${expectedIssuer} then ${v}) — refusing to choose which one you meant to pin to.`);
665
+ return 2;
666
+ }
633
667
  expectedIssuer = v;
634
668
  }
635
669
  else if (!a.startsWith("-")) {
@@ -711,14 +745,14 @@ async function cmdQuickstart(opts = {}) {
711
745
  const prev = readFileSync(envFile, "utf8");
712
746
  const prevId = prev.match(/^DECIDIO_AGENT_ID=(.+)$/m)?.[1];
713
747
  const prevJti = prev.match(/^DECIDIO_TOKEN_JTI=(.+)$/m)?.[1];
748
+ const carried = new Set((prev.match(/^DECIDIO_ORPHANED_TOKEN_JTI=(.+)$/m)?.[1] ?? "").split(",").map((s) => s.trim()).filter(Boolean));
714
749
  if (prevId && prevId !== agentId) {
715
750
  if (prevJti) {
716
751
  const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(prevJti)}`, { method: "DELETE" }, session);
717
752
  if (rv.status >= 200 && rv.status < 300)
718
753
  log(` ✔ the previous tour agent (${prevId}) had its token REVOKED before this tour replaced it — no live credential is left behind`);
719
754
  else {
720
- const carried = [...(prev.match(/^DECIDIO_ORPHANED_TOKEN_JTI=(.+)$/m)?.[1] ?? "").split(",").filter(Boolean), prevJti];
721
- envOut.DECIDIO_ORPHANED_TOKEN_JTI = [...new Set(carried)].join(",");
755
+ carried.add(prevJti);
722
756
  log(` ⚠ could not revoke the previous tour's token (jti ${prevJti}, ${rv.status}) — it is still LIVE. Its id is kept in ${envFile} as DECIDIO_ORPHANED_TOKEN_JTI, and \`quickstart cleanup\` will retry it; or revoke it by hand: npx @decidio/sdk tokens revoke ${prevJti}`);
723
757
  }
724
758
  }
@@ -726,12 +760,34 @@ async function cmdQuickstart(opts = {}) {
726
760
  log(` ⚠ the previous tour file carried no revocation id, so its token (if any) must be revoked from your Decidio app.`);
727
761
  }
728
762
  }
763
+ if (carried.size) {
764
+ envOut.DECIDIO_ORPHANED_TOKEN_JTI = [...carried].join(",");
765
+ log(` • ${carried.size} orphaned token id(s) from earlier replacements carried forward — \`quickstart cleanup\` will retry them.`);
766
+ }
767
+ }
768
+ try {
769
+ writeSecretFile(envFile, Object.entries(envOut).map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
770
+ }
771
+ catch (e) {
772
+ if (mint.body?.jti)
773
+ await revokeFreshMint(mint.body.jti, session, e, envFile);
774
+ else
775
+ err(`✘ could not write ${envFile} (${e?.message ?? e}). The tour token minted a moment ago is LIVE and the server returned no revocation id - find and revoke it: npx @decidio/sdk tokens list --mine`);
776
+ return 1;
729
777
  }
730
- writeSecretFile(envFile, Object.entries(envOut).map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
731
778
  log(` ✔ this directory already has an agent in .env — tour credentials went to ${envFile} (your agent is untouched)`);
732
779
  }
733
780
  else {
734
- upsertEnv(envOut);
781
+ try {
782
+ upsertEnv(envOut);
783
+ }
784
+ catch (e) {
785
+ if (mint.body?.jti)
786
+ await revokeFreshMint(mint.body.jti, session, e, envFile);
787
+ else
788
+ err(`✘ could not write ${envFile} (${e?.message ?? e}). The tour token minted a moment ago is LIVE and the server returned no revocation id - find and revoke it: npx @decidio/sdk tokens list --mine`);
789
+ return 1;
790
+ }
735
791
  }
736
792
  const failTour = async (code) => {
737
793
  log("");
@@ -3,6 +3,15 @@
3
3
  * we do not understand is never treated as evidence. */
4
4
  export declare const EVIDENCE_TIERS: readonly ["pending", "execution_unconfirmed", "agent_asserted", "sensor_observed", "application_confirmed", "independently_reconciled"];
5
5
  export type EvidenceTier = (typeof EVIDENCE_TIERS)[number];
6
+ /** Whether a recorded tier attests a COMMITTED execution - the same set the live reader uses, so a
7
+ * tombstone that kept only the tier can still answer the question honestly (retest V030-07). */
8
+ export declare function tierConfirms(tier: string | undefined | null): boolean;
9
+ /** The tier a `reported` tombstone records. The server caps a confirming tier whose proof failed to
10
+ * `execution_unconfirmed` (apps/api agent-confirm.ts, lint-locked CONFIRM-TRUTH), so a body that
11
+ * says otherwise is one the current server cannot emit; the tombstone applies the same rule, so
12
+ * reconfirm() - which derives from the tier - can never answer "confirmed" where resolve() answered
13
+ * "not confirmed" over the same record (invariant-guard, 0.3.3). Every other tier is stored verbatim. */
14
+ export declare function tombstoneTier(ev: EvidenceOutcome): string;
6
15
  export interface EvidenceOutcome {
7
16
  /** Decidio accepted the report and named the tier it recorded. */
8
17
  reportRecorded: boolean;
package/dist/evidence.js CHANGED
@@ -7,6 +7,10 @@ export const EVIDENCE_TIERS = [
7
7
  "independently_reconciled",
8
8
  ];
9
9
  const CONFIRMING = ["sensor_observed", "application_confirmed", "independently_reconciled"];
10
+ export function tierConfirms(tier) { return typeof tier === "string" && CONFIRMING.includes(tier); }
11
+ export function tombstoneTier(ev) {
12
+ return ev.executionReportVerified === false && tierConfirms(ev.evidenceTier) ? "execution_unconfirmed" : (ev.evidenceTier ?? "recorded");
13
+ }
10
14
  export function describeTierGap(capturedResponse) {
11
15
  const r = (capturedResponse ?? {});
12
16
  const id = r.id ?? r.recordId ?? r.Id ?? null;
@@ -47,7 +51,7 @@ export function readConfirmResponse(body, opts) {
47
51
  outcome.executionReportReason = rpt.reason;
48
52
  if (proofExplicitlyFailed) {
49
53
  const why = typeof rpt.reason === "string" && rpt.reason ? ` (${rpt.reason})` : "";
50
- outcome.diagnostic = `the signed execution report did NOT verify${why}, so this is NOT a confirmed execution — the gate reported tier "${tier}", and this SDK refuses to read an unproven attestation as confirmation. Your action DID run; check the agent's key/DID/registration (npx @decidio/sdk doctor), then ${retry}`;
54
+ outcome.diagnostic = `the signed execution report did NOT verify${why}, so this is NOT a confirmed execution — the gate reported tier "${tier}", and this SDK refuses to read an unproven attestation as confirmation. Your action DID run and the report IS recorded at that tier - there is nothing to re-send, and re-sending would not change the verdict; the record stands as unverified. Check the agent's key/DID/registration (npx @decidio/sdk doctor) so the NEXT execution verifies.`;
51
55
  }
52
56
  }
53
57
  return outcome;
package/dist/index.d.ts CHANGED
@@ -12,7 +12,10 @@ export { readConfirmResponse, buildConfirmBody, describeTierGap, EVIDENCE_TIERS
12
12
  export type { EvidenceOutcome, EvidenceTier } from "./evidence.js";
13
13
  export type { RequesterIdentity } from "./signing.js";
14
14
  export interface GuardConfig {
15
- /** Base URL of the Decidio gate, e.g. "https://api.decidio.io". */
15
+ /** Base URL of the Decidio gate. The hosted sandbox is "https://decidio-api.onrender.com",
16
+ * which is also the default when this is unset; a self-hosted deployment is your own origin.
17
+ * (This example named a host the product does not use — it shipped in the .d.ts, so it was the
18
+ * first URL a typing-aware editor suggested to anyone configuring the SDK.) */
16
19
  apiUrl: string;
17
20
  /** The agent identity registered with Decidio (onboarding wizard), e.g. "salesforce/SalesforceBot". */
18
21
  agentId: string;
package/dist/index.js CHANGED
@@ -138,10 +138,10 @@ export function withApproval(fn, describe, config, opts) {
138
138
  }
139
139
  throw e;
140
140
  }
141
+ ctx = canonicalize(ctx, "describe(...)");
141
142
  const requesterIdentity = (config.agentKey && config.agentDid)
142
143
  ? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
143
144
  : undefined;
144
- canonicalize(ctx, "describe(...)");
145
145
  if (typeof ctx?.action !== "string" || ctx.action.trim().length === 0) {
146
146
  throw new Error(`[decidio] describe(...) must return an \`action\` string naming what is being asked (got ${JSON.stringify(ctx?.action ?? null)}). Nothing was routed or executed.`);
147
147
  }
package/dist/resume.d.ts CHANGED
@@ -129,11 +129,12 @@ export interface ExecutionRecord {
129
129
  at: string;
130
130
  workerStopped?: boolean;
131
131
  };
132
- /** DERIVED AT READ TIME, never persisted: set when a DENIED decision also carries an execution
133
- * marker. That pair is the single most alarming state the machine can hold — a human refused it
134
- * and something ran anyway — so it must never resolve to silence. Only the file store can hold
135
- * it (the memory store keeps one record per decision); a crash between writing a marker and
136
- * sweeping the weaker ones, or a 0.1.x report migrated beside a tombstone, both produce it. */
132
+ /** Set when a DENIED decision also carries an execution record. That pair is the single most
133
+ * alarming state the machine can hold — a human refused it and something ran anyway — so it must
134
+ * never resolve to silence. The revision chain cannot produce it (no transition publishes over
135
+ * a denial); it is DERIVED by the legacy-marker reader, and PERSISTED only when a 0.2/0.3
136
+ * directory that held a denial marker beside an execution marker is migrated onto the chain —
137
+ * there it is the record of what that layout held. */
137
138
  executionDespiteDenial?: {
138
139
  state: ExecutionState;
139
140
  at: string;
@@ -346,19 +347,37 @@ export declare class FilePendingStore implements PendingStore {
346
347
  constructor(dir: string);
347
348
  private key;
348
349
  private path;
349
- /** One marker file per state. The FILENAME carries the state so a scan never has to parse,
350
- * and the content carries the record. */
351
- private statePath;
352
- /** Durable write: temp file in the SAME directory → fsync → atomic rename. A crash can then
353
- * only leave the old complete file or the new complete file, never a truncated one. The
354
- * 0.1.x journal wrote straight to its final path with O_TRUNC, so a crash mid-write
355
- * destroyed the record that proved an action had already run. */
350
+ private revPath;
351
+ /** Legacy (0.2/0.3) marker path, read for migration and never written. */
352
+ private markerPath;
353
+ private listRevs;
354
+ private legacyMarkers;
355
+ /** Write `text` to a fresh temp file beside `target`, fsynced. Returns the temp path. */
356
+ private writeTemp;
357
+ /** Durable write for the PAYLOAD file (not the chain): temp → fsync → atomic rename. */
356
358
  private writeAtomic;
357
- /** The authoritative record: the STRONGEST state present on disk. A crash between "write the
358
- * new marker" and "remove the old one" leaves two markers; taking the strongest means an
359
- * executed action can never read back as parked, so a partial transition is safe rather than
360
- * merely unlikely. Corruption is reported, never silently treated as absent. */
361
- private resolve;
359
+ /** The head of the chain, or the migrated reading of a legacy layout that has not been
360
+ * upgraded. Corruption is REPORTED, never treated as absent: an unreadable record is the one
361
+ * that proves an action already ran. */
362
+ private readHead;
363
+ /** TEST SEAM, a no-op in production. Called with the operation name and the state about to be
364
+ * published, AFTER validation and BEFORE the publish — the exact window a concurrent writer
365
+ * can land in. The deterministic two-process suites subclass the store and block here so the
366
+ * race the retest found is reproduced on purpose, every run, rather than by luck. */
367
+ protected _barrier(_op: string, _id: string, _next: ExecutionState): void;
368
+ /** ONE TRANSITION = read head → compute successor → publish with link(). `compute` receives the
369
+ * current record (null if none) and returns the successor, or null for "nothing to write" (a
370
+ * refusal that is a return value, like a lost claim, rather than an error). Every rule runs
371
+ * INSIDE `compute`, against the revision that is about to be superseded; if a rival publishes
372
+ * first, `compute` runs again against the rival's record. */
373
+ private transition;
374
+ /** The 0.2/0.3 reading of a marker layout — strongest marker wins, a denial marker wins at any
375
+ * rank, and a denial beside an execution marker is reported as both. Used ONLY to migrate an
376
+ * older directory and to report one that could not be migrated; nothing writes markers. */
377
+ private resolveLegacyMarkers;
378
+ /** The generic ADVANCE, with every fence evaluated against the head the publish will supersede.
379
+ * `pre` is a per-method check (markInvoking's claim requirement) that must ALSO run inside the
380
+ * transition — a check that runs before it is a check against a snapshot. */
362
381
  private setState;
363
382
  put(p: PendingAction): Promise<void>;
364
383
  get(id: string): Promise<PendingAction | null>;
@@ -374,10 +393,11 @@ export declare class FilePendingStore implements PendingStore {
374
393
  markDiscarded(id: string, verdict: "rejected" | "blocked", reason?: string): Promise<void>;
375
394
  getExecution(id: string): Promise<ExecutionRecord | null>;
376
395
  listExecutions(): Promise<ExecutionRecord[]>;
377
- /** OPERATOR RECONCILIATION — see MemoryPendingStore.settle. Writes the new marker first and
378
- * then clears every other state file for this decision, including the STRONGER ones: this is
379
- * the single place where a state legitimately moves backwards, so leaving a stronger marker
380
- * behind would let resolve()'s strongest-wins rule undo the operator's decision. */
396
+ /** OPERATOR RECONCILIATION — see MemoryPendingStore.settle. The one legal backwards move, and
397
+ * now a transition like any other: two conflicting settlements both validate, both try to
398
+ * publish the next revision, exactly one wins, and the loser re-validates against the winner's
399
+ * record — where `assertSettlable` refuses it (captured evidence protects a `committed`; a
400
+ * `parked` record refuses `committed`). Before this, both won and the second erased the first. */
381
401
  settle(id: string, outcome: SettleOutcome, opts?: {
382
402
  evidence?: unknown;
383
403
  reason?: string;
@@ -385,14 +405,20 @@ export declare class FilePendingStore implements PendingStore {
385
405
  workerStopped?: boolean;
386
406
  }): Promise<void>;
387
407
  releasePreinvoke(id: string, attemptId: string): Promise<void>;
388
- /** ONE-WAY MIGRATION from the 0.1.x layout, run once at construction. `<id>.json` → parked;
389
- * `<id>.claimed` → INDETERMINATE, never parked: a claimed 0.1.x entry may have been mid-flight
390
- * when the process died, and assuming it did not run is the assumption that duplicates a
391
- * payment. Nothing ambiguous is made executable by an upgrade. */
408
+ /** ONE-WAY MIGRATION of older layouts, run at construction.
409
+ * 0.2/0.3 one-marker-per-state → the head of a fresh revision chain (markers removed)
410
+ * 0.1.x `<id>.json` with no state → parked
411
+ * 0.1.x `<id>.claimed` → INDETERMINATE, never parked: a claimed 0.1.x entry may have
412
+ * been mid-flight when the process died, and assuming it did
413
+ * not run is the assumption that duplicates a payment
414
+ * 0.1.x `reports/<id>.json` → executed_unreported (the report can be re-sent)
415
+ * Nothing ambiguous is made executable by an upgrade, and nothing unreadable is silently
416
+ * dropped: a key whose markers cannot be parsed is left exactly as found and reads as corrupt. */
392
417
  migrateLegacy(): {
393
418
  migrated: number;
394
419
  indeterminate: number;
395
420
  reports: number;
421
+ chained: number;
396
422
  };
397
423
  /** 0.1.x wrote execution reports to `<pendingDir>/reports/<id>.json` — evidence that an action
398
424
  * ALREADY RAN and still owes Decidio a report. 0.2.0 keeps that in the ledger, so without this
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 };
@@ -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() {
package/dist/signing.js CHANGED
@@ -124,6 +124,17 @@ export function assertSafeTransport(url, hasCredential) {
124
124
  if (u.username || u.password) {
125
125
  throw new Error(`[decidio] the gate URL carries credentials in the URL itself (${u.protocol}//user:***@${u.host}). Decidio authenticates with a bearer token; remove the user:password from the URL. Nothing was sent.`);
126
126
  }
127
+ if (u.hostname === "") {
128
+ throw new Error(`[decidio] the gate URL ${JSON.stringify(url)} has no host — there is nowhere for the request to go. Decidio needs a full https URL (or http://localhost for local dev). Nothing was sent.`);
129
+ }
130
+ {
131
+ const spelled = url.replace(/[\t\r\n]/g, "").replace(/^[\s\u0000-\u001f\u0085\ufeff]+/, "").toLowerCase();
132
+ const origin = `${u.protocol}//${u.host}`.toLowerCase();
133
+ const next = spelled.charAt(origin.length);
134
+ if (!spelled.startsWith(origin) || (next !== "" && next !== "/" && next !== "?" && next !== "#" && next !== ":")) {
135
+ throw new Error(`[decidio] the gate URL ${JSON.stringify(url)} is not written in canonical form — this parser reads it as ${origin}, but the raw spelling is what a transport receives, and a parser that normalizes differently can read it as another host entirely. Write it as ${origin}. Nothing was sent.`);
136
+ }
137
+ }
127
138
  if (u.protocol !== "http:")
128
139
  return;
129
140
  const host = u.hostname.toLowerCase();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decidio/sdk",
3
- "version": "0.3.0",
3
+ "version": "0.3.3",
4
4
  "description": "One-line approval gate for AI-agent actions. Wrap a risky tool call with guard.protect(); Decidio's policy engine decides proceed | route | block, a human approves routed actions, and every outcome is sealed as a verifiable Authority Receipt.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -79,10 +79,10 @@
79
79
  "scripts": {
80
80
  "typecheck": "tsc --noEmit",
81
81
  "build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && tsc -p tsconfig.types.json",
82
- "prepublishOnly": "npm run typecheck && npm run build"
82
+ "prepublishOnly": "npm run typecheck && npm run build && node ../publish-boundary/check.mjs --only @decidio/sdk"
83
83
  },
84
84
  "dependencies": {
85
- "@decidio/verify": "0.3.0"
85
+ "@decidio/verify": "0.3.3"
86
86
  },
87
87
  "author": "OmniTwin Technologies Inc.",
88
88
  "bugs": {