@decidio/sdk 0.3.5 → 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 ADDED
@@ -0,0 +1,142 @@
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.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
+
57
+ ## 0.4.0 — the scope statement
58
+
59
+ - **README: "What 0.4 does not yet claim" and "Compatibility and support."** The stability promise is
60
+ stated: what is stable, what is Beta (the engine adapters), what is deprecated and kept until 1.0
61
+ (`protectBestEffort`, `gateInterruptions`), and the five things reviewers keep finding that only
62
+ product decisions close (trust-on-first-use issuer DID, the unshipped schema, the unshipped
63
+ conformance suites, provenance and the repository link). 1.0.0 follows user-acceptance testing.
64
+ - **The tour fails closed on an unpinned check.** If the API omits the issuer DID, the tour used to warn
65
+ and still report success; it now fails and cleans up.
66
+ - **The tour's preflight names everything it writes**, including `.decidio-token-journal` and
67
+ `.decidio-pending/`.
68
+ - **`--` ends option parsing on all three verifier doors**, so a receipt saved under a dash-leading
69
+ name can be verified.
70
+ - **The token recovery journal treats corruption as uncertainty, never absence** (prodsec retest of
71
+ 0.3.5). Every row is parsed and schema-checked before any mint; a row that cannot be trusted refuses
72
+ `init` and `quickstart`, turns `doctor` red and makes `quickstart cleanup` exit non-zero - the bytes are
73
+ left as found. Appends are framed (a delimiter first if the previous row lost its newline, a schema
74
+ version, a write loop before the fsync), so a torn row stays discoverable.
75
+ - **A resume signal that finds the decision `invoking` answers `indeterminate`**, not `already_resolved`
76
+ with "already ran": the action may still be running, may have crashed before any effect, or may have
77
+ finished without reporting. `already_resolved` is reserved for the final states.
78
+ - **Temporal (Beta): a custom `confirm` may return the gate's confirm outcome** (validated with
79
+ `readConfirmResponse`) and it becomes the step's confirmation instead of being discarded.
80
+ - **Transport: every refusal redacts URL userinfo**, including the opaque `scheme:user:secret@` spelling,
81
+ and Unicode space and format characters (NBSP, ideographic space, zero-width space, …) are refused in
82
+ both runtimes from one explicit list. Four cases joined the shared transport corpus.
83
+ - **This file ships** in the tarball (and in the Python sdist), as a deliberate publish-boundary line.
84
+
85
+ ## 0.3.5 — the retest residuals
86
+
87
+ - **Transport:** any control character or whitespace anywhere in the URL is refused before anything is
88
+ sent; refusals echo a redacted spelling (URL userinfo is a credential).
89
+ - **Credential lifecycle:** the quickstart inspects a previous `.env.quickstart` before minting; secret
90
+ files are written temp + fsync + rename; **the token recovery journal** (`.decidio-token-journal`,
91
+ owner-only, append-only) records every minted token id before the credential file exists and retires
92
+ it on a durable write or any proven revoke. Credential creation fails closed if the journal cannot be
93
+ written. `quickstart cleanup` sweeps it; `doctor` lists outstanding entries.
94
+ - **Verifiers (all three doors):** the `verificationMethod` must be the issuer's exact `did:key` method
95
+ identifier, and `created` must be a real calendar date-time. Correctly signed receipts naming a
96
+ nonexistent method fragment or an impossible time are now INVALID.
97
+ - **Adapter gate:** the same describe validation as the core (a blank action or negative amount is
98
+ refused before signing or posting).
99
+ - **Outcomes:** a resume signal that lost the race to a final answer is `already_resolved`; a parked row
100
+ whose payload is gone is `orphaned_state`.
101
+ - Upgrade: no action. Custom stores are unaffected.
102
+
103
+ ## 0.3.4 — first-run consistency
104
+
105
+ - Tour and README pointers, routing-by-default vs the universal-backstop block named as two states, the
106
+ stale "unsafe values park and fail on resume" paragraph corrected, cleanup discloses the durable
107
+ store, help never loads `.env` (and only when usage will actually print), adapter headers current.
108
+ - Upgrade: no action.
109
+
110
+ ## 0.3.2 / 0.3.3 — the transition itself is atomic (COORDINATED UPGRADE)
111
+
112
+ - **The shipped file stores are an append-only revision chain** (`<key>.rev.<N>`), published with a
113
+ hard link that refuses an existing name: exactly one writer wins each transition, and a loser
114
+ re-validates against what actually happened. This closed the pre-0.3.2 race in which a denial could
115
+ be written and returned and the denied action still run.
116
+ - **Upgrade — read this if a directory is shared by more than one worker:** the first 0.3.2+ store
117
+ migrates the older marker layout one way. **Stop every older worker that shares the directory before
118
+ the first 0.3.2+ process starts**, then upgrade them together. An older worker writing into an
119
+ upgraded directory leaves a marker beside the chain, and that decision reads as unreadable by design.
120
+ Nothing deletes a revision; each decision keeps a handful of small files (see the README's sizing
121
+ envelope).
122
+ - 0.3.3: one snapshot of `describe()`'s answer for signing and posting; the shared id grammar at every
123
+ exported resume door; a compensating revoke when a minted token's credential file fails to write;
124
+ `reconfirm()` on a reported decision tells the truth about confirmation.
125
+
126
+ ## 0.3.1
127
+
128
+ - The verifier says what it proves (canonical signed claims, not exact bytes); proof options
129
+ (`proofPurpose`, `verificationMethod`, `created`) are part of the claim on every door; duplicate
130
+ `--issuer` flags are refused everywhere; orphaned token ids survive every replacement.
131
+
132
+ ## 0.3.0
133
+
134
+ - A claim that gates nothing: `reportRecorded` and `executionConfirmed` are distinct, honest booleans.
135
+ A denial is final on every path. The decision-id grammar is defined server-side and mirrored here.
136
+ - Upgrade for custom stores: `markDiscarded` is required (a hard construction failure without it).
137
+
138
+ ## 0.2.x
139
+
140
+ - The execution state machine (`parked → claimed_preinvoke → invoking → executed_unreported |
141
+ indeterminate → reported`), `reconcile()`, `ExecutionContext` with a stable idempotency key, and the
142
+ 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
 
