@decidio/sdk 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,92 @@
1
+ # Changelog — @decidio/sdk
2
+
3
+ Release notes for the client SDK. The Python package `decidio` ships the same changes under the same
4
+ version (the two are conformance-locked twins). Each entry names what changed, what a reader must do
5
+ on upgrade, and what proved it.
6
+
7
+ ## 0.4.0 — the scope statement
8
+
9
+ - **README: "What 0.4 does not yet claim" and "Compatibility and support."** The stability promise is
10
+ stated: what is stable, what is Beta (the engine adapters), what is deprecated and kept until 1.0
11
+ (`protectBestEffort`, `gateInterruptions`), and the five things reviewers keep finding that only
12
+ product decisions close (trust-on-first-use issuer DID, the unshipped schema, the unshipped
13
+ conformance suites, provenance and the repository link). 1.0.0 follows user-acceptance testing.
14
+ - **The tour fails closed on an unpinned check.** If the API omits the issuer DID, the tour used to warn
15
+ and still report success; it now fails and cleans up.
16
+ - **The tour's preflight names everything it writes**, including `.decidio-token-journal` and
17
+ `.decidio-pending/`.
18
+ - **`--` ends option parsing on all three verifier doors**, so a receipt saved under a dash-leading
19
+ name can be verified.
20
+ - **The token recovery journal treats corruption as uncertainty, never absence** (prodsec retest of
21
+ 0.3.5). Every row is parsed and schema-checked before any mint; a row that cannot be trusted refuses
22
+ `init` and `quickstart`, turns `doctor` red and makes `quickstart cleanup` exit non-zero - the bytes are
23
+ left as found. Appends are framed (a delimiter first if the previous row lost its newline, a schema
24
+ version, a write loop before the fsync), so a torn row stays discoverable.
25
+ - **A resume signal that finds the decision `invoking` answers `indeterminate`**, not `already_resolved`
26
+ with "already ran": the action may still be running, may have crashed before any effect, or may have
27
+ finished without reporting. `already_resolved` is reserved for the final states.
28
+ - **Temporal (Beta): a custom `confirm` may return the gate's confirm outcome** (validated with
29
+ `readConfirmResponse`) and it becomes the step's confirmation instead of being discarded.
30
+ - **Transport: every refusal redacts URL userinfo**, including the opaque `scheme:user:secret@` spelling,
31
+ and Unicode space and format characters (NBSP, ideographic space, zero-width space, …) are refused in
32
+ both runtimes from one explicit list. Four cases joined the shared transport corpus.
33
+ - **This file ships** in the tarball (and in the Python sdist), as a deliberate publish-boundary line.
34
+
35
+ ## 0.3.5 — the retest residuals
36
+
37
+ - **Transport:** any control character or whitespace anywhere in the URL is refused before anything is
38
+ sent; refusals echo a redacted spelling (URL userinfo is a credential).
39
+ - **Credential lifecycle:** the quickstart inspects a previous `.env.quickstart` before minting; secret
40
+ files are written temp + fsync + rename; **the token recovery journal** (`.decidio-token-journal`,
41
+ owner-only, append-only) records every minted token id before the credential file exists and retires
42
+ it on a durable write or any proven revoke. Credential creation fails closed if the journal cannot be
43
+ written. `quickstart cleanup` sweeps it; `doctor` lists outstanding entries.
44
+ - **Verifiers (all three doors):** the `verificationMethod` must be the issuer's exact `did:key` method
45
+ identifier, and `created` must be a real calendar date-time. Correctly signed receipts naming a
46
+ nonexistent method fragment or an impossible time are now INVALID.
47
+ - **Adapter gate:** the same describe validation as the core (a blank action or negative amount is
48
+ refused before signing or posting).
49
+ - **Outcomes:** a resume signal that lost the race to a final answer is `already_resolved`; a parked row
50
+ whose payload is gone is `orphaned_state`.
51
+ - Upgrade: no action. Custom stores are unaffected.
52
+
53
+ ## 0.3.4 — first-run consistency
54
+
55
+ - Tour and README pointers, routing-by-default vs the universal-backstop block named as two states, the
56
+ stale "unsafe values park and fail on resume" paragraph corrected, cleanup discloses the durable
57
+ store, help never loads `.env` (and only when usage will actually print), adapter headers current.
58
+ - Upgrade: no action.
59
+
60
+ ## 0.3.2 / 0.3.3 — the transition itself is atomic (COORDINATED UPGRADE)
61
+
62
+ - **The shipped file stores are an append-only revision chain** (`<key>.rev.<N>`), published with a
63
+ hard link that refuses an existing name: exactly one writer wins each transition, and a loser
64
+ re-validates against what actually happened. This closed the pre-0.3.2 race in which a denial could
65
+ be written and returned and the denied action still run.
66
+ - **Upgrade — read this if a directory is shared by more than one worker:** the first 0.3.2+ store
67
+ migrates the older marker layout one way. **Stop every older worker that shares the directory before
68
+ the first 0.3.2+ process starts**, then upgrade them together. An older worker writing into an
69
+ upgraded directory leaves a marker beside the chain, and that decision reads as unreadable by design.
70
+ Nothing deletes a revision; each decision keeps a handful of small files (see the README's sizing
71
+ envelope).
72
+ - 0.3.3: one snapshot of `describe()`'s answer for signing and posting; the shared id grammar at every
73
+ exported resume door; a compensating revoke when a minted token's credential file fails to write;
74
+ `reconfirm()` on a reported decision tells the truth about confirmation.
75
+
76
+ ## 0.3.1
77
+
78
+ - The verifier says what it proves (canonical signed claims, not exact bytes); proof options
79
+ (`proofPurpose`, `verificationMethod`, `created`) are part of the claim on every door; duplicate
80
+ `--issuer` flags are refused everywhere; orphaned token ids survive every replacement.
81
+
82
+ ## 0.3.0
83
+
84
+ - A claim that gates nothing: `reportRecorded` and `executionConfirmed` are distinct, honest booleans.
85
+ A denial is final on every path. The decision-id grammar is defined server-side and mirrored here.
86
+ - Upgrade for custom stores: `markDiscarded` is required (a hard construction failure without it).
87
+
88
+ ## 0.2.x
89
+
90
+ - The execution state machine (`parked → claimed_preinvoke → invoking → executed_unreported |
91
+ indeterminate → reported`), `reconcile()`, `ExecutionContext` with a stable idempotency key, and the
92
+ composite return (`{ value, decisionId, confirmation }`) with `protectBestEffort` keeping the old shape.
package/README.md CHANGED
@@ -66,7 +66,8 @@ stderr each run — set `DECIDIO_API_URL` (env or `.env`) to point at another De
66
66
  `init` signs you into your workspace (email + password, or paste the sign-in link from your
67
67
  workspace email), generates the agent's Ed25519 keypair **locally** (only the public `did:key`
