@decidio/sdk 0.4.0 → 0.4.1

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/CHANGELOG.md CHANGED
@@ -4,6 +4,56 @@ Release notes for the client SDK. The Python package `decidio` ships the same ch
4
4
  version (the two are conformance-locked twins). Each entry names what changed, what a reader must do
5
5
  on upgrade, and what proved it.
6
6
 
7
+ ## 0.4.1 — "no" means no
8
+
9
+ Two first-run defects found by an outside engineer within sixteen minutes of installing 0.4.0. Both were
10
+ in the CLI's own tour and cleanup, not in the SDK's gate, ledger or verifier; both are fixed in both
11
+ languages, and the two blind spots that let them ship are now test axes.
12
+
13
+ - **The tour's approval prompt is a question.** "Press Enter to approve" accepted ANY completed line —
14
+ the literal `no` included — as approval, because the answer was never read. Now `y` approves, `n`
15
+ REJECTS (the tour rehearses the rejection half: the request is refused, your function does not run, and
16
+ the tour says so), and anything else asks again; three non-answers stop the tour with the decision still
17
+ pending (exit 3) rather than guess. The rule is one pure function, `approvalAnswer`, driven by a shared
18
+ corpus (`packages/conformance/vectors/consent-answers.json`) in both languages, and the interactive
19
+ branch itself is exercised under a real terminal on the Linux gate. The automated `--auto-approve-demo`
20
+ path is unchanged and still labeled as automated authority.
21
+ - **A lost `.env` no longer loses the token.** 0.3.5's recovery journal retired a token id the moment its
22
+ credential file was written, so moving or deleting `.env` afterwards left `quickstart cleanup` saying the
23
+ journal owed nothing over a live seven-day token. Only a revocation retires a journal entry now. Cleanup
24
+ revokes tour tokens from the journal even when the credential file is gone, and still revokes a token
25
+ whose credential file never landed (the double fault the journal was built for). It never revokes an
26
+ `init` token that DID land: one a credential file here holds is named and left alone; one that was written
27
+ to a file no longer found here is named with the `tokens revoke` command (it may simply live elsewhere);
28
+ `doctor` shows a held token as live-and-held. **Upgrading from 0.3.5/0.4.0:** rows those versions retired as `persisted` count as
29
+ outstanding again. A tour you already cleaned up will show in `doctor` until you run
30
+ `npx @decidio/sdk quickstart cleanup` once, which reports the token as already revoked and retires the row.
31
+ - **Truth and disclosure.** The failure text after a refused registration no longer claims nothing was
32
+ written (the `.gitignore` entries were); the preflight discloses the five-minute wait for your answer and
33
+ that receipts are shareable but carry business metadata; cleanup names the retained journal; the
34
+ worker's "prints once" is "once per worker process"; the tour tells you to unset `DECIDIO_SESSION_TOKEN`
35
+ before launching agent code from the same shell.
36
+ - **`dev --target` obeys the wire rule:** the relay refuses a remote plaintext `http://` target, since the
37
+ forwarded body is signed resume material.
38
+ - **A race loser says so** (prodsec retest of 0.4.0, V040-01). A resume signal that finds the pre-invocation
39
+ claim held by another live attempt, or loses the invocation-boundary race to one, answers the new additive
40
+ status `lost_race` (it did nothing; the winner reports). It used to read `unknown` — the word for "not
41
+ ours" — and `no_handler` although the handler was registered. A boundary loss to a DENIAL is final and
42
+ answers `already_resolved` with the denial named; a store fault at the boundary answers `lost_race` with
43
+ "nothing ran … a later signal may retry". A signal that arrives after the entry was consumed now consults
44
+ the ledger: already ran or denied → `already_resolved`; in flight → `indeterminate`; a claim held by another
45
+ attempt → `lost_race`; only a decision with no record at all is `unknown`. Exhaustive switches over the
46
+ status union gain one case.
47
+ - **Adapter declarations aligned** (V040-02): the Temporal compatibility floor checked at bind time is now the
48
+ same `>=1.9 <2` the package declares; the peer-dependency comment says what the manifest does (optional
49
+ peers, checked at bind time); the Inngest step outcome is a discriminated union, so `confirmation` is
50
+ statically present when `status` is `"executed"`. Adapters stay Beta; real-engine evidence (V040-03) and
51
+ provenance metadata (V040-04) remain open and disclosed.
52
+ - Deliberately not changed, recorded in the backlog for a product decision: per-subcommand `--help`, the
53
+ tour showing the exact call it makes (`withApproval` in blocking mode rather than the `guard.protect`
54
+ line it prints as the takeaway), dot-stripping in receipt filenames, and a shared no-overwrite receipt
55
+ writer for the tour.
56
+
7
57
  ## 0.4.0 — the scope statement
8
58
 
9
59
  - **README: "What 0.4 does not yet claim" and "Compatibility and support."** The stability promise is
package/README.md CHANGED
@@ -81,7 +81,10 @@ fresh one (or pass `--unique` to auto-append a random suffix — collision-proof
81
81
  workspaces and CI). Headless terminal (no prompts possible)? Export `DECIDIO_SESSION_TOKEN` first:
82
82
  request a sign-in link, then exchange its `?login=` value at `POST /demo/login-consume` — the
83
83
  response's `token` is your session token (the CLI prints these exact instructions when it
84
- can't prompt).
84
+ can't prompt). That session token is your workspace login, not the agent's credential: unset it
85
+ before launching agent code from the same shell, because child processes inherit exported
86
+ variables. In the tour's human step, `y` approves and `n` rejects — the rejection half is worth
87
+ watching once: the request is refused and your function does not run.
85
88
 
86
89
  **3. Protect one function** — save this as `quickstart.mjs`:
87
90
 