@@ -80,7 +81,10 @@ fresh one (or pass `--unique` to auto-append a random suffix — collision-proof
80
81
  workspaces and CI). Headless terminal (no prompts possible)? Export `DECIDIO_SESSION_TOKEN` first:
81
82
  request a sign-in link, then exchange its `?login=` value at `POST /demo/login-consume` — the
82
83
  response's `token` is your session token (the CLI prints these exact instructions when it
83
- 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.
84
88
 
85
89
  **3. Protect one function** — save this as `quickstart.mjs`:
86
90
 
@@ -677,6 +681,45 @@ Every error raised after a decision exists carries the `decisionId`, so a suspen
677
681
  timed-out action is always findable in
678
682
  your queue.
679
683
 
684
+ ## What 0.4 does not yet claim
685
+
686
+ The retest that graded 0.3.3 *A- for the core and the shipped stores* and the first-run reviews of
687
+ 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
688
+ the next reader grades a promise, not a moving target:
689
+
690
+ - **Engine adapters are Beta and outside the stability promise.** They pass the same conformance
691
+ battery as the core against structural mocks; no live LangGraph/Temporal/Inngest/OpenAI restart suite
692
+ exists yet. Use the core path for production until it does.
693
+ - **The issuer DID is trust on first use.** The tour and `receipt` print your workspace's issuer DID
694
+ from the same authenticated response that carries the receipt. Pinned verification is only as strong
695
+ as where you keep that DID: record it through a channel you trust independently (your app's
696
+ record-verify panel today; a published well-known location is on the roadmap).
697
+ - **Verification is cryptographic, not schema validation.** `VALID` means the issuer's key signed these
698
+ canonical claims; the sealed-payload schema the receipt names by hash is not shipped, so a
699
+ structurally odd but correctly signed document is not refused on shape. Shipping the schema is on
700
+ the roadmap.
701
+ - **The conformance suites are not in the package.** The deterministic two-process schedules that
702
+ prove the durable store, and the corpus every door is held to, live in the Decidio source repository.
703
+ A customer cannot rerun them from this tarball; a public conformance kit is on the roadmap.
704
+ - **No provenance attestation, no repository link.** The source repository is private; npm refuses
705
+ provenance for private repositories, and a link that 404s is worse than none. Registry signatures and
706
+ lockfile integrity are what you can check today.
707
+
708
+ ## Compatibility and support
709
+
710
+ - **Stable in 0.4:** `guard.protect` / `withApproval` and their options; the durable resume
711
+ controller (`createDecidioResume`: `park`, `resolve`, `reconcile`, `reconfirm`, `recover`, the worker
712
+ and both webhook handlers); the `PendingStore` contract and both shipped stores, including the on-disk
713
+ revision-chain layout; `EvidenceOutcome` (a root export) and `ResumeStatus` / `RecoveryState` (on the
714
+ `@decidio/sdk/resume` subpath), additive growth only; the error classes; the CLI's commands, flags and exit codes; the receipt verifier's verdict and
715
+ reasons. Changes to any of these announce themselves in the changelog first.
716
+ - **Beta:** the engine adapters (above).
717
+ - **Deprecated, kept until 1.0:** `protectBestEffort` (the value-only shape) and the old adapter
718
+ name `gateInterruptions`. They will be removed in 1.0.0, not before.
719
+ - **Versioning:** 0.x minor releases may still carry deliberate breaking cleanups, announced in the
720
+ changelog with the upgrade step. 1.0.0 follows user-acceptance testing of this release and freezes the
721
+ surface above.
722
+
680
723
  ## Notes