68
68
  is sent), registers the agent, mints its **floor-limited API token** (limited to the agent runtime — request authorization, poll status, report signed confirmation; it cannot administer the workspace or approve anything — and your workspace session never touches the agent's disk), and writes `.env`.
69
- Every later command reads `.env` from this directory automatically. Re-running init for an
69
+ Every later command that talks to Decidio reads `.env` from this directory automatically (`verify` is
70
+ offline and never does). Re-running init for an
70
71
  agent that already has a token REPLACES it — the server keeps one active token per agent, so
71
72
  the previous one is revoked and init says so.
72
73
 
@@ -677,6 +678,45 @@ Every error raised after a decision exists carries the `decisionId`, so a suspen
677
678
  timed-out action is always findable in
678
679
  your queue.
679
680
 
681
+ ## What 0.4 does not yet claim
682
+
683
+ The retest that graded 0.3.3 *A- for the core and the shipped stores* and the first-run reviews of
684
+ 0.3.3 and 0.3.5 agree on what is left, and none of it is a defect a patch release closes. Stated here so
685
+ the next reader grades a promise, not a moving target:
686
+
687
+ - **Engine adapters are Beta and outside the stability promise.** They pass the same conformance
688
+ battery as the core against structural mocks; no live LangGraph/Temporal/Inngest/OpenAI restart suite
689
+ exists yet. Use the core path for production until it does.
690
+ - **The issuer DID is trust on first use.** The tour and `receipt` print your workspace's issuer DID
691
+ from the same authenticated response that carries the receipt. Pinned verification is only as strong
692
+ as where you keep that DID: record it through a channel you trust independently (your app's
693
+ record-verify panel today; a published well-known location is on the roadmap).
694
+ - **Verification is cryptographic, not schema validation.** `VALID` means the issuer's key signed these
695
+ canonical claims; the sealed-payload schema the receipt names by hash is not shipped, so a
696
+ structurally odd but correctly signed document is not refused on shape. Shipping the schema is on
697
+ the roadmap.
698
+ - **The conformance suites are not in the package.** The deterministic two-process schedules that
699
+ prove the durable store, and the corpus every door is held to, live in the Decidio source repository.
700
+ A customer cannot rerun them from this tarball; a public conformance kit is on the roadmap.
701
+ - **No provenance attestation, no repository link.** The source repository is private; npm refuses
702
+ provenance for private repositories, and a link that 404s is worse than none. Registry signatures and
703
+ lockfile integrity are what you can check today.
704
+
705
+ ## Compatibility and support
706
+
707
+ - **Stable in 0.4:** `guard.protect` / `withApproval` and their options; the durable resume
708
+ controller (`createDecidioResume`: `park`, `resolve`, `reconcile`, `reconfirm`, `recover`, the worker
709
+ and both webhook handlers); the `PendingStore` contract and both shipped stores, including the on-disk
710
+ revision-chain layout; `EvidenceOutcome` (a root export) and `ResumeStatus` / `RecoveryState` (on the
711
+ `@decidio/sdk/resume` subpath), additive growth only; the error classes; the CLI's commands, flags and exit codes; the receipt verifier's verdict and
712
+ reasons. Changes to any of these announce themselves in the changelog first.
713
+ - **Beta:** the engine adapters (above).
714
+ - **Deprecated, kept until 1.0:** `protectBestEffort` (the value-only shape) and the old adapter
715
+ name `gateInterruptions`. They will be removed in 1.0.0, not before.
716
+ - **Versioning:** 0.x minor releases may still carry deliberate breaking cleanups, announced in the
717
+ changelog with the upgrade step. 1.0.0 follows user-acceptance testing of this release and freezes the
718
+ surface above.
719
+
680
720
  ## Notes
681
721
 
682
722
  - **Framework-agnostic:** it wraps at the tool-call boundary, so it works with any agent runtime (LangGraph, OpenAI Agents SDK, CrewAI, a raw tool loop) and any action (Salesforce, M365, SAP, a DB write).
@@ -1,4 +1,4 @@
1
- import { type GuardConfig, type ApprovalContext, type ProtectedExecution } from "../index.js";
1
+ import { type GuardConfig, type ApprovalContext, type ProtectedExecution, type EvidenceOutcome } from "../index.js";
2
2
  import { type GateAuth } from "./_gate.js";
3
3
  /**
4
4
  * @decidio/sdk/temporal — durable suspend via Temporal's native `condition()` + a
@@ -23,7 +23,10 @@ export declare function installDecidioSignal(): Map<string, {
23
23
  }>;
24
24
  export interface DecidioGateOpts<R = unknown> {
25
25
  authorize?: () => Promise<GateAuth>;
26
- confirm?: (decisionId: string, result: unknown) => Promise<void>;
26
+ /** Reports through YOUR activity. Return the gate's confirm response body (validated with
27
+ * `readConfirmResponse`) and it becomes the outcome; return nothing and the outcome says the SDK could
28
+ * not read back what Decidio recorded (prodsec V035-03: a returned outcome used to be discarded). */
29
+ confirm?: (decisionId: string, result: unknown) => Promise<EvidenceOutcome | void>;
27
30
  /** Map your action's return value to the evidence Decidio records — the same option the core
28
31
  * path takes. Every adapter's stderr note named it and no adapter accepted one, so on this
29
32
  * path the advice could not be followed. */
@@ -25,12 +25,13 @@ export async function decidioGate(config, ctx, run, verdicts, opts = {}) {
25
25
  if (opts.confirm) {
26
26
  if (opts.evidence)
27
27
  console.warn("[decidio] temporal: `evidence` is ignored when you supply `confirm` — your callback owns the report, so map the result inside it. Remove one of the two to make the intent clear.");
28
- await opts.confirm(auth.decisionId, result);
29
- confirmation = {
28
+ const returned = await opts.confirm(auth.decisionId, result);
29
+ const usable = !!returned && typeof returned === "object" && typeof returned.reportRecorded === "boolean";
30
+ confirmation = usable ? returned : {
30
31
  reportRecorded: false, executionConfirmed: false, confirmationRetryable: false, reportJournalled: false,
31
32
  diagnostic: "your own `confirm` callback reported this execution, so this SDK cannot read back what Decidio recorded. Check the record, or return the gate's response body from your activity and validate it with readConfirmResponse.",
32
33
  };
33
- if (!confirmWarned) {
34
+ if (!usable && !confirmWarned) {
34
35
  confirmWarned = true;
35
36
  console.warn(`[decidio] temporal: a custom \`confirm\` reports through your own activity, so this SDK cannot read back the evidence tier Decidio recorded. Check the record (or return the gate's response body from your activity and validate it with readConfirmResponse) if you need to know whether the execution was CONFIRMED, not merely reported.`);
36
37
  }
package/dist/cli.js CHANGED
@@ -168,11 +168,25 @@ async function revokeFreshMint(jti, bearer, writeError, target) {
168
168
  return false;
169
169
  }
170
170
  const JOURNAL_PATH = ".decidio-token-journal";
171
+ const JOURNAL_SCHEMA = 1;
172
+ const JOURNAL_EVENTS = new Set(["probe", "minted", "retired"]);
171
173
  function journalAppend(entry) {
172
174
  const fresh = !existsSync(JOURNAL_PATH);
175
+ const row = Buffer.from(JSON.stringify({ v: JOURNAL_SCHEMA, ...entry, at: new Date().toISOString() }) + "\n", "utf8");
173
176
  const fd = openSync(JOURNAL_PATH, "a", 0o600);
174
177
  try {
175
- writeSync(fd, JSON.stringify({ ...entry, at: new Date().toISOString() }) + "\n");
178
+ if (!fresh) {
179
+ const st = readFileSync(JOURNAL_PATH);
180
+ if (st.length && st[st.length - 1] !== 0x0a) {
181
+ let w = 0;
182
+ const nl = Buffer.from("\n");
183
+ while (w < nl.length)
184
+ w += writeSync(fd, nl, w);
185
+ }
186
+ }
187
+ let written = 0;
188
+ while (written < row.length)
189
+ written += writeSync(fd, row, written);
176
190
  fsyncSync(fd);
177
191
  }
178
192
  finally {
@@ -186,7 +200,60 @@ function journalAppend(entry) {
186
200
  hardenFile(JOURNAL_PATH);
187
201
  }
188
202
  }
203
+ function journalRowProblem(e) {
204
+ if (!e || typeof e !== "object" || Array.isArray(e))
205
+ return "not a JSON object";
206
+ if (e.v !== undefined && e.v !== JOURNAL_SCHEMA)
207
+ return `schema version ${JSON.stringify(e.v)} is not ${JOURNAL_SCHEMA}`;
208
+ if (typeof e.event !== "string" || !JOURNAL_EVENTS.has(e.event))
209
+ return `unknown event ${JSON.stringify(e.event ?? null)}`;
210
+ if (e.event !== "probe" && (typeof e.jti !== "string" || !e.jti.trim()))
211
+ return `${e.event} row without a string jti`;
212
+ if (e.event === "retired" && e.how !== "persisted" && e.how !== "revoked")
213
+ return `retired row with how=${JSON.stringify(e.how ?? null)}`;
214
+ return null;
215
+ }
216
+ class JournalCorruptError extends Error {
217
+ line;
218
+ why;
219
+ constructor(line, why) {
220
+ super(`${JOURNAL_PATH} line ${line}: ${why}`);
221
+ this.line = line;
222
+ this.why = why;
223
+ this.name = "JournalCorruptError";
224
+ }
225
+ }
226
+ function journalRows() {
227
+ if (!existsSync(JOURNAL_PATH))
228
+ return [];
229
+ const rows = [];
230
+ const lines = readFileSync(JOURNAL_PATH, "utf8").split(/\r?\n/);
231
+ for (let i = 0; i < lines.length; i++) {
232
+ const line = lines[i];
233
+ if (!line.trim())
234
+ continue;
235
+ let e;
236
+ try {
237
+ e = JSON.parse(line);
238
+ }
239
+ catch {
240
+ throw new JournalCorruptError(i + 1, "not valid JSON (a torn or hand-edited row)");
241
+ }
242
+ const why = journalRowProblem(e);
243
+ if (why)
244
+ throw new JournalCorruptError(i + 1, why);
245
+ rows.push(e);
246
+ }
247
+ return rows;
248
+ }
249
+ 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.`;
189
250
  function journalProbe() {
251
+ try {
252
+ journalRows();
253
+ }
254
+ catch (e) {
255
+ return `CORRUPT: ${e?.message ?? e}`;
256
+ }
190
257
  try {
191
258
  journalAppend({ event: "probe" });
192
259
  return null;
@@ -201,27 +268,18 @@ function journalRetire(jti, how) { try {
201
268
  }
202
269
  catch { } }
203
270
  function journalOutstanding() {
204
- if (!existsSync(JOURNAL_PATH))
205
- return [];
206
271
  const minted = new Map();
207
- for (const line of readFileSync(JOURNAL_PATH, "utf8").split(/\r?\n/)) {
208
- if (!line.trim())
209
- continue;
210
- let e;
211
- try {
212
- e = JSON.parse(line);
213
- }
214
- catch {
215
- continue;
216
- }
217
- if (e?.event === "minted" && typeof e.jti === "string")
272
+ for (const e of journalRows()) {
273
+ if (e.event === "minted")
218
274
  minted.set(e.jti, { jti: e.jti, agentId: e.agentId, label: e.label, apiUrl: e.apiUrl, at: e.at });
219
- if (e?.event === "retired" && typeof e.jti === "string")
275
+ if (e.event === "retired")
220
276
  minted.delete(e.jti);
221
277
  }
222
278
  return [...minted.values()];
223
279
  }
224
- const REFUSE_MINT_NO_JOURNAL = (why) => `✘ could not write the token recovery journal (${JOURNAL_PATH}: ${why}) - refusing to mint a credential this directory cannot record a revocation handle for. Fix the directory's permissions or free space and re-run. Nothing was minted.`;
280
+ const REFUSE_MINT_NO_JOURNAL = (why) => why.startsWith("CORRUPT: ")
281
+ ? REFUSE_MINT_CORRUPT_JOURNAL(new Error(why.slice("CORRUPT: ".length)))
282
+ : `✘ could not write the token recovery journal (${JOURNAL_PATH}: ${why}) - refusing to mint a credential this directory cannot record a revocation handle for. Fix the directory's permissions or free space and re-run. Nothing was minted.`;
225
283
  function upsertEnv(updates) {
226
284
  const remaining = { ...updates };
227
285
  const updatedKeys = new Set();
@@ -514,14 +572,14 @@ async function cmdDoctor() {
514
572
  let warned = 0;
515
573
  const line = (good, label) => { log(`${good ? "✔" : "✘"} ${label}`); ok = ok && good; };
516
574
  const warn = (label) => { warned++; log(`⚠ ${label}`); };
517
- let journalRows = [];
575
+ let journalItems = [];
518
576
  try {
519
- journalRows = journalOutstanding();
577
+ journalItems = journalOutstanding();
520
578
  }
521
579
  catch (e) {
522
- warn(`token recovery journal (${JOURNAL_PATH}) could not be read (${e?.message ?? e}) — it may hold live token ids; fix its permissions and re-run doctor.`);
580
+ line(false, `token recovery journal: ${e?.message ?? e} — it may hold the id of a live token, and this directory cannot mint until it is repaired or moved aside (every byte is left as found).`);
523
581
  }
524
- for (const j of journalRows) {
582
+ for (const j of journalItems) {
525
583
  const where = j.apiUrl && j.apiUrl !== API ? `DECIDIO_API_URL=${j.apiUrl} ` : "";
526
584
  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})`);
527
585
  }
@@ -762,6 +820,10 @@ function cmdVerify(argsIn) {
762
820
  const files = [];
763
821
  for (let i = 0; i < argsIn.length; i++) {
764
822
  const a = argsIn[i];
823
+ if (a === "--") {
824
+ files.push(...argsIn.slice(i + 1));
825
+ break;
826
+ }
765
827
  if (a === "--issuer") {
766
828
  const v = argsIn[i + 1];
767
829
  if (v === undefined || v === "" || v.startsWith("-")) {
@@ -817,8 +879,10 @@ async function cmdQuickstart(opts = {}) {
817
879
  log(` Target: ${API}${process.env.DECIDIO_API_URL ? "" : " (the hosted synthetic sandbox — set DECIDIO_API_URL to use your own)"}`);
818
880
  log("");
819
881
  log(" This tour will: register one synthetic agent · mint a 7-DAY floor-limited token ·");
820
- log(" write .env (or .env.quickstart) + .gitignore entries · create one synthetic decision");
821
- log(" and its sealed record (records are permanent by design). Cleanup offer at the end.");
882
+ log(" write .env (or .env.quickstart), .decidio-token-journal (revocation ids, no secrets) and .gitignore");
883
+ 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.");
822
886
  log("");
823
887
  const session = await acquireSession("The quickstart");
824
888
  if (!session)
@@ -1043,11 +1107,17 @@ async function cmdQuickstart(opts = {}) {
1043
1107
  const issuer = rec.body?.issuer;
1044
1108
  const v = verifyReceipt(rec.body.credential, issuer);
1045
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);
1113
+ }
1046
1114
  if (!issuer) {
1047
1115
  err(` ⚠ the server did not return an issuer DID, so this check ran UNPINNED — it proves the`);
1048
1116
  err(` receipt is internally consistent, NOT who issued it (a self-signed forgery also passes).`);
1049
1117
  err(` Get your workspace's issuer DID from the app's record-verify panel, then re-check:`);
1050
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);
1051
1121
  }
1052
1122
  if (issuer)
1053
1123
  log(` Keep that DID: it is your trust anchor — npx @decidio/sdk verify --issuer ${issuer} <receipt.json>`);
@@ -1101,7 +1171,15 @@ async function quickstartCleanup(session, envFile) {
1101
1171
  })();
1102
1172
  let journalRevoked = 0;
1103
1173
  const journalStuck = [];
1104
- for (const j of journalOutstanding()) {
1174
+ let outstanding = [];
1175
+ try {
1176
+ outstanding = journalOutstanding();
1177
+ }
1178
+ catch (e) {
1179
+ 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
+ return 1;
1181
+ }
1182
+ for (const j of outstanding) {
1105
1183
  if (j.apiUrl && j.apiUrl !== API) {
1106
1184
  journalStuck.push(j.jti);
1107
1185
  err(` ⚠ journalled token ${j.jti} was minted against ${j.apiUrl}, not ${API} — revoke it there: DECIDIO_API_URL=${j.apiUrl} npx @decidio/sdk tokens revoke ${j.jti}`);
@@ -1437,7 +1515,12 @@ async function main(argv) {
1437
1515
  if (argsIn.includes("cleanup")) {
1438
1516
  const envFile = existsSync(".env.quickstart") ? ".env.quickstart"
1439
1517
  : (readEnvFile().DECIDIO_AGENT_ID ?? "").startsWith("quickstart-") ? ENV_PATH
1440
- : journalOutstanding().length ? ".env.quickstart" : undefined;
1518
+ : (() => { try {
1519
+ return journalOutstanding().length ? ".env.quickstart" : undefined;
1520
+ }
1521
+ catch {
1522
+ return ".env.quickstart";
1523
+ } })();
1441
1524
  if (!envFile) {
1442
1525
  log("Nothing to clean up here: no .env.quickstart, .env doesn't belong to a quickstart agent, and the token recovery journal owes nothing.");
1443
1526
  return 0;
@@ -1458,7 +1541,8 @@ async function main(argv) {
1458
1541
  err("✘ --expires-days needs a positive whole number of days (e.g. --expires-days 30).");
1459
1542
  return 2;
1460
1543
  }
1461
- const nameArg = argsIn.find((x, i) => !x.startsWith("-") && !(expIdx >= 0 && i === expIdx + 1));
1544
+ const ddIdx = argsIn.indexOf("--");
1545
+ const nameArg = ddIdx >= 0 ? argsIn[ddIdx + 1] : argsIn.find((x, i) => !x.startsWith("-") && !(expIdx >= 0 && i === expIdx + 1));
1462
1546
  let name = nameArg ?? process.env.DECIDIO_AGENT_ID ?? "my-agent";
1463
1547
  if (unique)
1464
1548
  name = `${name}-${randomBytes(2).toString("hex")}`;
package/dist/inngest.d.ts CHANGED
@@ -35,6 +35,8 @@ export type GateStepOutcome<R> = {
35
35
  status: "executed" | "rejected" | "blocked";
36
36
  decisionId: string;
37
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. */
38
40
  confirmation?: EvidenceOutcome;
39
41
  };
40
42
  /**
package/dist/resume.d.ts CHANGED
@@ -450,9 +450,11 @@ export declare class DecidioSuspendedError extends Error {
450
450
  /** `indeterminate` (0.2.0): the handler was INVOKED and then threw. Whether the downstream
451
451
  * effect committed is not knowable from here, so the action is never retried automatically —
452
452
  * it waits for reconcile(). Callers that switch exhaustively on this union must handle it. */
453
- /** `already_resolved` (0.3.5): this resume signal LOST the transition race to an earlier, final answer
454
- * (a denial, or an execution that already ran) — `reason` names which. It used to read `unknown`,
455
- * which is the word for "not ours"; safety was right and the diagnosis was not (prodsec V033-06). */
453
+ /** `already_resolved` (0.3.5): this resume signal LOST the transition race to an earlier, FINAL answer
454
+ * (a denial, an execution that already ran, or a report already recorded) — `reason` names which. It used
455
+ * to read `unknown`, the word for "not ours" (prodsec V033-06). An `invoking` row is NOT final and answers
456
+ * `indeterminate` (prodsec V035-02). Both members are additive: an exhaustive switch over this union
457
+ * gains a case per release, which the changelog announces. */
456
458
  export type ResumeStatus = "executed" | "rejected" | "blocked" | "no_handler" | "unknown" | "indeterminate" | "already_resolved";
457
459
  /** The states a durable action can actually be in — every one of them nameable, including the
458
460
  * one nobody likes. */
package/dist/resume.js CHANGED
@@ -1037,7 +1037,12 @@ export function createDecidioResume(opts) {
1037
1037
  refuseOnce(decisionId, claim.state === "discarded"
1038
1038
  ? `[decidio] REFUSING to execute ${decisionId}: it was DENIED (${(await ctrl.store.getExecution(decisionId))?.verdict ?? "rejected"}) and that answer is final. Nothing ran. A denial cannot be reconciled or retried — if this action should now proceed, it needs a NEW request and a new approval.`
1039
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);
1040
- return finish({ status: claim.state === "indeterminate" ? "indeterminate" : "already_resolved", decisionId, action: pending.action, reason: claim.state === "discarded" ? `this decision was already DENIED (${(await ctrl.store.getExecution(decisionId))?.verdict ?? "rejected"}); that answer is final and this signal lost the race to it` : `this decision already ran (durable state ${claim.state}); this signal lost the race to it` });
1040
+ if (claim.state === "invoking" || claim.state === "indeterminate") {
1041
+ return finish({ status: "indeterminate", decisionId, action: pending.action, reason: claim.state === "invoking"
1042
+ ? `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}", …).`
1043
+ : claim.reason });
1044
+ }
1045
+ 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` });
1041
1046
  }
1042
1047
  return { status: "unknown", decisionId };
1043
1048
  }
package/dist/signing.d.ts CHANGED
@@ -78,6 +78,10 @@ export declare function assertJsonSafe(value: unknown, path?: string): void;
78
78
  * (it is refused as one), so no refusal may print it - every branch of assertSafeTransport echoes through
79
79
  * this, not the raw string (invariant-guard, 0.3.5). */
80
80
  export declare function redactUrl(u: string): string;
81
+ /** Unicode SPACE and FORMAT characters a URL must never contain: the Zs/Zl/Zp separators (NBSP, ideographic
82
+ * space, …) and the zero-width formats. Spelled as an explicit list in BOTH runtimes so the answers cannot
83
+ * drift with a library's notion of whitespace (Python's urlsplit strips a leading NBSP; WHATWG does not). */
84
+ export declare const URL_UNICODE_SPACE: RegExp;
81
85
  export declare function assertSafeTransport(url: string, hasCredential: boolean): void;
82
86
  /** The ONE answer to "does this request carry a credential worth protecting?" (A6, 0.1.12).
83
87
  * A signed identity proof is a credential too: sending it over remote plaintext disclosed
package/dist/signing.js CHANGED
@@ -106,14 +106,17 @@ export function assertJsonSafe(value, path = "args") {
106
106
  };
107
107
  walk(value, path);
108
108
  }
109
- export function redactUrl(u) { return u.replace(/\/\/[^/@]*@/, "//***@"); }
109
+ export function redactUrl(u) {
110
+ return u.replace(/\/\/[^/?#]*@/, "//***@").replace(/^([^/?#@:]*:)(?!\/\/)[\/\\]*[^/?#]*@/, "$1***@");
111
+ }
112
+ export const URL_UNICODE_SPACE = /[\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\u200b-\u200d\u2060]/;
110
113
  export function assertSafeTransport(url, hasCredential) {
111
114
  if (typeof url !== "string") {
112
115
  throw new Error(`[decidio] the gate URL is ${url === null ? "null" : typeof url}, not a string, so there is no host to send to. Decidio needs a full https URL (or http://localhost for local dev). Nothing was sent.`);
113
116
  }
114
117
  for (const ch of url) {
115
118
  const cp = ch.codePointAt(0);
116
- if (cp <= 0x20 || (cp >= 0x7f && cp <= 0x9f) || cp === 0xfeff) {
119
+ if (cp <= 0x20 || (cp >= 0x7f && cp <= 0x9f) || cp === 0xfeff || URL_UNICODE_SPACE.test(ch)) {
117
120
  throw new Error(`[decidio] the gate URL ${JSON.stringify(redactUrl(url))} contains whitespace or a control character (U+${cp.toString(16).toUpperCase().padStart(4, "0")}) - a validator and a transport can read such a spelling differently, so it is refused as written. Remove it. Nothing was sent.`);
118
121
  }
119
122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decidio/sdk",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
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",
@@ -40,6 +40,7 @@
40
40
  "dist",
41
41
  "openapi.yaml",
42
42
  "README.md",
43
+ "CHANGELOG.md",
43
44
  "LICENSE"
44
45
  ],
45
46
  "engines": {
@@ -82,7 +83,7 @@
82
83
  "prepublishOnly": "npm run typecheck && npm run build && node ../publish-boundary/check.mjs --only @decidio/sdk"
83
84
  },
84
85
  "dependencies": {
85
- "@decidio/verify": "0.3.5"
86
+ "@decidio/verify": "0.4.0"
86
87
  },
87
88
  "author": "OmniTwin Technologies Inc.",
88
89
  "bugs": {