@@ -27,12 +27,12 @@ export declare function gateConfirm(config: GuardConfig, decisionId: string, res
27
27
  export declare function verdictFrom(payload: any): string;
28
28
  /** Throw the right error for a non-approving verdict; return for approved/auto_approved. */
29
29
  export declare function assertVerdict(verdict: string, decisionId: string, reason?: string): void;
30
- /** Engine versions these adapters are written and tested against. Peer dependencies are
31
- * deliberately NOT declared: declaring them made npm resolve four engine ecosystems into ~211MB
32
- * of cache metadata for every consumer, including the majority who use none of them. The
33
- * compatibility contract lives here instead, and it is CHECKED at bind time — a mismatch names
34
- * the version it found, the range it wants, the beta status and the exact remediation, rather
35
- * than failing somewhere deep in the engine later. */
30
+ /** Engine versions these adapters are written and tested against. The two engines that ARE declared in
31
+ * package.json are declared as OPTIONAL peers (peerDependenciesMeta), so a consumer who uses none of them
32
+ * installs nothing extra; Inngest and the OpenAI Agents SDK are not declared at all. Either way the
33
+ * contract is CHECKED here at bind time — a mismatch names the version it found, the range it wants, the
34
+ * beta status and the exact remediation, rather than failing somewhere deep in the engine later. These
35
+ * ranges and package.json's peer ranges are the SAME numbers (prodsec V040-02: they had drifted). */
36
36
  export declare const ENGINE_RANGES: Record<string, {
37
37
  pkg: string;
38
38
  min: string;
@@ -105,7 +105,7 @@ export function assertVerdict(verdict, decisionId, reason) {
105
105
  }
106
106
  export const ENGINE_RANGES = {
107
107
  langgraph: { pkg: "@langchain/langgraph", min: "0.2.0", lessThan: "2.0.0" },
108
- temporal: { pkg: "@temporalio/workflow", min: "1.0.0", lessThan: "2.0.0" },
108
+ temporal: { pkg: "@temporalio/workflow", min: "1.9.0", lessThan: "2.0.0" },
109
109
  openai: { pkg: "@openai/agents", min: "0.0.1", lessThan: "2.0.0" },
110
110
  inngest: { pkg: "inngest", min: "3.0.0", lessThan: "4.0.0" },
111
111
  };
package/dist/cli.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ export type ApprovalAnswer = "approve" | "reject" | "again";
3
+ export declare function approvalAnswer(answer: unknown): ApprovalAnswer;
package/dist/cli.js CHANGED
@@ -4,10 +4,12 @@ import { randomBytes } from "node:crypto";
4
4
  import { createServer } from "node:http";
5
5
  import { execFileSync } from "node:child_process";
6
6
  import { createInterface } from "node:readline";
7
+ import { pathToFileURL as toFileUrl, fileURLToPath as fromFileUrl } from "node:url";
8
+ import { realpathSync as realPath } from "node:fs";
7
9
  import { createRequire } from "node:module";
8
10
  import { generateKeypair, assertSafeTransport } from "./signing.js";
9
11
  import { hardenFile } from "./resume.js";
10
- import { withApproval, DecidioRateLimitError, didFromPrivateKey } from "./index.js";
12
+ import { withApproval, DecidioRateLimitError, DecidioRejectedError, didFromPrivateKey } from "./index.js";
11
13
  import { verifyReceipt, runVerify } from "@decidio/verify";
12
14
  const VERSION = (() => {
13
15
  try {
@@ -246,6 +248,32 @@ function journalRows() {
246
248
  }
247
249
  return rows;
248
250
  }
251
+ function credentialJtisOnDisk() {
252
+ const out = new Set();
253
+ for (const f of [".env", ".env.quickstart"]) {
254
+ if (!existsSync(f))
255
+ continue;
256
+ let text = "";
257
+ try {
258
+ text = readFileSync(f, "utf8");
259
+ }
260
+ catch {
261
+ continue;
262
+ }
263
+ for (const m of text.matchAll(/^\s*DECIDIO_(?:ORPHANED_)?TOKEN_JTI=(.+)$/gm))
264
+ for (const j of m[1].split(","))
265
+ if (j.trim())
266
+ out.add(j.trim());
267
+ }
268
+ return out;
269
+ }
270
+ function journalOwed() {
271
+ return journalOutstanding().filter((j) => j.label === "quickstart" || !j.persisted);
272
+ }
273
+ function journalUnheld() {
274
+ const held = credentialJtisOnDisk();
275
+ return journalOutstanding().filter((j) => j.label !== "quickstart" && j.persisted && !held.has(j.jti));
276
+ }
249
277
  const REFUSE_MINT_CORRUPT_JOURNAL = (e) => `✘ the token recovery journal cannot be trusted (${e.message}) - it may hold the id of a token that is still live. Refusing to mint on top of uncertain history. Inspect the file, revoke anything it names (npx @decidio/sdk tokens list --mine), and repair or move it aside; every byte is left as found. Nothing was minted.`;
250
278
  function journalProbe() {
251
279
  try {
@@ -272,7 +300,12 @@ function journalOutstanding() {
272
300
  for (const e of journalRows()) {
273
301
  if (e.event === "minted")
274
302
  minted.set(e.jti, { jti: e.jti, agentId: e.agentId, label: e.label, apiUrl: e.apiUrl, at: e.at });
275
- if (e.event === "retired")
303
+ if (e.event === "retired" && e.how === "persisted") {
304
+ const m = minted.get(e.jti);
305
+ if (m)
306
+ m.persisted = true;
307
+ }
308
+ if (e.event === "retired" && e.how === "revoked")
276
309
  minted.delete(e.jti);
277
310
  }
278
311
  return [...minted.values()];
@@ -337,7 +370,19 @@ function ensureGitignore() {
337
370
  appendFileSync(".gitignore", (existing && !existing.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n");
338
371
  log(`✔ added ${missing.join(" + ")} to .gitignore (new secret files here will not be committed)`);
339
372
  }
373
+ const APPROVE_WORDS = new Set(["y", "yes", "approve", "approved"]);
374
+ const REJECT_WORDS = new Set(["n", "no", "reject", "rejected", "deny", "denied"]);
375
+ export function approvalAnswer(answer) {
376
+ const a = String(answer ?? "").trim().toLowerCase();
377
+ if (APPROVE_WORDS.has(a))
378
+ return "approve";
379
+ if (REJECT_WORDS.has(a))
380
+ return "reject";
381
+ return "again";
382
+ }
340
383
  function ask(question, mute = false) {
384
+ if (process.stdin.readableEnded || process.stdin.destroyed)
385
+ return Promise.resolve("");
341
386
  return new Promise((resolve) => {
342
387
  const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
343
388
  if (mute) {
@@ -346,8 +391,9 @@ function ask(question, mute = false) {
346
391
  anyRl._writeToOutput = (s) => { if (s.includes(question))
347
392
  write?.(s); };
348
393
  }
349
- rl.question(question, (answer) => { rl.close(); if (mute)
350
- process.stderr.write("\n"); resolve(answer.trim()); });
394
+ rl.once("close", () => resolve(""));
395
+ rl.question(question, (answer) => { resolve(answer.trim()); rl.close(); if (mute)
396
+ process.stderr.write("\n"); });
351
397
  });
352
398
  }
353
399
  async function acquireSession(purpose) {
@@ -581,6 +627,10 @@ async function cmdDoctor() {
581
627
  }
582
628
  for (const j of journalItems) {
583
629
  const where = j.apiUrl && j.apiUrl !== API ? `DECIDIO_API_URL=${j.apiUrl} ` : "";
630
+ if (j.jti === env.DECIDIO_TOKEN_JTI) {
631
+ line(true, `token recovery journal: jti ${j.jti} is live and held by ${ENV_PATH} — it retires only when revoked (npx @decidio/sdk tokens revoke ${j.jti}; a tour token: quickstart cleanup).`);
632
+ continue;
633
+ }
584
634
  warn(`token recovery journal: jti ${j.jti} (${j.label ?? "token"}, agent ${j.agentId ?? "?"}, minted ${j.at ?? "?"} against ${j.apiUrl ?? API}) has no recorded revocation in this directory's journal — it may still be live. Revoke it, or confirm it is gone: ${where}npx @decidio/sdk quickstart cleanup (or: ${where}npx @decidio/sdk tokens revoke ${j.jti})`);
585
635
  }
586
636
  log(`✔ API URL: ${API}${(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL) ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
@@ -881,8 +931,9 @@ async function cmdQuickstart(opts = {}) {
881
931
  log(" This tour will: register one synthetic agent · mint a 7-DAY floor-limited token ·");
882
932
  log(" write .env (or .env.quickstart), .decidio-token-journal (revocation ids, no secrets) and .gitignore");
883
933
  log(" entries (incl. .decidio-pending/, the durable store your real agent will use) · save the receipt");
884
- log(" as decidio-receipt-<id>.json here (public; it stays) · create one synthetic decision and its");
885
- log(" sealed record (records are permanent by design). Cleanup offer at the end.");
934
+ log(" as decidio-receipt-<id>.json here (shareable: action, amount, policy, the human's reason - no secrets; it stays)");
935
+ log(" · create one synthetic decision and its sealed record (records are permanent by design) · wait up to");
936
+ log(" 5 minutes for your y (approve) or n (reject) at the human step. Cleanup offer at the end.");
886
937
  log("");
887
938
  const session = await acquireSession("The quickstart");
888
939
  if (!session)
@@ -911,7 +962,7 @@ async function cmdQuickstart(opts = {}) {
911
962
  else {
912
963
  err(` Check the target and your sign-in, then re-run: npx @decidio/sdk doctor`);
913
964
  }
914
- err(` Nothing was written to this directory.`);
965
+ err(` No credential was written to this directory (the only write so far is the .gitignore entries above, which are safe to keep).`);
915
966
  return 1;
916
967
  }
917
968
  let prevTourFile = null;
@@ -958,8 +1009,10 @@ async function cmdQuickstart(opts = {}) {
958
1009
  if (prevId && prevId !== agentId) {
959
1010
  if (prevJti) {
960
1011
  const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(prevJti)}`, { method: "DELETE" }, session);
961
- if (rv.status >= 200 && rv.status < 300)
1012
+ if (rv.status >= 200 && rv.status < 300) {
1013
+ journalRetire(prevJti, "revoked");
962
1014
  log(` ✔ the previous tour agent (${prevId}) had its token REVOKED before this tour replaced it — no live credential is left behind`);
1015
+ }
963
1016
  else {
964
1017
  carried.add(prevJti);
965
1018
  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}`);
@@ -1050,8 +1103,26 @@ async function cmdQuickstart(opts = {}) {
1050
1103
  log(` Nothing executes until someone decides.`);
1051
1104
  log("");
1052
1105
  const interactive = process.stdin.isTTY;
1106
+ let verdict = "approve";
1053
1107
  if (interactive) {
1054
- await ask(`③ YOU are the approver. Press Enter to approve it here (or approve it in your Decidio queue first, then Enter): `);
1108
+ verdict = "again";
1109
+ for (let attempt = 0; attempt < 3 && verdict === "again"; attempt++) {
1110
+ const answer = await ask(attempt === 0
1111
+ ? `③ YOU are the approver. Type y to approve or n to reject, then Enter (or decide it in your Decidio queue first, then answer here the same way): `
1112
+ : ` Please answer y (approve) or n (reject): `);
1113
+ verdict = approvalAnswer(answer);
1114
+ if (process.stdin.readableEnded || process.stdin.destroyed) {
1115
+ verdict = "again";
1116
+ break;
1117
+ }
1118
+ }
1119
+ if (verdict === "again") {
1120
+ log(`③ No decision was made (no y or n answer), so the tour stops here rather than guess.`);
1121
+ log(` The request is still ROUTED and waiting in your queue - approve or reject it there, or re-run the tour.`);
1122
+ log(` NOTE: this tour's credentials are still live in ${envFile} (a 7-day token + the agent's key). Once you`);
1123
+ log(` have decided - or if you abandon the tour - remove them: npx @decidio/sdk quickstart cleanup`);
1124
+ process.exit(3);
1125
+ }
1055
1126
  }
1056
1127
  else if (opts.autoApproveDemo) {
1057
1128
  log("③ --auto-approve-demo: approving with your session credential. This is AUTOMATED authority");
@@ -1078,67 +1149,119 @@ async function cmdQuickstart(opts = {}) {
1078
1149
  log(` or if you abandon the tour — remove them: npx @decidio/sdk quickstart cleanup`);
1079
1150
  process.exit(3);
1080
1151
  }
1081
- const ap = await api(`/api/decisions/${encodeURIComponent(decisionId)}/approve`, { method: "POST", body: JSON.stringify({ reason: opts.autoApproveDemo && !interactive ? "quickstart --auto-approve-demo (automated)" : "quickstart" }) }, session);
1082
- if (!(ap.status >= 200 && ap.status < 300)) {
1083
- err(`✘ approve failed (${ap.status}): ${JSON.stringify(ap.body)}`);
1084
- return failTour(1);
1085
- }
1086
- log(` ✔ approved${ap.body?.alreadyResolved ? " (it was already approved from your app — even better)" : ""}`);
1087
- let done;
1088
- try {
1089
- done = await running;
1090
- }
1091
- catch (e) {
1092
- err(`✘ the gated call did not complete: ${e?.message ?? e}`);
1093
- return failTour(1);
1094
- }
1095
- log(` ✔ your function executed AFTER approval: ${JSON.stringify(done.value)}`);
1096
- if (done.confirmation?.evidenceTier)
1097
- log(` …and the call itself told you what was recorded: confirmation.evidenceTier = ${done.confirmation.evidenceTier}`);
1098
- log("");
1099
- log("④ Downloading the sealed Authority Receipt and verifying it OFFLINE…");
1100
- const rec = await api(`/api/records/${encodeURIComponent(decisionId)}/verify`, {}, session);
1101
- if (!(rec.status >= 200 && rec.status < 300) || !rec.body?.credential) {
1102
- err(`✘ receipt fetch failed (${rec.status})`);
1103
- return failTour(1);
1104
- }
1105
- const fname = `decidio-receipt-${decisionId.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
1106
- writeFileSync(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
1107
- const issuer = rec.body?.issuer;
1108
- const v = verifyReceipt(rec.body.credential, issuer);
1109
- log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? `, PINNED to your workspace issuer ${issuer}` : ""})`);
1110
- if (!v.valid) {
1111
- err(` ✘ the receipt did not verify (${v.reason ?? "no reason given"}) — the tour did not complete.`);
1112
- return failTour(1);
1152
+ const rejected = verdict === "reject";
1153
+ if (rejected) {
1154
+ const rj = await api(`/api/decisions/${encodeURIComponent(decisionId)}/reject`, { method: "POST", body: JSON.stringify({ reason: "quickstart: rejected by the approver" }) }, session);
1155
+ if (!(rj.status >= 200 && rj.status < 300)) {
1156
+ err(`✘ reject failed (${rj.status}): ${JSON.stringify(rj.body)}`);
1157
+ return failTour(1);
1158
+ }
1159
+ const decidedInApp = rj.body?.alreadyResolved === true;
1160
+ log(decidedInApp ? ` • this decision was already decided from your app — the tour follows that decision` : ` ✔ rejected`);
1161
+ let leaked;
1162
+ let refused;
1163
+ try {
1164
+ leaked = await running;
1165
+ }
1166
+ catch (e) {
1167
+ refused = e;
1168
+ }
1169
+ if (refused === undefined && !decidedInApp) {
1170
+ err(`✘ the gated function RAN after the rejection (it returned ${JSON.stringify(leaked)}) — this is a defect, not a tour. Please report it: decidioai.com/#early-access`);
1171
+ return failTour(1);
1172
+ }
1173
+ const ranOnAppApproval = refused === undefined;
1174
+ if (ranOnAppApproval) {
1175
+ log(` ✔ your app APPROVED it first, so that decision stood and your function ran: ${JSON.stringify(leaked)}. Re-run the tour to watch a rejection.`);
1176
+ }
1177
+ else if (refused instanceof DecidioRejectedError) {
1178
+ log(` ✔ your function did NOT run — the wrapper refused: ${refused.message}`);
1179
+ log(` That is the product: a human said no, and nothing happened.${rj.body?.sealed === true ? " The denial is sealed on the record, as an approval would be." : ""}`);
1180
+ }
1181
+ else {
1182
+ err(`✘ the gated call ended with an error rather than a clean refusal: ${refused?.message ?? refused} — nothing ran, but the tour did not complete.`);
1183
+ return failTour(1);
1184
+ }
1185
+ log("");
1186
+ const secsR = Math.round((Date.now() - t0) / 1000);
1187
+ log(ranOnAppApproval
1188
+ ? `Done in ${secsR}s: gated → routed to a human → decided in your app (approved) → your function ran once.`
1189
+ : `Done in ${secsR}s: gated → routed to a human → REJECTED → your function never ran.`);
1190
+ if (!ranOnAppApproval)
1191
+ log(`Re-run the tour and answer y to watch the approval half, with its offline-verifiable receipt.`);
1192
+ log("");
1193
+ log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")}`);
1113
1194
  }
1114
- if (!issuer) {
1115
- err(` ⚠ the server did not return an issuer DID, so this check ran UNPINNED — it proves the`);
1116
- err(` receipt is internally consistent, NOT who issued it (a self-signed forgery also passes).`);
1117
- err(` Get your workspace's issuer DID from the app's record-verify panel, then re-check:`);
1118
- err(` npx @decidio/sdk verify --issuer <did:key:...> ${fname}`);
1119
- err(` ✘ the tour does not count an UNPINNED check as success.`);
1120
- return failTour(1);
1195
+ else {
1196
+ const ap = await api(`/api/decisions/${encodeURIComponent(decisionId)}/approve`, { method: "POST", body: JSON.stringify({ reason: opts.autoApproveDemo && !interactive ? "quickstart --auto-approve-demo (automated)" : "quickstart" }) }, session);
1197
+ if (!(ap.status >= 200 && ap.status < 300)) {
1198
+ err(`✘ approve failed (${ap.status}): ${JSON.stringify(ap.body)}`);
1199
+ return failTour(1);
1200
+ }
1201
+ log(` ✔ approved${ap.body?.alreadyResolved ? " (already decided from your app — the tour follows that decision)" : ""}`);
1202
+ let done;
1203
+ try {
1204
+ done = await running;
1205
+ }
1206
+ catch (e) {
1207
+ if (e instanceof DecidioRejectedError) {
1208
+ err(`✘ it was REJECTED from your app before this tour's approval landed — your function did NOT run (that is the product). Re-run the tour to watch the approval half.`);
1209
+ return failTour(1);
1210
+ }
1211
+ err(`✘ the gated call did not complete: ${e?.message ?? e}`);
1212
+ return failTour(1);
1213
+ }
1214
+ log(` ✔ your function executed AFTER approval: ${JSON.stringify(done.value)}`);
1215
+ if (done.confirmation?.evidenceTier)
1216
+ log(` …and the call itself told you what was recorded: confirmation.evidenceTier = ${done.confirmation.evidenceTier}`);
1217
+ log("");
1218
+ log("④ Downloading the sealed Authority Receipt and verifying it OFFLINE…");
1219
+ const rec = await api(`/api/records/${encodeURIComponent(decisionId)}/verify`, {}, session);
1220
+ if (!(rec.status >= 200 && rec.status < 300) || !rec.body?.credential) {
1221
+ err(`✘ receipt fetch failed (${rec.status})`);
1222
+ return failTour(1);
1223
+ }
1224
+ const fname = `decidio-receipt-${decisionId.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
1225
+ writeFileSync(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
1226
+ const issuer = rec.body?.issuer;
1227
+ const v = verifyReceipt(rec.body.credential, issuer);
1228
+ log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? `, PINNED to your workspace issuer ${issuer}` : ""})`);
1229
+ if (!v.valid) {
1230
+ err(` ✘ the receipt did not verify (${v.reason ?? "no reason given"}) — the tour did not complete.`);
1231
+ return failTour(1);
1232
+ }
1233
+ if (!issuer) {
1234
+ err(` ⚠ the server did not return an issuer DID, so this check ran UNPINNED — it proves the`);
1235
+ err(` receipt is internally consistent, NOT who issued it (a self-signed forgery also passes).`);
1236
+ err(` Get your workspace's issuer DID from the app's record-verify panel, then re-check:`);
1237
+ err(` npx @decidio/sdk verify --issuer <did:key:...> ${fname}`);
1238
+ err(` ✘ the tour does not count an UNPINNED check as success.`);
1239
+ return failTour(1);
1240
+ }
1241
+ if (issuer)
1242
+ log(` Keep that DID: it is your trust anchor — npx @decidio/sdk verify --issuer ${issuer} <receipt.json>`);
1243
+ const detail = await api(`/api/records/${encodeURIComponent(decisionId)}`, {}, session);
1244
+ const tier = detail.body?.record?.confirmation?.status;
1245
+ if (tier)
1246
+ log(` ✔ execution evidence on the record: ${tier} — the wrapper's signed report. (The receipt seals the DECISION; execution evidence accrues on the record — see the README.)`);
1247
+ log("");
1248
+ const secs = Math.round((Date.now() - t0) / 1000);
1249
+ log(`Done in ${secs}s: gated → routed to a human → approved → your function executed once → an authority-decision receipt you can verify offline.`);
1250
+ log("");
1251
+ log("Take it to your real agent — the .env here already works:");
1252
+ log(` import { guard } from "@decidio/sdk";`);
1253
+ log(` const doIt = guard.protect(doItRaw, (a) => ({ action: "yourAction", amount: a.amount, scope: "YourScope" }));`);
1254
+ log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
1255
+ log("");
1256
+ log(`Before launching agent code from THIS shell, unset DECIDIO_SESSION_TOKEN if you exported it: child processes`);
1257
+ log(`inherit exported variables, and that credential is your workspace login, not the agent's.`);
1258
+ log("");
1259
+ log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")}`);
1260
+ log(`Next: the README section "Production mode: durable async resume" (the worker; the engine adapters follow it).`);
1261
+ log(`The README's seven-step quickstart is this same blocking flow in your own code - if you keep this agent, its .env`);
1262
+ log(`already works here, so skip that flow's init step.`);
1263
+ log(`Built by OmniTwin — decidioai.com · Putting this in front of real approvals? decidioai.com/#early-access`);
1121
1264
  }
1122
- if (issuer)
1123
- log(` Keep that DID: it is your trust anchor — npx @decidio/sdk verify --issuer ${issuer} <receipt.json>`);
1124
- const detail = await api(`/api/records/${encodeURIComponent(decisionId)}`, {}, session);
1125
- const tier = detail.body?.record?.confirmation?.status;
1126
- if (tier)
1127
- log(` ✔ execution evidence on the record: ${tier} — the wrapper's signed report. (The receipt seals the DECISION; execution evidence accrues on the record — see the README.)`);
1128
- log("");
1129
- const secs = Math.round((Date.now() - t0) / 1000);
1130
- log(`Done in ${secs}s: gated → routed to a human → approved → your function executed once → an authority-decision receipt you can verify offline.`);
1131
- log("");
1132
- log("Take it to your real agent — the .env here already works:");
1133
- log(` import { guard } from "@decidio/sdk";`);
1134
- log(` const doIt = guard.protect(doItRaw, (a) => ({ action: "yourAction", amount: a.amount, scope: "YourScope" }));`);
1135
- log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
1136
- log("");
1137
- log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")}`);
1138
- log(`Next: the README section "Production mode: durable async resume" (the worker; the engine adapters follow it).`);
1139
- log(`The README's seven-step quickstart is this same blocking flow in your own code - if you keep this agent, its .env`);
1140
- log(`already works here, so skip that flow's init step.`);
1141
- log(`Built by OmniTwin — decidioai.com · Putting this in front of real approvals? decidioai.com/#early-access`);
1142
1265
  let doCleanup = false;
1143
1266
  if (interactive) {
1144
1267
  const answer = await ask(`\nKeep this agent for development? [Enter = keep · c + Enter = clean up now]: `);
@@ -1151,7 +1274,7 @@ async function cmdQuickstart(opts = {}) {
1151
1274
  log(`\nAutomated run: cleaning the tour up (pass --keep-agent to keep the agent + token).`);
1152
1275
  }
1153
1276
  const cleanupCode = doCleanup ? await quickstartCleanup(session, envFile) : 0;
1154
- return v.valid ? (cleanupCode || 0) : 1;
1277
+ return cleanupCode || 0;
1155
1278
  }
1156
1279
  async function quickstartCleanup(session, envFile) {
1157
1280
  const env = (() => {
@@ -1172,13 +1295,25 @@ async function quickstartCleanup(session, envFile) {
1172
1295
  let journalRevoked = 0;
1173
1296
  const journalStuck = [];
1174
1297
  let outstanding = [];
1298
+ let heldElsewhere = [];
1299
+ let unheld = [];
1300
+ let journalReconciled = 0;
1175
1301
  try {
1176
- outstanding = journalOutstanding();
1302
+ const ownIds = new Set([env.DECIDIO_TOKEN_JTI, ...(env.DECIDIO_ORPHANED_TOKEN_JTI ?? "").split(",").map((s) => s.trim())].filter(Boolean));
1303
+ outstanding = journalOwed().filter((j) => !ownIds.has(j.jti));
1304
+ unheld = journalUnheld();
1305
+ const owedIds = new Set(outstanding.map((j) => j.jti));
1306
+ const unheldIds = new Set(unheld.map((j) => j.jti));
1307
+ heldElsewhere = journalOutstanding().filter((j) => !owedIds.has(j.jti) && !unheldIds.has(j.jti) && !ownIds.has(j.jti));
1177
1308
  }
1178
1309
  catch (e) {
1179
1310
  err(`✘ the token recovery journal cannot be trusted (${e?.message ?? e}). It may hold the id of a live token, so this cleanup cannot claim nothing is owed. Every byte is left as found: inspect ${JOURNAL_PATH}, revoke anything it names (npx @decidio/sdk tokens list --mine), then repair or move it aside and re-run.`);
1180
1311
  return 1;
1181
1312
  }
1313
+ for (const j of heldElsewhere)
1314
+ log(` • journalled token ${j.jti} (${j.label ?? "token"}) is live and still held by a credential file here — not a tour token, left alone (revoke it yourself: npx @decidio/sdk tokens revoke ${j.jti})`);
1315
+ for (const j of unheld)
1316
+ err(` ⚠ journalled token ${j.jti} (${j.label ?? "token"}) is live: it was written to a credential file that is no longer here — not a tour token, so this cleanup will not revoke it. If it is yours and lost, revoke it: npx @decidio/sdk tokens revoke ${j.jti}`);
1182
1317
  for (const j of outstanding) {
1183
1318
  if (j.apiUrl && j.apiUrl !== API) {
1184
1319
  journalStuck.push(j.jti);
@@ -1188,11 +1323,14 @@ async function quickstartCleanup(session, envFile) {
1188
1323
  const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(j.jti)}`, { method: "DELETE" }, session);
1189
1324
  if (rv.status >= 200 && rv.status < 300) {
1190
1325
  journalRetire(j.jti, "revoked");
1191
- journalRevoked++;
1192
- if (rv.body?.changed === false)
1326
+ if (rv.body?.changed === false) {
1327
+ journalReconciled++;
1193
1328
  log(` • journalled token ${j.jti} was already revoked — retiring its journal entry.`);
1194
- else
1195
- log(` ✔ journalled token revoked (jti ${j.jti}${j.label ? `, ${j.label}` : ""}) — its credential file never landed, so this was its only handle`);
1329
+ }
1330
+ else {
1331
+ journalRevoked++;
1332
+ log(` ✔ journalled token revoked (jti ${j.jti}${j.label ? `, ${j.label}` : ""}) — from the journal: ${j.persisted ? "its credential file is gone" : "its credential file never landed"}, so the journal was the handle`);
1333
+ }
1196
1334
  }
1197
1335
  else {
1198
1336
  journalStuck.push(j.jti);
@@ -1209,8 +1347,10 @@ async function quickstartCleanup(session, envFile) {
1209
1347
  const stillOrphaned = [];
1210
1348
  for (const jti of orphans) {
1211
1349
  const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, session);
1212
- if (rv.status >= 200 && rv.status < 300)
1350
+ if (rv.status >= 200 && rv.status < 300) {
1351
+ journalRetire(jti, "revoked");
1213
1352
  log(` ✔ orphaned token revoked (jti ${jti}) — it had survived an earlier replace`);
1353
+ }
1214
1354
  else {
1215
1355
  stillOrphaned.push(jti);
1216
1356
  err(` ⚠ orphaned token ${jti} could NOT be revoked (${rv.status}) — it is still live.`);
@@ -1224,6 +1364,8 @@ async function quickstartCleanup(session, envFile) {
1224
1364
  }
1225
1365
  if (env.DECIDIO_TOKEN_JTI) {
1226
1366
  const rev = await api(`/api/workspace/agent-token/${encodeURIComponent(env.DECIDIO_TOKEN_JTI)}`, { method: "DELETE" }, session);
1367
+ if (rev.status >= 200 && rev.status < 300)
1368
+ journalRetire(env.DECIDIO_TOKEN_JTI, "revoked");
1227
1369
  if (rev.status >= 200 && rev.status < 300 && rev.body?.changed === false)
1228
1370
  log(` • token was already revoked (jti ${env.DECIDIO_TOKEN_JTI}) — nothing to do.`);
1229
1371
  else if (rev.status >= 200 && rev.status < 300)
@@ -1238,6 +1380,12 @@ async function quickstartCleanup(session, envFile) {
1238
1380
  else if (journalRevoked > 0 && !existsSync(envFile) || (journalRevoked > 0 && Object.keys(env).length === 0)) {
1239
1381
  log(` • no credential file to clean: the journal sweep revoked ${journalRevoked} token(s) this directory owed.`);
1240
1382
  }
1383
+ else if (!existsSync(envFile)) {
1384
+ if (unheld.length)
1385
+ err(` • no credential file here (${envFile} is already gone); the journal names ${unheld.length} live token(s) this cleanup will not revoke (above).`);
1386
+ else
1387
+ log(` • no credential file here (${envFile} is already gone) and the token recovery journal owes nothing further${journalReconciled ? ` (${journalReconciled} already-revoked row(s) reconciled)` : ""}.`);
1388
+ }
1241
1389
  else {
1242
1390
  err(`✘ no DECIDIO_TOKEN_JTI in ${envFile} (older tour) — this tour's token is STILL LIVE and this`);
1243
1391
  err(` command cannot revoke it. Nothing was deleted. Find and revoke it, then re-run cleanup:`);
@@ -1281,6 +1429,8 @@ async function quickstartCleanup(session, envFile) {
1281
1429
  }
1282
1430
  log(` • What remains, by design: the sealed decision record (records are permanent — that is the product)`);
1283
1431
  log(` and the agent's registration row (inert without key or token; server-side deregistration is on the roadmap).`);
1432
+ if (existsSync(JOURNAL_PATH))
1433
+ log(` • Also kept: ${JOURNAL_PATH} — the ids and timestamps of what this directory minted and revoked (no secrets). Gitignored; owner-only where the OS supports it.`);
1284
1434
  if (existsSync(".decidio-pending")) {
1285
1435
  log(` • Also kept: .decidio-pending/ — this agent's own execution ledger: whatever this agent parked (call arguments,`);
1286
1436
  log(` execution evidence; never credentials, by design). Gitignored; owner-only where the OS supports it. If nothing is`);
@@ -1358,6 +1508,13 @@ async function cmdTokens(sub, jti, mineOnly = false) {
1358
1508
  return 0;
1359
1509
  }
1360
1510
  function cmdDev(target, port) {
1511
+ try {
1512
+ assertSafeTransport(target, true);
1513
+ }
1514
+ catch (e) {
1515
+ err(`✘ dev relay refused: ${e?.message ?? e}`);
1516
+ return 2;
1517
+ }
1361
1518
  log(`decidio dev relay on http://localhost:${port}/decidio/resume → forwarding to ${target}`);
1362
1519
  createServer((req, res) => {
1363
1520
  if (req.method !== "POST") {
@@ -1516,7 +1673,7 @@ async function main(argv) {
1516
1673
  const envFile = existsSync(".env.quickstart") ? ".env.quickstart"
1517
1674
  : (readEnvFile().DECIDIO_AGENT_ID ?? "").startsWith("quickstart-") ? ENV_PATH
1518
1675
  : (() => { try {
1519
- return journalOutstanding().length ? ".env.quickstart" : undefined;
1676
+ return (journalOwed().length || journalUnheld().length) ? ".env.quickstart" : undefined;
1520
1677
  }
1521
1678
  catch {
1522
1679
  return ".env.quickstart";
@@ -1614,5 +1771,19 @@ async function main(argv) {
1614
1771
  return 2;
1615
1772
  }
1616
1773
  }
1617
- main(process.argv).then((code) => { if (code !== 0)
1618
- process.exitCode = code; }).catch((e) => { console.error(`✘ unexpected error: ${e?.message ?? e}`); process.exit(1); });
1774
+ const invokedAsProgram = (() => {
1775
+ const entry = process.argv[1];
1776
+ if (!entry)
1777
+ return true;
1778
+ if (/(^|[\\/])(cli\.(js|mjs|ts)|decidio(\.cmd|\.ps1)?)$/i.test(entry))
1779
+ return true;
1780
+ try {
1781
+ return toFileUrl(realPath(entry)).href === toFileUrl(realPath(fromFileUrl(import.meta.url))).href;
1782
+ }
1783
+ catch {
1784
+ return true;
1785
+ }
1786
+ })();
1787
+ if (invokedAsProgram)
1788
+ main(process.argv).then((code) => { if (code !== 0)
1789
+ process.exitCode = code; }).catch((e) => { console.error(`✘ unexpected error: ${e?.message ?? e}`); process.exit(1); });
package/dist/inngest.d.ts CHANGED
@@ -31,13 +31,21 @@ export interface InngestClientLike {
31
31
  * `confirmation` is CARRIED rather than discarded. It used to be thrown away inside the confirm
32
32
  * step, which made "it ran and Decidio recorded it" and "it ran and Decidio does not know"
33
33
  * indistinguishable to the caller. */
34
- export type GateStepOutcome<R> = {
35
- status: "executed" | "rejected" | "blocked";
34
+ export type GateStepOutcome<R> =
35
+ /** The action ran: the value AND what Decidio recorded (the step confirms before it returns). Encoded in the
36
+ * type, not only in prose (prodsec V040-02): narrow on `status` and `confirmation` is there. */
37
+ {
38
+ status: "executed";
36
39
  decisionId: string;
37
- value?: R;
38
- /** Always present when `status` is "executed" (the step confirms before it returns); optional in the type
39
- * because the other statuses carry none. */
40
- confirmation?: EvidenceOutcome;
40
+ value: R;
41
+ confirmation: EvidenceOutcome;
42
+ }
43
+ /** The action never ran — a human said no, or a rule blocked it — so there is nothing to confirm. */
44
+ | {
45
+ status: "rejected" | "blocked";
46
+ decisionId: string;
47
+ value?: undefined;
48
+ confirmation?: undefined;
41
49
  };
42
50
  /**
43
51
  * Gate an action INSIDE an Inngest function, with durable suspend on routing.
package/dist/resume.d.ts CHANGED
@@ -455,7 +455,13 @@ export declare class DecidioSuspendedError extends Error {
455
455
  * to read `unknown`, the word for "not ours" (prodsec V033-06). An `invoking` row is NOT final and answers
456
456
  * `indeterminate` (prodsec V035-02). Both members are additive: an exhaustive switch over this union
457
457
  * gains a case per release, which the changelog announces. */
458
- export type ResumeStatus = "executed" | "rejected" | "blocked" | "no_handler" | "unknown" | "indeterminate" | "already_resolved";
458
+ /** `lost_race` (0.4.1): this signal lost a durable transition race to ANOTHER ATTEMPT that is still in flight
459
+ * (it holds the pre-invocation claim, or crossed the invocation boundary first), or the boundary could not be
460
+ * recorded at all - and it did NOTHING. Nothing ran on this signal; the winner reports, or the entry stays
461
+ * parked for a later signal. It used to read `unknown` (the word for "not ours") for the claim loser and
462
+ * `no_handler` for the boundary loser although a handler was registered (prodsec V040-01). A loss to a FINAL
463
+ * answer (a denial, an execution) stays `already_resolved`. Additive, like the two above. */
464
+ export type ResumeStatus = "executed" | "rejected" | "blocked" | "no_handler" | "unknown" | "indeterminate" | "already_resolved" | "lost_race";
459
465
  /** The states a durable action can actually be in — every one of them nameable, including the
460
466
  * one nobody likes. */
461
467
  export type RecoveryState =
package/dist/resume.js CHANGED
@@ -951,7 +951,7 @@ export function createDecidioResume(opts) {
951
951
  if (refusalWarned.has(decisionId))
952
952
  return;
953
953
  refusalWarned.add(decisionId);
954
- console.error(msg + (tail ?? " (This prints once — the entry stays parked until you act.)"));
954
+ console.error(msg + (tail ?? " (This prints once per worker process — the entry stays parked until you act.)"));
955
955
  };
956
956
  const ctrl = {
957
957
  ...opts,
@@ -991,8 +991,18 @@ export function createDecidioResume(opts) {
991
991
  if (idProblem)
992
992
  return { status: "unknown", decisionId: String(decisionId ?? ""), reason: `not a decision id this SDK will act on (${idProblem})` };
993
993
  const pending = await ctrl.store.get(decisionId);
994
- if (!pending)
994
+ if (!pending) {
995
+ const head = await ctrl.store.getExecution(decisionId).catch(() => undefined);
996
+ if (head?.state === "discarded")
997
+ return { status: "already_resolved", decisionId, reason: `this decision was DENIED (${head.verdict ?? "rejected"}); that answer is final and this signal did nothing` };
998
+ if (head?.state === "executed_unreported" || head?.state === "reported")
999
+ return { status: "already_resolved", decisionId, reason: `this decision already ran (durable state ${head.state}); this signal arrived after it and did nothing` };
1000
+ if (head?.state === "invoking" || head?.state === "indeterminate")
1001
+ return { status: "indeterminate", decisionId, reason: `this decision is in durable state ${head.state}: its action may still be running or may have finished without reporting - it may or may not have run. Nothing will retry it; check the worker and the downstream system, then settle it with reconcile("${decisionId}", …).` };
1002
+ if (head?.state === "claimed_preinvoke")
1003
+ return { status: "lost_race", decisionId, reason: `another attempt holds the pre-invocation claim on this decision; this signal did nothing. If that attempt is alive it will report; if it died, recover() lists the decision and reconcile() can settle it` };
995
1004
  return { status: "unknown", decisionId };
1005
+ }
996
1006
  if (verdict === "rejected" || verdict === "blocked") {
997
1007
  await ctrl.store.markDiscarded(decisionId, verdict, reason);
998
1008
  await ctrl.store.delete(decisionId);
@@ -1036,7 +1046,7 @@ export function createDecidioResume(opts) {
1036
1046
  if (claim.state && NON_REPLAYABLE.includes(claim.state)) {
1037
1047
  refuseOnce(decisionId, claim.state === "discarded"
1038
1048
  ? `[decidio] REFUSING to execute ${decisionId}: it was DENIED (${(await ctrl.store.getExecution(decisionId))?.verdict ?? "rejected"}) and that answer is final. Nothing ran. A denial cannot be reconciled or retried — if this action should now proceed, it needs a NEW request and a new approval.`
1039
- : `[decidio] REFUSING to execute ${decisionId}: its durable state is ${claim.state} — invocation already began, so re-running it could duplicate a committed effect. Inspect it with guard.resume.recover(); settle it with guard.resume.reconcile("${decisionId}", …).`, claim.state === "discarded" ? " (This prints once. The decision is closed — no action is needed.)" : undefined);
1049
+ : `[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 per worker process. The decision is closed — no action is needed.)" : undefined);
1040
1050
  if (claim.state === "invoking" || claim.state === "indeterminate") {
1041
1051
  return finish({ status: "indeterminate", decisionId, action: pending.action, reason: claim.state === "invoking"
1042
1052
  ? `this decision is in durable state invoking: its action may still be running, may have crashed before any effect, or may have finished without reporting - it may or may not have run. Nothing will retry it; check the worker and the downstream system, then settle it with reconcile("${decisionId}", …).`
@@ -1044,7 +1054,7 @@ export function createDecidioResume(opts) {
1044
1054
  }
1045
1055
  return finish({ status: "already_resolved", decisionId, action: pending.action, reason: claim.state === "discarded" ? `this decision was already DENIED (${(await ctrl.store.getExecution(decisionId))?.verdict ?? "rejected"}); that answer is final and this signal lost the race to it` : `this decision already ran (durable state ${claim.state}); this signal lost the race to it` });
1046
1056
  }
1047
- return { status: "unknown", decisionId };
1057
+ return finish({ status: "lost_race", decisionId, action: pending.action, reason: `another attempt holds the pre-invocation claim on this decision (durable state ${claim.state}); this signal lost the race and did nothing. If that attempt is alive it will report; if it died, recover() lists the decision and reconcile() can settle it` });
1048
1058
  }
1049
1059
  try {
1050
1060
  await ctrl.store.markInvoking(decisionId, attemptId);
@@ -1052,7 +1062,14 @@ export function createDecidioResume(opts) {
1052
1062
  catch (e) {
1053
1063
  await ctrl.store.releasePreinvoke(decisionId, attemptId).catch(() => { });
1054
1064
  console.error(`[decidio] ${decisionId}: could not record the invocation boundary (${e?.message ?? e}) — refusing to run the action rather than run it unrecorded.`);
1055
- return finish({ status: "no_handler", decisionId, action: pending.action, reason: "could not durably record the invocation boundary" });
1065
+ const head = await ctrl.store.getExecution(decisionId).catch(() => undefined);
1066
+ if (head?.state === "discarded")
1067
+ return finish({ status: "already_resolved", decisionId, action: pending.action, reason: `this decision was DENIED (${head.verdict ?? "rejected"}) while this signal was about to invoke; that answer is final and nothing ran` });
1068
+ if (head?.state === "executed_unreported" || head?.state === "reported")
1069
+ return finish({ status: "already_resolved", decisionId, action: pending.action, reason: `this decision already ran (durable state ${head.state}); this signal lost the race to it and did nothing` });
1070
+ if (head?.state === "invoking" || head?.state === "indeterminate")
1071
+ return finish({ status: "lost_race", decisionId, action: pending.action, reason: `another attempt crossed the invocation boundary first (durable state ${head.state}); this signal did nothing - the winner reports, or reconcile() settles it` });
1072
+ return finish({ status: "lost_race", decisionId, action: pending.action, reason: `could not durably record the invocation boundary (${e?.message ?? e}); nothing ran, the entry stays parked, and a later signal may retry` });
1056
1073
  }
1057
1074
  let result;
1058
1075
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decidio/sdk",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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",
@@ -83,7 +83,7 @@
83
83
  "prepublishOnly": "npm run typecheck && npm run build && node ../publish-boundary/check.mjs --only @decidio/sdk"
84
84
  },
85
85
  "dependencies": {
86
- "@decidio/verify": "0.4.0"
86
+ "@decidio/verify": "0.4.1"
87
87
  },
88
88
  "author": "OmniTwin Technologies Inc.",
89
89
  "bugs": {