681
724
 
682
725
  - **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).
@@ -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
  };
@@ -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.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 {
@@ -168,11 +170,25 @@ async function revokeFreshMint(jti, bearer, writeError, target) {
168
170
  return false;
169
171
  }
170
172
  const JOURNAL_PATH = ".decidio-token-journal";
173
+ const JOURNAL_SCHEMA = 1;
174
+ const JOURNAL_EVENTS = new Set(["probe", "minted", "retired"]);
171
175
  function journalAppend(entry) {
172
176
  const fresh = !existsSync(JOURNAL_PATH);
177
+ const row = Buffer.from(JSON.stringify({ v: JOURNAL_SCHEMA, ...entry, at: new Date().toISOString() }) + "\n", "utf8");
173
178
  const fd = openSync(JOURNAL_PATH, "a", 0o600);
174
179
  try {
175
- writeSync(fd, JSON.stringify({ ...entry, at: new Date().toISOString() }) + "\n");
180
+ if (!fresh) {
181
+ const st = readFileSync(JOURNAL_PATH);
182
+ if (st.length && st[st.length - 1] !== 0x0a) {
183
+ let w = 0;
184
+ const nl = Buffer.from("\n");
185
+ while (w < nl.length)
186
+ w += writeSync(fd, nl, w);
187
+ }
188
+ }
189
+ let written = 0;
190
+ while (written < row.length)
191
+ written += writeSync(fd, row, written);
176
192
  fsyncSync(fd);
177
193
  }
178
194
  finally {
@@ -186,7 +202,86 @@ function journalAppend(entry) {
186
202
  hardenFile(JOURNAL_PATH);
187
203
  }
188
204
  }
205
+ function journalRowProblem(e) {
206
+ if (!e || typeof e !== "object" || Array.isArray(e))
207
+ return "not a JSON object";
208
+ if (e.v !== undefined && e.v !== JOURNAL_SCHEMA)
209
+ return `schema version ${JSON.stringify(e.v)} is not ${JOURNAL_SCHEMA}`;
210
+ if (typeof e.event !== "string" || !JOURNAL_EVENTS.has(e.event))
211
+ return `unknown event ${JSON.stringify(e.event ?? null)}`;
212
+ if (e.event !== "probe" && (typeof e.jti !== "string" || !e.jti.trim()))
213
+ return `${e.event} row without a string jti`;
214
+ if (e.event === "retired" && e.how !== "persisted" && e.how !== "revoked")
215
+ return `retired row with how=${JSON.stringify(e.how ?? null)}`;
216
+ return null;
217
+ }
218
+ class JournalCorruptError extends Error {
219
+ line;
220
+ why;
221
+ constructor(line, why) {
222
+ super(`${JOURNAL_PATH} line ${line}: ${why}`);
223
+ this.line = line;
224
+ this.why = why;
225
+ this.name = "JournalCorruptError";
226
+ }
227
+ }
228
+ function journalRows() {
229
+ if (!existsSync(JOURNAL_PATH))
230
+ return [];
231
+ const rows = [];
232
+ const lines = readFileSync(JOURNAL_PATH, "utf8").split(/\r?\n/);
233
+ for (let i = 0; i < lines.length; i++) {
234
+ const line = lines[i];
235
+ if (!line.trim())
236
+ continue;
237
+ let e;
238
+ try {
239
+ e = JSON.parse(line);
240
+ }
241
+ catch {
242
+ throw new JournalCorruptError(i + 1, "not valid JSON (a torn or hand-edited row)");
243
+ }
244
+ const why = journalRowProblem(e);
245
+ if (why)
246
+ throw new JournalCorruptError(i + 1, why);
247
+ rows.push(e);
248
+ }
249
+ return rows;
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
+ }
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.`;
189
278
  function journalProbe() {
279
+ try {
280
+ journalRows();
281
+ }
282
+ catch (e) {
283
+ return `CORRUPT: ${e?.message ?? e}`;
284
+ }
190
285
  try {
191
286
  journalAppend({ event: "probe" });
192
287
  return null;
@@ -201,27 +296,23 @@ function journalRetire(jti, how) { try {
201
296
  }
202
297
  catch { } }
203
298
  function journalOutstanding() {
204
- if (!existsSync(JOURNAL_PATH))
205
- return [];
206
299
  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")
300
+ for (const e of journalRows()) {
301
+ if (e.event === "minted")
218
302
  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")
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")
220
309
  minted.delete(e.jti);
221
310
  }
222
311
  return [...minted.values()];
223
312
  }
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.`;
313
+ const REFUSE_MINT_NO_JOURNAL = (why) => why.startsWith("CORRUPT: ")
314
+ ? REFUSE_MINT_CORRUPT_JOURNAL(new Error(why.slice("CORRUPT: ".length)))
315
+ : `✘ 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
316
  function upsertEnv(updates) {
226
317
  const remaining = { ...updates };
227
318
  const updatedKeys = new Set();
@@ -279,7 +370,19 @@ function ensureGitignore() {
279
370
  appendFileSync(".gitignore", (existing && !existing.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n");
280
371
  log(`✔ added ${missing.join(" + ")} to .gitignore (new secret files here will not be committed)`);
281
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
+ }
282
383
  function ask(question, mute = false) {
384
+ if (process.stdin.readableEnded || process.stdin.destroyed)
385
+ return Promise.resolve("");
283
386
  return new Promise((resolve) => {
284
387
  const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
285
388
  if (mute) {
@@ -288,8 +391,9 @@ function ask(question, mute = false) {
288
391
  anyRl._writeToOutput = (s) => { if (s.includes(question))
289
392
  write?.(s); };
290
393
  }
291
- rl.question(question, (answer) => { rl.close(); if (mute)
292
- 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"); });
293
397
  });
294
398
  }
295
399
  async function acquireSession(purpose) {
@@ -514,15 +618,19 @@ async function cmdDoctor() {
514
618
  let warned = 0;
515
619
  const line = (good, label) => { log(`${good ? "✔" : "✘"} ${label}`); ok = ok && good; };
516
620
  const warn = (label) => { warned++; log(`⚠ ${label}`); };
517
- let journalRows = [];
621
+ let journalItems = [];
518
622
  try {
519
- journalRows = journalOutstanding();
623
+ journalItems = journalOutstanding();
520
624
  }
521
625
  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.`);
626
+ 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
627
  }
524
- for (const j of journalRows) {
628
+ for (const j of journalItems) {
525
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
+ }
526
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})`);
527
635
  }
528
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)"}`);
@@ -762,6 +870,10 @@ function cmdVerify(argsIn) {
762
870
  const files = [];
763
871
  for (let i = 0; i < argsIn.length; i++) {
764
872
  const a = argsIn[i];
873
+ if (a === "--") {
874
+ files.push(...argsIn.slice(i + 1));
875
+ break;
876
+ }
765
877
  if (a === "--issuer") {
766
878
  const v = argsIn[i + 1];
767
879
  if (v === undefined || v === "" || v.startsWith("-")) {
@@ -817,8 +929,11 @@ async function cmdQuickstart(opts = {}) {
817
929
  log(` Target: ${API}${process.env.DECIDIO_API_URL ? "" : " (the hosted synthetic sandbox — set DECIDIO_API_URL to use your own)"}`);
818
930
  log("");
819
931
  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.");
932
+ log(" write .env (or .env.quickstart), .decidio-token-journal (revocation ids, no secrets) and .gitignore");
933
+ log(" entries (incl. .decidio-pending/, the durable store your real agent will use) · save the receipt");
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.");
822
937
  log("");
823
938
  const session = await acquireSession("The quickstart");
824
939
  if (!session)
@@ -847,7 +962,7 @@ async function cmdQuickstart(opts = {}) {
847
962
  else {
848
963
  err(` Check the target and your sign-in, then re-run: npx @decidio/sdk doctor`);
849
964
  }
850
- 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).`);
851
966
  return 1;
852
967
  }
853
968
  let prevTourFile = null;
@@ -894,8 +1009,10 @@ async function cmdQuickstart(opts = {}) {
894
1009
  if (prevId && prevId !== agentId) {
895
1010
  if (prevJti) {
896
1011
  const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(prevJti)}`, { method: "DELETE" }, session);
897
- if (rv.status >= 200 && rv.status < 300)
1012
+ if (rv.status >= 200 && rv.status < 300) {
1013
+ journalRetire(prevJti, "revoked");
898
1014
  log(` ✔ the previous tour agent (${prevId}) had its token REVOKED before this tour replaced it — no live credential is left behind`);
1015
+ }
899
1016
  else {
900
1017
  carried.add(prevJti);
901
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}`);
@@ -986,8 +1103,26 @@ async function cmdQuickstart(opts = {}) {
986
1103
  log(` Nothing executes until someone decides.`);
987
1104
  log("");
988
1105
  const interactive = process.stdin.isTTY;
1106
+ let verdict = "approve";
989
1107
  if (interactive) {
990
- 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
+ }
991
1126
  }
992
1127
  else if (opts.autoApproveDemo) {
993
1128
  log("③ --auto-approve-demo: approving with your session credential. This is AUTOMATED authority");
@@ -1014,61 +1149,119 @@ async function cmdQuickstart(opts = {}) {
1014
1149
  log(` or if you abandon the tour — remove them: npx @decidio/sdk quickstart cleanup`);
1015
1150
  process.exit(3);
1016
1151
  }
1017
- 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);
1018
- if (!(ap.status >= 200 && ap.status < 300)) {
1019
- err(`✘ approve failed (${ap.status}): ${JSON.stringify(ap.body)}`);
1020
- return failTour(1);
1021
- }
1022
- log(` ✔ approved${ap.body?.alreadyResolved ? " (it was already approved from your app — even better)" : ""}`);
1023
- let done;
1024
- try {
1025
- done = await running;
1026
- }
1027
- catch (e) {
1028
- err(`✘ the gated call did not complete: ${e?.message ?? e}`);
1029
- 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")}`);
1030
1194
  }
1031
- log(` ✔ your function executed AFTER approval: ${JSON.stringify(done.value)}`);
1032
- if (done.confirmation?.evidenceTier)
1033
- log(` …and the call itself told you what was recorded: confirmation.evidenceTier = ${done.confirmation.evidenceTier}`);
1034
- log("");
1035
- log("④ Downloading the sealed Authority Receipt and verifying it OFFLINE…");
1036
- const rec = await api(`/api/records/${encodeURIComponent(decisionId)}/verify`, {}, session);
1037
- if (!(rec.status >= 200 && rec.status < 300) || !rec.body?.credential) {
1038
- err(`✘ receipt fetch failed (${rec.status})`);
1039
- 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`);
1040
1264
  }
1041
- const fname = `decidio-receipt-${decisionId.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
1042
- writeFileSync(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
1043
- const issuer = rec.body?.issuer;
1044
- const v = verifyReceipt(rec.body.credential, issuer);
1045
- log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? `, PINNED to your workspace issuer ${issuer}` : ""})`);
1046
- if (!issuer) {
1047
- err(` ⚠ the server did not return an issuer DID, so this check ran UNPINNED — it proves the`);
1048
- err(` receipt is internally consistent, NOT who issued it (a self-signed forgery also passes).`);
1049
- err(` Get your workspace's issuer DID from the app's record-verify panel, then re-check:`);
1050
- err(` npx @decidio/sdk verify --issuer <did:key:...> ${fname}`);
1051
- }
1052
- if (issuer)
1053
- log(` Keep that DID: it is your trust anchor — npx @decidio/sdk verify --issuer ${issuer} <receipt.json>`);
1054
- const detail = await api(`/api/records/${encodeURIComponent(decisionId)}`, {}, session);
1055
- const tier = detail.body?.record?.confirmation?.status;
1056
- if (tier)
1057
- 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.)`);
1058
- log("");
1059
- const secs = Math.round((Date.now() - t0) / 1000);
1060
- log(`Done in ${secs}s: gated → routed to a human → approved → your function executed once → an authority-decision receipt you can verify offline.`);
1061
- log("");
1062
- log("Take it to your real agent — the .env here already works:");
1063
- log(` import { guard } from "@decidio/sdk";`);
1064
- log(` const doIt = guard.protect(doItRaw, (a) => ({ action: "yourAction", amount: a.amount, scope: "YourScope" }));`);
1065
- log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
1066
- log("");
1067
- log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")}`);
1068
- log(`Next: the README section "Production mode: durable async resume" (the worker; the engine adapters follow it).`);
1069
- log(`The README's seven-step quickstart is this same blocking flow in your own code - if you keep this agent, its .env`);
1070
- log(`already works here, so skip that flow's init step.`);
1071
- log(`Built by OmniTwin — decidioai.com · Putting this in front of real approvals? decidioai.com/#early-access`);
1072
1265
  let doCleanup = false;
1073
1266
  if (interactive) {
1074
1267
  const answer = await ask(`\nKeep this agent for development? [Enter = keep · c + Enter = clean up now]: `);
@@ -1081,7 +1274,7 @@ async function cmdQuickstart(opts = {}) {
1081
1274
  log(`\nAutomated run: cleaning the tour up (pass --keep-agent to keep the agent + token).`);
1082
1275
  }
1083
1276
  const cleanupCode = doCleanup ? await quickstartCleanup(session, envFile) : 0;
1084
- return v.valid ? (cleanupCode || 0) : 1;
1277
+ return cleanupCode || 0;
1085
1278
  }
1086
1279
  async function quickstartCleanup(session, envFile) {
1087
1280
  const env = (() => {
@@ -1101,7 +1294,27 @@ async function quickstartCleanup(session, envFile) {
1101
1294
  })();
1102
1295
  let journalRevoked = 0;
1103
1296
  const journalStuck = [];
1104
- for (const j of journalOutstanding()) {
1297
+ let outstanding = [];
1298
+ let heldElsewhere = [];
1299
+ let unheld = [];
1300
+ let journalReconciled = 0;
1301
+ try {
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));
1308
+ }
1309
+ catch (e) {
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.`);
1311
+ return 1;
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}`);
1317
+ for (const j of outstanding) {
1105
1318
  if (j.apiUrl && j.apiUrl !== API) {
1106
1319
  journalStuck.push(j.jti);
1107
1320
  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}`);
@@ -1110,11 +1323,14 @@ async function quickstartCleanup(session, envFile) {
1110
1323
  const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(j.jti)}`, { method: "DELETE" }, session);
1111
1324
  if (rv.status >= 200 && rv.status < 300) {
1112
1325
  journalRetire(j.jti, "revoked");
1113
- journalRevoked++;
1114
- if (rv.body?.changed === false)
1326
+ if (rv.body?.changed === false) {
1327
+ journalReconciled++;
1115
1328
  log(` • journalled token ${j.jti} was already revoked — retiring its journal entry.`);
1116
- else
1117
- 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
+ }
1118
1334
  }
1119
1335
  else {
1120
1336
  journalStuck.push(j.jti);
@@ -1131,8 +1347,10 @@ async function quickstartCleanup(session, envFile) {
1131
1347
  const stillOrphaned = [];
1132
1348
  for (const jti of orphans) {
1133
1349
  const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, session);
1134
- if (rv.status >= 200 && rv.status < 300)
1350
+ if (rv.status >= 200 && rv.status < 300) {
1351
+ journalRetire(jti, "revoked");
1135
1352
  log(` ✔ orphaned token revoked (jti ${jti}) — it had survived an earlier replace`);
1353
+ }
1136
1354
  else {
1137
1355
  stillOrphaned.push(jti);
1138
1356
  err(` ⚠ orphaned token ${jti} could NOT be revoked (${rv.status}) — it is still live.`);
@@ -1146,6 +1364,8 @@ async function quickstartCleanup(session, envFile) {
1146
1364
  }
1147
1365
  if (env.DECIDIO_TOKEN_JTI) {
1148
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");
1149
1369
  if (rev.status >= 200 && rev.status < 300 && rev.body?.changed === false)
1150
1370
  log(` • token was already revoked (jti ${env.DECIDIO_TOKEN_JTI}) — nothing to do.`);
1151
1371
  else if (rev.status >= 200 && rev.status < 300)
@@ -1160,6 +1380,12 @@ async function quickstartCleanup(session, envFile) {
1160
1380
  else if (journalRevoked > 0 && !existsSync(envFile) || (journalRevoked > 0 && Object.keys(env).length === 0)) {
1161
1381
  log(` • no credential file to clean: the journal sweep revoked ${journalRevoked} token(s) this directory owed.`);
1162
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
+ }
1163
1389
  else {
1164
1390
  err(`✘ no DECIDIO_TOKEN_JTI in ${envFile} (older tour) — this tour's token is STILL LIVE and this`);
1165
1391
  err(` command cannot revoke it. Nothing was deleted. Find and revoke it, then re-run cleanup:`);
@@ -1203,6 +1429,8 @@ async function quickstartCleanup(session, envFile) {
1203
1429
  }
1204
1430
  log(` • What remains, by design: the sealed decision record (records are permanent — that is the product)`);
1205
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.`);
1206
1434
  if (existsSync(".decidio-pending")) {
1207
1435
  log(` • Also kept: .decidio-pending/ — this agent's own execution ledger: whatever this agent parked (call arguments,`);
1208
1436
  log(` execution evidence; never credentials, by design). Gitignored; owner-only where the OS supports it. If nothing is`);
@@ -1280,6 +1508,13 @@ async function cmdTokens(sub, jti, mineOnly = false) {
1280
1508
  return 0;
1281
1509
  }
1282
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
+ }
1283
1518
  log(`decidio dev relay on http://localhost:${port}/decidio/resume → forwarding to ${target}`);
1284
1519
  createServer((req, res) => {
1285
1520
  if (req.method !== "POST") {
@@ -1437,7 +1672,12 @@ async function main(argv) {
1437
1672
  if (argsIn.includes("cleanup")) {
1438
1673
  const envFile = existsSync(".env.quickstart") ? ".env.quickstart"
1439
1674
  : (readEnvFile().DECIDIO_AGENT_ID ?? "").startsWith("quickstart-") ? ENV_PATH
1440
- : journalOutstanding().length ? ".env.quickstart" : undefined;
1675
+ : (() => { try {
1676
+ return (journalOwed().length || journalUnheld().length) ? ".env.quickstart" : undefined;
1677
+ }
1678
+ catch {
1679
+ return ".env.quickstart";
1680
+ } })();
1441
1681
  if (!envFile) {
1442
1682
  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
1683
  return 0;
@@ -1458,7 +1698,8 @@ async function main(argv) {
1458
1698
  err("✘ --expires-days needs a positive whole number of days (e.g. --expires-days 30).");
1459
1699
  return 2;
1460
1700
  }
1461
- const nameArg = argsIn.find((x, i) => !x.startsWith("-") && !(expIdx >= 0 && i === expIdx + 1));
1701
+ const ddIdx = argsIn.indexOf("--");
1702
+ const nameArg = ddIdx >= 0 ? argsIn[ddIdx + 1] : argsIn.find((x, i) => !x.startsWith("-") && !(expIdx >= 0 && i === expIdx + 1));
1462
1703
  let name = nameArg ?? process.env.DECIDIO_AGENT_ID ?? "my-agent";
1463
1704
  if (unique)
1464
1705
  name = `${name}-${randomBytes(2).toString("hex")}`;
@@ -1530,5 +1771,19 @@ async function main(argv) {
1530
1771
  return 2;
1531
1772
  }
1532
1773
  }
1533
- main(process.argv).then((code) => { if (code !== 0)
1534
- 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,11 +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
- 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;
39
49
  };
40
50
  /**
41
51
  * Gate an action INSIDE an Inngest function, with durable suspend on routing.
package/dist/resume.d.ts CHANGED
@@ -450,10 +450,18 @@ 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). */
456
- export type ResumeStatus = "executed" | "rejected" | "blocked" | "no_handler" | "unknown" | "indeterminate" | "already_resolved";
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. */
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";
457
465
  /** The states a durable action can actually be in — every one of them nameable, including the
458
466
  * one nobody likes. */
459
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,10 +1046,15 @@ 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);
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` });
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);
1050
+ if (claim.state === "invoking" || claim.state === "indeterminate") {
1051
+ return finish({ status: "indeterminate", decisionId, action: pending.action, reason: claim.state === "invoking"
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}", …).`
1053
+ : claim.reason });
1054
+ }
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` });
1041
1056
  }
1042
- 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` });
1043
1058
  }
1044
1059
  try {
1045
1060
  await ctrl.store.markInvoking(decisionId, attemptId);
@@ -1047,7 +1062,14 @@ export function createDecidioResume(opts) {
1047
1062
  catch (e) {
1048
1063
  await ctrl.store.releasePreinvoke(decisionId, attemptId).catch(() => { });
1049
1064
  console.error(`[decidio] ${decisionId}: could not record the invocation boundary (${e?.message ?? e}) — refusing to run the action rather than run it unrecorded.`);
1050
- 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` });
1051
1073
  }
1052
1074
  let result;
1053
1075
  try {
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.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",
@@ -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.1"
86
87
  },
87
88
  "author": "OmniTwin Technologies Inc.",
88
89
  "bugs": {