@decidio/sdk 0.3.3 → 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
@@ -57,13 +57,17 @@ npm install @decidio/sdk
57
57
  npx @decidio/sdk init --unique my-agent # --unique appends a random suffix: collision-proof
58
58
  ```
59
59
 
60
+ > Just ran `npx @decidio/sdk quickstart` in this directory and kept its agent? Its `.env` already
61
+ > works — skip this step and continue at step 3.
62
+
60
63
  Every CLI command talks to the hosted sandbox by default and announces its target host on
61
64
  stderr each run — set `DECIDIO_API_URL` (env or `.env`) to point at another Decidio instead.
62
65
 
63
66
  `init` signs you into your workspace (email + password, or paste the sign-in link from your
64
67
  workspace email), generates the agent's Ed25519 keypair **locally** (only the public `did:key`
65
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`.
66
- 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
67
71
  agent that already has a token REPLACES it — the server keeps one active token per agent, so
68
72
  the previous one is revoked and init says so.
69
73
 
@@ -230,7 +234,11 @@ Real approvals take minutes to days, and nobody keeps a terminal open for them.
230
234
  `mode: "blocking"` (durable is the default) and a routed action **suspends** instead of
231
235
  waiting: it parks its call arguments agent-side (`.decidio-pending/` by default — Decidio
232
236
  stores none of your downstream payload) and throws `DecidioSuspendedError`. The process may
233
- exit. Two ways the parked action resumes when a human decides:
237
+ exit. **The parked payload is plaintext on the agent's disk:** the full call arguments and their
238
+ signature sit in `.decidio-pending/` until the decision resolves — owner-only permissions where the
239
+ OS supports it, gitignored by `init`, but not encrypted. Treat that directory as you treat the `.env`
240
+ beside it; a custom `PendingStore` can encrypt at rest. Two ways the parked action resumes when a
241
+ human decides:
234
242
 
235
243
  **The worker (self-serve — start here).** No inbound URL, no shared secrets, and it resumes work
236
244
  the current process never started. Here is a complete `agent.mjs` you can run:
@@ -421,11 +429,12 @@ the store you configured, so two workers sharing one Redis or Postgres store can
421
429
  other's work. (Through 0.1.x this was a separate local-filesystem journal, which meant two
422
430
  workers kept two disjoint journals and neither could. `reportDir` is accepted and ignored now.)
423
431
 
424
- **Parked arguments must be JSON-serializable.** A suspended action is signed and then written to
425
- disk as JSON, so values that don't survive `JSON.stringify` round-tripping — `Date`, `Map`,
426
- `undefined`, `NaN`, class instances with custom `toJSON` — come back changed and fail the
427
- integrity check on resume (a refusal, never a wrong execution). Pass plain data; rehydrate
428
- inside your function.
432
+ **Parked arguments must be JSON-serializable — and that is checked BEFORE routing.** Values that
433
+ do not survive JSON with their meaning intact — `Date`, `Map`, `undefined`, `NaN`, class instances,
434
+ functions — are refused with `DecidioUnsafeArgumentsError` before any decision exists and before any
435
+ network call (since 0.1.12): nothing is parked, nothing is routed. The parked payload is also
436
+ signed, so a file edited on disk fails the integrity check on resume (a refusal, never a wrong
437
+ execution). Pass plain data; rehydrate inside your function.
429
438
 
430
439
  **Payload integrity.** When a routed action is parked, the SDK signs its arguments (bound to the
431
440
  decision id) with your agent key; on resume it **refuses to execute** if the parked payload was
@@ -451,7 +460,7 @@ for engines you don't. Each adapter's shipped `.d.ts` header documents its wirin
451
460
 
452
461
 
453
462
 
454
- ## The durable store is a revision chain (0.3.2)
463
+ ## The durable store is a revision chain (since 0.3.2)
455
464
 
456
465
  `FilePendingStore` keeps each decision's execution record as an **append-only revision chain** —
457
466
  `<id>.rev.1`, `<id>.rev.2`, … — and the head is the highest number. Every state change reads the
@@ -484,11 +493,19 @@ the store refuses to pick one.
484
493
 
485
494
  **Custom stores.** The `PendingStore` contract has said since 0.2.0 that every method must be atomic
486
495
  against concurrent callers. The shipped file store did not honor that until now; a custom store must.
487
- The deterministic two-process schedules that proved the defect live in
488
- `packages/conformance/acceptance.mjs` (criteria 7a, 7b and 7c): a denial landing while an invoker
489
- is paused between validating and publishing; two conflicting settlements from one head; and a denier
490
- paused while the approver publishes four revisions past it. A store that cannot pass them is not
491
- atomic, whatever its methods say.
496
+ The deterministic two-process schedules that proved the defect live in the Decidio source
497
+ repository's conformance suite (not part of this package): a denial landing while an invoker is
498
+ paused between validating and publishing; two conflicting settlements from one head; and a denier
499
+ paused while the approver publishes four revisions past it. A store that cannot pass those schedules
500
+ is not atomic, whatever its methods say.
501
+
502
+ **Sizing envelope.** Nothing deletes a revision, so each decision leaves a handful of small files (one
503
+ per transition, typically four to six) and a head lookup lists the directory. That is comfortable for
504
+ tens of thousands of decisions on a local disk; beyond that, or for any deployment that shares a store
505
+ across hosts, use a transactional custom `PendingStore`. The tested envelope is a **single host on a local
506
+ filesystem with hard links** — network, FUSE and multi-host volumes are outside it, and their link
507
+ semantics have not been exercised. Never compact the directory while a worker runs; if compaction is
508
+ ever wanted it is an offline job with every writer stopped.
492
509
 
493
510
  ## Reading a receipt
494
511
 
@@ -510,8 +527,9 @@ request was compared to and did not clear, not a limit that was applied to it. R
510
527
  `requested_amount` as the ask, the other two as ceilings.
511
528
 
512
529
  **`firedRules` is what the policy engine actually matched — for any verdict.** It is not an
513
- auto-approve indicator: a **blocked** request carries the deny-by-default backstop rule here, so
514
- non-empty does not mean "approved" and empty does not mean "blocked". The field that states the
530
+ auto-approve indicator: a request **blocked by the universal backstop** (no active policy governs
531
+ agents at all) carries that backstop rule here, so non-empty does not mean "approved" and empty does
532
+ not mean "blocked". The field that states the
515
533
  disposition is `authority.result`; `firedRules` tells you which named, versioned rules produced it.
516
534
 
517
535
  **`payloadSchemaUri` is a `urn:`, not a URL.** It is content-addressed —
@@ -541,8 +559,10 @@ verdict, reason?)` is now REQUIRED — it writes the tombstone that makes a deni
541
559
  permanently unclaimable, and a store that cannot record a denial cannot uphold the guarantee, so
542
560
  this is refused at construction rather than deep inside a resolve. `delete` also takes an optional
543
561
  owning attempt (`delete(id, attemptId?)`) so a superseded attempt cannot clean up the live one's
544
- payload, and `settle` takes `workerStopped`. The shipped stores show the shape, and
545
- `packages/conformance/vectors/ownership.json` is the corpus your store should pass.
562
+ payload, and `settle` takes `workerStopped`. The shipped stores show the shape; the ownership
563
+ corpus that pins these rules (claim fencing, terminal denial, advance-only transitions, settle
564
+ preconditions) lives in the Decidio source repository and is not part of this package — the rules
565
+ themselves are the ones stated above.
546
566
 
547
567
  **`recover()` now throws instead of returning `[]`** when the ledger cannot be read
548
568
  (`DecidioRecoveryUnavailableError`). `[]` and "I cannot tell you what you owe" are the same value
@@ -581,8 +601,9 @@ that fits every other confirm error.
581
601
 
582
602
  - **Engine adapters are BETA.** They share the core's gate semantics — the verdict allow-list,
583
603
  the canonical argument model, the transport rule and evidence handling are ONE implementation,
584
- and a conformance matrix runs the same malformed-verdict, unsafe-argument and plaintext-transport
585
- battery against every door. What is *not* yet verified is the engine integration itself: those
604
+ and a conformance matrix runs the same malformed-verdict, unsafe-argument, plaintext-transport and
605
+ malformed-description (blank action, negative amount) battery against every door. What is *not* yet
606
+ verified is the engine integration itself: those
586
607
  paths are exercised against structural mocks, not live LangGraph/Temporal/Inngest/OpenAI
587
608
  runtimes. Use the core path for production until that changes. Each adapter is bound through its
588
609
  own `@decidio/sdk/<engine>` subpath (they need the engine handle at call time); no engine at all?
@@ -631,7 +652,7 @@ manager for production.
631
652
 
632
653
  ## Errors
633
654
 
634
- - `DecidioBlockedError` — policy blocked the action (incl. the universal deny-by-default backstop).
655
+ - `DecidioBlockedError` — policy blocked the action: a named block rule, or the universal backstop that fires when NO active policy governs agents at all (fail closed, no human involved). Distinct from routing-by-default — with an active policy and no matching auto-approve rule, a request is not blocked, it is ROUTED to a person.
635
656
  - `DecidioRejectedError` — a human rejected the routed action.
636
657
  - `DecidioSuspendedError` — (durable mode) the action was routed and is parked for async approval; the process may exit and resume later. Not a failure.
637
658
  - `DecidioTimeoutError` — (blocking mode only) no human decided within `pollTimeoutMs` (default 10 min); the decision stays open in Decidio, nothing executed locally.
@@ -657,6 +678,45 @@ Every error raised after a decision exists carries the `decisionId`, so a suspen
657
678
  timed-out action is always findable in
658
679
  your queue.
659
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
+
660
720
  ## Notes
661
721
 
662
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,5 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, carriesCredential, canonicalize, readConfirmResponse, buildConfirmBody, decisionIdProblem } from "../index.js";
2
+ import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, carriesCredential, canonicalize, assertDescribed, readConfirmResponse, buildConfirmBody, decisionIdProblem } from "../index.js";
3
3
  export const AUTHORIZE_VERDICTS = ["proceed", "route", "block"];
4
4
  export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
5
5
  const a = auth;
@@ -15,6 +15,7 @@ export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
15
15
  }
16
16
  export async function gateAuthorize(config, ctx) {
17
17
  ctx = canonicalize(ctx, "describe(...)");
18
+ assertDescribed(ctx);
18
19
  assertSafeTransport(config.apiUrl, carriesCredential(config));
19
20
  const f = config.fetchImpl ?? fetch;
20
21
  const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
@@ -15,7 +15,7 @@ interface RunStateLike {
15
15
  * park the serialized state agent-side on `route`, resume on Decidio's approval.
16
16
  *
17
17
  * const state = result.state; // or RunState from the run
18
- * const { resolved, pending } = await gateInterruptions(config, state, describe);
18
+ * const { resolved, pending } = await authorizeInterruptions(config, state, describe);
19
19
  * if (pending.length) { await store.put(decisionId, state.toString()); return; } // suspend
20
20
  * result = await run(agent, state); // all resolved → resume
21
21
  * // on Decidio approval webhook: state = await RunState.fromString(agent, parked);
@@ -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
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync } from "node:fs";
2
+ import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync, openSync, writeSync, fsyncSync, closeSync, renameSync } from "node:fs";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { createServer } from "node:http";
5
5
  import { execFileSync } from "node:child_process";
@@ -38,7 +38,10 @@ function readEnvFile() {
38
38
  }
39
39
  return out;
40
40
  }
41
- const OFFLINE_CMD = ["verify", "--version", "-v", "-V", "version", "--help", "-h", "help", undefined].includes(process.argv[2]);
41
+ const wantsHelp = (s) => s === "help" || s === "--help" || s === "-h";
42
+ const ARGV_TAIL = process.argv.slice(2);
43
+ const OFFLINE_CMD = ["verify", "--version", "-v", "-V", "version", undefined].includes(ARGV_TAIL[0])
44
+ || wantsHelp(ARGV_TAIL[0]) || wantsHelp(ARGV_TAIL[1]);
42
45
  if (!OFFLINE_CMD) {
43
46
  readEnvFile();
44
47
  if (envUnreadable) {
@@ -109,7 +112,30 @@ async function api(path, init = {}, bearer) {
109
112
  return { status: res.status, body };
110
113
  }
111
114
  function writeSecretFile(path, body) {
112
- writeFileSync(path, body, { mode: 0o600 });
115
+ const tmp = `${path}.${process.pid}.tmp`;
116
+ const fd = openSync(tmp, "w", 0o600);
117
+ try {
118
+ writeSync(fd, body);
119
+ fsyncSync(fd);
120
+ }
121
+ finally {
122
+ closeSync(fd);
123
+ }
124
+ try {
125
+ chmodSync(tmp, 0o600);
126
+ }
127
+ catch { }
128
+ hardenFile(tmp);
129
+ try {
130
+ renameSync(tmp, path);
131
+ }
132
+ catch (e) {
133
+ try {
134
+ rmSync(tmp, { force: true });
135
+ }
136
+ catch { }
137
+ throw e;
138
+ }
113
139
  try {
114
140
  chmodSync(path, 0o600);
115
141
  }
@@ -123,14 +149,137 @@ async function revokeFreshMint(jti, bearer, writeError, target) {
123
149
  err(`✘ could not write ${target} (${writeError?.message ?? writeError}). The token minted a moment ago (jti ${jti}) is LIVE until revoked - revoking it now.`);
124
150
  const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, bearer);
125
151
  if (rv.status >= 200 && rv.status < 300) {
152
+ journalRetire(jti, "revoked");
126
153
  err(` ✔ the token minted a moment ago (jti ${jti}) was REVOKED again, so nothing live was left behind. Fix the cause above and re-run - it will mint a fresh one.`);
127
154
  return true;
128
155
  }
156
+ let known = false;
157
+ try {
158
+ known = journalOutstanding().some((j) => j.jti === jti);
159
+ }
160
+ catch { }
161
+ if (known)
162
+ err(` Its id is kept in ${JOURNAL_PATH} (owner-only, append-only) until it is revoked - \`quickstart cleanup\` retries it, and \`doctor\` names it.`);
163
+ else
164
+ err(` Its id is NOT recorded anywhere but this terminal (the journal write is what failed) - write it down now and revoke it by hand.`);
129
165
  err(` ⚠ the token minted a moment ago (jti ${jti}) is LIVE and could NOT be revoked (${rv.status}). Revoke it by hand, or re-run init - the server keeps one active token per agent, so a fresh mint supersedes it:`);
130
166
  err(` npx @decidio/sdk tokens revoke ${jti}`);
131
167
  err(` npx @decidio/sdk tokens list --mine`);
132
168
  return false;
133
169
  }
170
+ const JOURNAL_PATH = ".decidio-token-journal";
171
+ const JOURNAL_SCHEMA = 1;
172
+ const JOURNAL_EVENTS = new Set(["probe", "minted", "retired"]);
173
+ function journalAppend(entry) {
174
+ const fresh = !existsSync(JOURNAL_PATH);
175
+ const row = Buffer.from(JSON.stringify({ v: JOURNAL_SCHEMA, ...entry, at: new Date().toISOString() }) + "\n", "utf8");
176
+ const fd = openSync(JOURNAL_PATH, "a", 0o600);
177
+ try {
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);
190
+ fsyncSync(fd);
191
+ }
192
+ finally {
193
+ closeSync(fd);
194
+ }
195
+ if (fresh) {
196
+ try {
197
+ chmodSync(JOURNAL_PATH, 0o600);
198
+ }
199
+ catch { }
200
+ hardenFile(JOURNAL_PATH);
201
+ }
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.`;
250
+ function journalProbe() {
251
+ try {
252
+ journalRows();
253
+ }
254
+ catch (e) {
255
+ return `CORRUPT: ${e?.message ?? e}`;
256
+ }
257
+ try {
258
+ journalAppend({ event: "probe" });
259
+ return null;
260
+ }
261
+ catch (e) {
262
+ return String(e?.message ?? e);
263
+ }
264
+ }
265
+ function journalMinted(jti, agentId, label) { journalAppend({ event: "minted", jti, agentId, label, apiUrl: API }); }
266
+ function journalRetire(jti, how) { try {
267
+ journalAppend({ event: "retired", jti, how });
268
+ }
269
+ catch { } }
270
+ function journalOutstanding() {
271
+ const minted = new Map();
272
+ for (const e of journalRows()) {
273
+ if (e.event === "minted")
274
+ minted.set(e.jti, { jti: e.jti, agentId: e.agentId, label: e.label, apiUrl: e.apiUrl, at: e.at });
275
+ if (e.event === "retired")
276
+ minted.delete(e.jti);
277
+ }
278
+ return [...minted.values()];
279
+ }
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.`;
134
283
  function upsertEnv(updates) {
135
284
  const remaining = { ...updates };
136
285
  const updatedKeys = new Set();
@@ -180,7 +329,7 @@ function assertNotTracked(path) {
180
329
  return false;
181
330
  }
182
331
  function ensureGitignore() {
183
- const entries = [".env", ".env.quickstart", ".env.*.bak", ".decidio-pending/"];
332
+ const entries = [".env", ".env.quickstart", ".env.*.bak", ".env.*.tmp", ".decidio-pending/", ".decidio-token-journal"];
184
333
  const existing = existsSync(".gitignore") ? readFileSync(".gitignore", "utf8") : "";
185
334
  const missing = entries.filter((e) => !existing.split(/\r?\n/).some((l) => l.trim() === e || l.trim() === e.replace(/\/$/, "")));
186
335
  if (!missing.length)
@@ -319,16 +468,33 @@ async function cmdInit(agentId, opts = {}) {
319
468
  let agentToken, workspaceId, tokenJti;
320
469
  const mintBearer = session ?? TOKEN();
321
470
  if (mintBearer) {
471
+ ensureGitignore();
472
+ const noJournal = journalProbe();
473
+ if (noJournal) {
474
+ err(REFUSE_MINT_NO_JOURNAL(noJournal));
475
+ return 1;
476
+ }
322
477
  const expiresInDays = opts.expiresDays ?? 90;
323
478
  const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "sdk-cli", expiresInDays }) }, mintBearer);
324
479
  if (mint.status >= 200 && mint.status < 300 && mint.body?.token) {
325
480
  agentToken = mint.body.token;
326
481
  workspaceId = mint.body.workspaceId;
327
482
  tokenJti = mint.body.jti;
483
+ if (tokenJti) {
484
+ try {
485
+ journalMinted(tokenJti, agentId, "sdk-cli");
486
+ }
487
+ catch (e) {
488
+ await revokeFreshMint(tokenJti, mintBearer, e, JOURNAL_PATH);
489
+ return 1;
490
+ }
491
+ }
328
492
  log(`✔ minted the agent's API token — floor-limited: it can request authorization, poll status and report confirmation; it cannot administer the workspace or approve anything${mint.body.expiresAt ? ` (expires ${mint.body.expiresAt})` : ` (${expiresInDays} days)`}`);
329
493
  const superseded = Array.isArray(mint.body.supersededJtis) ? mint.body.supersededJtis : [];
330
- for (const s of superseded)
494
+ for (const s of superseded) {
495
+ journalRetire(s, "revoked");
331
496
  log(` • this replaces the agent's previous token (jti ${s}) — now REVOKED: the server keeps one active token per agent. Anything still using it will 401.`);
497
+ }
332
498
  }
333
499
  else if (session) {
334
500
  err(`✘ could not mint the agent's API token (${mint.status}): ${JSON.stringify(mint.body)}`);
@@ -356,6 +522,8 @@ async function cmdInit(agentId, opts = {}) {
356
522
  try {
357
523
  prepareEnvForWrite();
358
524
  upsertEnv(envOut);
525
+ if (tokenJti)
526
+ journalRetire(tokenJti, "persisted");
359
527
  }
360
528
  catch (e) {
361
529
  if (agentToken && tokenJti)
@@ -404,6 +572,17 @@ async function cmdDoctor() {
404
572
  let warned = 0;
405
573
  const line = (good, label) => { log(`${good ? "✔" : "✘"} ${label}`); ok = ok && good; };
406
574
  const warn = (label) => { warned++; log(`⚠ ${label}`); };
575
+ let journalItems = [];
576
+ try {
577
+ journalItems = journalOutstanding();
578
+ }
579
+ catch (e) {
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).`);
581
+ }
582
+ for (const j of journalItems) {
583
+ const where = j.apiUrl && j.apiUrl !== API ? `DECIDIO_API_URL=${j.apiUrl} ` : "";
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})`);
585
+ }
407
586
  log(`✔ API URL: ${API}${(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL) ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
408
587
  line(!!(process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID), `agent id: ${process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID || "(unset — run: npx @decidio/sdk init <agent-name>)"}`);
409
588
  const secret = process.env.DECIDIO_WEBHOOK_SECRET || env.DECIDIO_WEBHOOK_SECRET;
@@ -422,7 +601,7 @@ async function cmdDoctor() {
422
601
  else if (derived !== storedDid)
423
602
  line(false, `signing key: DOES NOT MATCH this agent's DID — the key here derives ${derived}, but .env registers ${storedDid}. Signed requests will not be recognised as this agent (auto-approve and application_confirmed evidence will both fail). Restore the original key, or re-run \`npx @decidio/sdk init\` to register a fresh one.`);
424
603
  else
425
- log("✔ signing key: set and MATCHES the registered DID — the agent can prove its identity, making it ELIGIBLE for policy auto-approve. Whether a rule matches is your workspace policy's decision; with no matching rule every request routes to a human (deny-by-default).");
604
+ log("✔ signing key: set and MATCHES the registered DID — the agent can prove its identity, making it ELIGIBLE for policy auto-approve. Whether a rule matches is your workspace policy's decision; with no matching rule every request routes to a human (routing-by-default - a block is different: a named rule, or the universal backstop when no active policy governs agents at all).");
426
605
  }
427
606
  else
428
607
  warn("signing key: MISSING (DECIDIO_AGENT_KEY/DID) — the agent can't prove identity, so EVERY request routes to a human. Run `npx @decidio/sdk init` to generate one.");
@@ -641,6 +820,10 @@ function cmdVerify(argsIn) {
641
820
  const files = [];
642
821
  for (let i = 0; i < argsIn.length; i++) {
643
822
  const a = argsIn[i];
823
+ if (a === "--") {
824
+ files.push(...argsIn.slice(i + 1));
825
+ break;
826
+ }
644
827
  if (a === "--issuer") {
645
828
  const v = argsIn[i + 1];
646
829
  if (v === undefined || v === "" || v.startsWith("-")) {
@@ -696,8 +879,10 @@ async function cmdQuickstart(opts = {}) {
696
879
  log(` Target: ${API}${process.env.DECIDIO_API_URL ? "" : " (the hosted synthetic sandbox — set DECIDIO_API_URL to use your own)"}`);
697
880
  log("");
698
881
  log(" This tour will: register one synthetic agent · mint a 7-DAY floor-limited token ·");
699
- log(" write .env (or .env.quickstart) + .gitignore entries · create one synthetic decision");
700
- 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.");
701
886
  log("");
702
887
  const session = await acquireSession("The quickstart");
703
888
  if (!session)
@@ -729,11 +914,35 @@ async function cmdQuickstart(opts = {}) {
729
914
  err(` Nothing was written to this directory.`);
730
915
  return 1;
731
916
  }
917
+ let prevTourFile = null;
918
+ if (envFile !== ENV_PATH && existsSync(envFile)) {
919
+ try {
920
+ prevTourFile = readFileSync(envFile, "utf8");
921
+ }
922
+ catch (e) {
923
+ err(`✘ ${envFile} exists but could not be read (${e?.message ?? e}) - a previous tour's credentials may be in it, and this tour would have to replace it. Refusing to mint a new token on top of a file this command cannot read: fix its permissions or move it aside, then re-run. Nothing was minted.`);
924
+ return 1;
925
+ }
926
+ }
927
+ const noJournal = journalProbe();
928
+ if (noJournal) {
929
+ err(REFUSE_MINT_NO_JOURNAL(noJournal));
930
+ return 1;
931
+ }
732
932
  const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "quickstart", expiresInDays: 7 }) }, session);
733
933
  if (!(mint.status >= 200 && mint.status < 300) || !mint.body?.token) {
734
934
  err(`✘ token mint failed (${mint.status}): ${JSON.stringify(mint.body)}`);
735
935
  return 1;
736
936
  }
937
+ if (mint.body.jti) {
938
+ try {
939
+ journalMinted(mint.body.jti, agentId, "quickstart");
940
+ }
941
+ catch (e) {
942
+ await revokeFreshMint(mint.body.jti, session, e, JOURNAL_PATH);
943
+ return 1;
944
+ }
945
+ }
737
946
  const envOut = {
738
947
  DECIDIO_API_URL: API, DECIDIO_AGENT_ID: agentId, DECIDIO_AGENT_DID: reg.body?.did ?? did,
739
948
  DECIDIO_AGENT_KEY: privateKeyBase64, DECIDIO_API_TOKEN: mint.body.token,
@@ -741,8 +950,8 @@ async function cmdQuickstart(opts = {}) {
741
950
  ...(mint.body.jti ? { DECIDIO_TOKEN_JTI: mint.body.jti } : {}),
742
951
  };
743
952
  if (envFile !== ENV_PATH) {
744
- if (existsSync(envFile)) {
745
- const prev = readFileSync(envFile, "utf8");
953
+ if (prevTourFile !== null) {
954
+ const prev = prevTourFile;
746
955
  const prevId = prev.match(/^DECIDIO_AGENT_ID=(.+)$/m)?.[1];
747
956
  const prevJti = prev.match(/^DECIDIO_TOKEN_JTI=(.+)$/m)?.[1];
748
957
  const carried = new Set((prev.match(/^DECIDIO_ORPHANED_TOKEN_JTI=(.+)$/m)?.[1] ?? "").split(",").map((s) => s.trim()).filter(Boolean));
@@ -767,6 +976,8 @@ async function cmdQuickstart(opts = {}) {
767
976
  }
768
977
  try {
769
978
  writeSecretFile(envFile, Object.entries(envOut).map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
979
+ if (mint.body.jti)
980
+ journalRetire(mint.body.jti, "persisted");
770
981
  }
771
982
  catch (e) {
772
983
  if (mint.body?.jti)
@@ -780,6 +991,8 @@ async function cmdQuickstart(opts = {}) {
780
991
  else {
781
992
  try {
782
993
  upsertEnv(envOut);
994
+ if (mint.body.jti)
995
+ journalRetire(mint.body.jti, "persisted");
783
996
  }
784
997
  catch (e) {
785
998
  if (mint.body?.jti)
@@ -832,7 +1045,9 @@ async function cmdQuickstart(opts = {}) {
832
1045
  return failTour(1);
833
1046
  }
834
1047
  log(` ✔ $86,000 payInvoice ROUTED to a human (decision ${decisionId}) — this agent has no auto-approve rule,`);
835
- log(` so deny-by-default sends everything to a person. Nothing executes until someone decides.`);
1048
+ log(` so the policy ROUTES it to a person (routing-by-default). That is distinct from a BLOCK, which no human can`);
1049
+ log(` override: a block is a named rule, or the universal backstop when no active policy governs agents at all.`);
1050
+ log(` Nothing executes until someone decides.`);
836
1051
  log("");
837
1052
  const interactive = process.stdin.isTTY;
838
1053
  if (interactive) {
@@ -892,11 +1107,17 @@ async function cmdQuickstart(opts = {}) {
892
1107
  const issuer = rec.body?.issuer;
893
1108
  const v = verifyReceipt(rec.body.credential, issuer);
894
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
+ }
895
1114
  if (!issuer) {
896
1115
  err(` ⚠ the server did not return an issuer DID, so this check ran UNPINNED — it proves the`);
897
1116
  err(` receipt is internally consistent, NOT who issued it (a self-signed forgery also passes).`);
898
1117
  err(` Get your workspace's issuer DID from the app's record-verify panel, then re-check:`);
899
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);
900
1121
  }
901
1122
  if (issuer)
902
1123
  log(` Keep that DID: it is your trust anchor — npx @decidio/sdk verify --issuer ${issuer} <receipt.json>`);
@@ -913,7 +1134,10 @@ async function cmdQuickstart(opts = {}) {
913
1134
  log(` const doIt = guard.protect(doItRaw, (a) => ({ action: "yourAction", amount: a.amount, scope: "YourScope" }));`);
914
1135
  log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
915
1136
  log("");
916
- log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")} · Next: the README's 7-step quickstart (durable, workers, adapters).`);
1137
+ log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")}`);
1138
+ log(`Next: the README section "Production mode: durable async resume" (the worker; the engine adapters follow it).`);
1139
+ log(`The README's seven-step quickstart is this same blocking flow in your own code - if you keep this agent, its .env`);
1140
+ log(`already works here, so skip that flow's init step.`);
917
1141
  log(`Built by OmniTwin — decidioai.com · Putting this in front of real approvals? decidioai.com/#early-access`);
918
1142
  let doCleanup = false;
919
1143
  if (interactive) {
@@ -932,14 +1156,55 @@ async function cmdQuickstart(opts = {}) {
932
1156
  async function quickstartCleanup(session, envFile) {
933
1157
  const env = (() => {
934
1158
  const out = {};
935
- if (existsSync(envFile))
936
- for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
937
- const m = line.match(/^\s*([A-Z_]+)=(.*)$/);
938
- if (m)
939
- out[m[1]] = m[2];
940
- }
1159
+ try {
1160
+ if (existsSync(envFile))
1161
+ for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
1162
+ const m = line.match(/^\s*([A-Z_]+)=(.*)$/);
1163
+ if (m)
1164
+ out[m[1]] = m[2];
1165
+ }
1166
+ }
1167
+ catch (e) {
1168
+ err(` ⚠ ${envFile} could not be read (${e?.message ?? e}) - sweeping the token recovery journal instead.`);
1169
+ }
941
1170
  return out;
942
1171
  })();
1172
+ let journalRevoked = 0;
1173
+ const journalStuck = [];
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) {
1183
+ if (j.apiUrl && j.apiUrl !== API) {
1184
+ journalStuck.push(j.jti);
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}`);
1186
+ continue;
1187
+ }
1188
+ const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(j.jti)}`, { method: "DELETE" }, session);
1189
+ if (rv.status >= 200 && rv.status < 300) {
1190
+ journalRetire(j.jti, "revoked");
1191
+ journalRevoked++;
1192
+ if (rv.body?.changed === false)
1193
+ log(` • journalled token ${j.jti} was already revoked — retiring its journal entry.`);
1194
+ else
1195
+ log(` ✔ journalled token revoked (jti ${j.jti}${j.label ? `, ${j.label}` : ""}) — its credential file never landed, so this was its only handle`);
1196
+ }
1197
+ else {
1198
+ journalStuck.push(j.jti);
1199
+ err(` ⚠ journalled token ${j.jti} could NOT be revoked (${rv.status}) — it is still live; its id stays in ${JOURNAL_PATH}.`);
1200
+ }
1201
+ }
1202
+ if (journalStuck.length) {
1203
+ err(`✘ ${journalStuck.length} journalled token(s) are STILL LIVE and could not be revoked. Nothing was deleted; retry, or revoke by hand:`);
1204
+ for (const jti of journalStuck)
1205
+ err(` npx @decidio/sdk tokens revoke ${jti}`);
1206
+ return 1;
1207
+ }
943
1208
  const orphans = (env.DECIDIO_ORPHANED_TOKEN_JTI ?? "").split(",").map((s) => s.trim()).filter(Boolean);
944
1209
  const stillOrphaned = [];
945
1210
  for (const jti of orphans) {
@@ -970,6 +1235,9 @@ async function quickstartCleanup(session, envFile) {
970
1235
  return 1;
971
1236
  }
972
1237
  }
1238
+ else if (journalRevoked > 0 && !existsSync(envFile) || (journalRevoked > 0 && Object.keys(env).length === 0)) {
1239
+ log(` • no credential file to clean: the journal sweep revoked ${journalRevoked} token(s) this directory owed.`);
1240
+ }
973
1241
  else {
974
1242
  err(`✘ no DECIDIO_TOKEN_JTI in ${envFile} (older tour) — this tour's token is STILL LIVE and this`);
975
1243
  err(` command cannot revoke it. Nothing was deleted. Find and revoke it, then re-run cleanup:`);
@@ -978,9 +1246,18 @@ async function quickstartCleanup(session, envFile) {
978
1246
  err(` (it also expires on its own within 7 days)`);
979
1247
  return 1;
980
1248
  }
1249
+ let envRaw = null;
981
1250
  if (existsSync(envFile)) {
1251
+ try {
1252
+ envRaw = readFileSync(envFile, "utf8");
1253
+ }
1254
+ catch (e) {
1255
+ err(` ⚠ ${envFile} could not be read (${e?.message ?? e}) — left as it is; remove it by hand once it is readable.`);
1256
+ }
1257
+ }
1258
+ if (envRaw !== null) {
982
1259
  const TOUR_KEYS = new Set(["DECIDIO_API_URL", "DECIDIO_AGENT_ID", "DECIDIO_AGENT_DID", "DECIDIO_AGENT_KEY", "DECIDIO_API_TOKEN", "DECIDIO_WORKSPACE_ID", "DECIDIO_TOKEN_JTI", "DECIDIO_ORPHANED_TOKEN_JTI"]);
983
- const kept = readFileSync(envFile, "utf8").split(/\r?\n/).filter((line) => {
1260
+ const kept = envRaw.split(/\r?\n/).filter((line) => {
984
1261
  const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)=/);
985
1262
  return m ? !TOUR_KEYS.has(m[1]) : line.trim() !== "";
986
1263
  });
@@ -1004,6 +1281,12 @@ async function quickstartCleanup(session, envFile) {
1004
1281
  }
1005
1282
  log(` • What remains, by design: the sealed decision record (records are permanent — that is the product)`);
1006
1283
  log(` and the agent's registration row (inert without key or token; server-side deregistration is on the roadmap).`);
1284
+ if (existsSync(".decidio-pending")) {
1285
+ log(` • Also kept: .decidio-pending/ — this agent's own execution ledger: whatever this agent parked (call arguments,`);
1286
+ log(` execution evidence; never credentials, by design). Gitignored; owner-only where the OS supports it. If nothing is`);
1287
+ log(` pending and you do not want the history, remove it:`);
1288
+ log(` ${process.platform === "win32" ? "Remove-Item -Recurse .decidio-pending" : "rm -r .decidio-pending"}`);
1289
+ }
1007
1290
  return 0;
1008
1291
  }
1009
1292
  async function cmdTokens(sub, jti, mineOnly = false) {
@@ -1036,10 +1319,12 @@ async function cmdTokens(sub, jti, mineOnly = false) {
1036
1319
  const rev = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, bearer);
1037
1320
  if (rev.status >= 200 && rev.status < 300) {
1038
1321
  if (rev.body?.changed === false) {
1322
+ journalRetire(jti, "revoked");
1039
1323
  log(`• ${jti} was already revoked — nothing changed.`);
1040
1324
  log(` Revocation is permanent; there is no re-revoke. See it: npx @decidio/sdk tokens list`);
1041
1325
  return 1;
1042
1326
  }
1327
+ journalRetire(jti, "revoked");
1043
1328
  log(`✔ revoked ${jti} — the token is dead now (its agent stays registered; mint a new one with init).`);
1044
1329
  return 0;
1045
1330
  }
@@ -1151,7 +1436,6 @@ Docs: the README that shipped with this package — node_modules/@decidio/sdk/RE
1151
1436
  (quickstart, durable resume, workers, adapters).`;
1152
1437
  async function main(argv) {
1153
1438
  const [cmd, a, b, ...rest] = argv.slice(2);
1154
- const wantsHelp = (s) => s === "help" || s === "--help" || s === "-h";
1155
1439
  if (cmd === "--version" || cmd === "-v" || cmd === "-V" || cmd === "version") {
1156
1440
  log(VERSION);
1157
1441
  return 0;
@@ -1230,9 +1514,15 @@ async function main(argv) {
1230
1514
  case "quickstart": {
1231
1515
  if (argsIn.includes("cleanup")) {
1232
1516
  const envFile = existsSync(".env.quickstart") ? ".env.quickstart"
1233
- : (readEnvFile().DECIDIO_AGENT_ID ?? "").startsWith("quickstart-") ? ENV_PATH : undefined;
1517
+ : (readEnvFile().DECIDIO_AGENT_ID ?? "").startsWith("quickstart-") ? ENV_PATH
1518
+ : (() => { try {
1519
+ return journalOutstanding().length ? ".env.quickstart" : undefined;
1520
+ }
1521
+ catch {
1522
+ return ".env.quickstart";
1523
+ } })();
1234
1524
  if (!envFile) {
1235
- log("Nothing to clean up here: no .env.quickstart, and .env doesn't belong to a quickstart agent.");
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.");
1236
1526
  return 0;
1237
1527
  }
1238
1528
  const session = await acquireSession("Cleaning the quickstart up");
@@ -1251,7 +1541,8 @@ async function main(argv) {
1251
1541
  err("✘ --expires-days needs a positive whole number of days (e.g. --expires-days 30).");
1252
1542
  return 2;
1253
1543
  }
1254
- 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));
1255
1546
  let name = nameArg ?? process.env.DECIDIO_AGENT_ID ?? "my-agent";
1256
1547
  if (unique)
1257
1548
  name = `${name}-${randomBytes(2).toString("hex")}`;
package/dist/errors.js CHANGED
@@ -26,7 +26,11 @@ export class DecidioHttpError extends Error {
26
26
  (credential
27
27
  ? ` — the credential was rejected. The agent token may have expired or been revoked (re-running \`init\` for the same agent revokes the previous one). Mint a fresh one: npx @decidio/sdk init <agent-name>, then restart this process.`
28
28
  : retryable
29
- ? ` — this looks transient; retrying is safe (a retry creates a NEW request and can never double-execute).`
29
+ ? (/\/agent\/confirm/.test(endpoint)
30
+ ? ` — this looks transient. Your action ALREADY RAN; do NOT re-run it — only its report failed to land, and the door's own note says whether a report was kept to re-send.`
31
+ : /\/agent\/status/.test(endpoint)
32
+ ? ` — this looks transient; polling again is safe (a status read changes nothing).`
33
+ : ` — this looks transient; retrying is safe (a retry creates a NEW request and can never double-execute).`)
30
34
  : ` — the server refused the request itself, so retrying it unchanged will fail the same way. Fix the request (or the decision it names) first.`) +
31
35
  sideEffectNote(endpoint, typeof body?.code === "string" ? body.code : undefined));
32
36
  this.status = status;
package/dist/index.d.ts CHANGED
@@ -103,6 +103,11 @@ export declare class DecidioNetworkError extends Error {
103
103
  code: string | undefined;
104
104
  constructor(endpoint: string, op: string, code: string | undefined, cause: unknown);
105
105
  }
106
+ /** THE ONE DESCRIBE VALIDATION (prodsec V033-05). The core wrapper refused a blank `action` and a
107
+ * negative / non-finite `amount` before routing; the adapter gate canonicalized and posted them, so
108
+ * every engine door reached the mock authorization path with a request the core would have refused.
109
+ * Exported so `adapters/_gate.ts` calls the same function rather than a second copy that can drift. */
110
+ export declare function assertDescribed(ctx: ApprovalContext): void;
106
111
  /**
107
112
  * Wrap an async action behind the Decidio gate. `describe` extracts the approval
108
113
  * context (action, amount, scope) from the call arguments. Returns a function with
package/dist/index.js CHANGED
@@ -78,6 +78,14 @@ async function reach(f, url, op, init) {
78
78
  throw new DecidioNetworkError(url, op, e?.cause?.code ?? e?.code, e);
79
79
  }
80
80
  }
81
+ export function assertDescribed(ctx) {
82
+ if (typeof ctx?.action !== "string" || ctx.action.trim().length === 0) {
83
+ throw new Error(`[decidio] describe(...) must return an \`action\` string naming what is being asked (got ${JSON.stringify(ctx?.action ?? null)}). Nothing was routed or executed.`);
84
+ }
85
+ if (typeof ctx.amount !== "number" || !Number.isFinite(ctx.amount) || ctx.amount < 0) {
86
+ throw new Error(`[decidio] describe(...) must return a finite, non-negative \`amount\` — the policy decides on it, so a request without one is not a request Decidio can rule on (got ${JSON.stringify(ctx.amount ?? null)}). Use 0 for an action with no monetary value. Nothing was routed or executed.`);
87
+ }
88
+ }
81
89
  async function post(cfg, path, body) {
82
90
  const f = cfg.fetchImpl ?? fetch;
83
91
  assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
@@ -142,12 +150,7 @@ export function withApproval(fn, describe, config, opts) {
142
150
  const requesterIdentity = (config.agentKey && config.agentDid)
143
151
  ? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
144
152
  : undefined;
145
- if (typeof ctx?.action !== "string" || ctx.action.trim().length === 0) {
146
- throw new Error(`[decidio] describe(...) must return an \`action\` string naming what is being asked (got ${JSON.stringify(ctx?.action ?? null)}). Nothing was routed or executed.`);
147
- }
148
- if (typeof ctx.amount !== "number" || !Number.isFinite(ctx.amount) || ctx.amount < 0) {
149
- throw new Error(`[decidio] describe(...) must return a finite, non-negative \`amount\` — the policy decides on it, so a request without one is not a request Decidio can rule on (got ${JSON.stringify(ctx.amount ?? null)}). Use 0 for an action with no monetary value. Nothing was routed or executed.`);
150
- }
153
+ assertDescribed(ctx);
151
154
  const auth = await post(config, "/agent/authorize", {
152
155
  requester: config.agentId, action: ctx.action, amount: ctx.amount,
153
156
  scope: ctx.scope, sourceSystem: config.sourceSystem,
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/inngest.js CHANGED
@@ -28,6 +28,8 @@ export async function decidioGateStep(args) {
28
28
  export function createInngestResumeBridge(opts) {
29
29
  const secret = opts.webhookSecret ?? "";
30
30
  const eventName = opts.eventName ?? DECIDIO_RESUME_EVENT;
31
+ if (!secret && opts.allowUnsigned)
32
+ console.warn("[decidio] inngest resume bridge: allowUnsigned=true and no webhookSecret — resume signals are NOT authenticated; anyone who can reach this endpoint can emit a resume event. Development only.");
31
33
  return (req, res) => {
32
34
  if (req.method !== "POST") {
33
35
  res.statusCode = 405;
package/dist/resume.d.ts CHANGED
@@ -450,7 +450,12 @@ 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
- export type ResumeStatus = "executed" | "rejected" | "blocked" | "no_handler" | "unknown" | "indeterminate";
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
+ export type ResumeStatus = "executed" | "rejected" | "blocked" | "no_handler" | "unknown" | "indeterminate" | "already_resolved";
454
459
  /** The states a durable action can actually be in — every one of them nameable, including the
455
460
  * one nobody likes. */
456
461
  export type RecoveryState =
@@ -472,7 +477,9 @@ export type RecoveryState =
472
477
  * and must never be retried automatically. */
473
478
  | "indeterminate"
474
479
  /** Executed, result captured, report not yet accepted by Decidio. Safe to `reconfirm`. */
475
- | "executed_unreported";
480
+ | "executed_unreported"
481
+ /** a parked row whose payload is gone: cannot execute, not awaiting anyone (0.3.5) */
482
+ | "orphaned_state";
476
483
  export interface RecoveryItem {
477
484
  decisionId: string;
478
485
  action?: string;
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" : "unknown", decisionId, action: pending.action, reason: claim.reason });
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
  }
@@ -1269,7 +1274,12 @@ export function createDecidioResume(opts) {
1269
1274
  guidance: `"${a}" was claimed at ${r.at} by attempt ${r.attemptId ?? "(unknown)"} which never reached the invocation boundary — the handler provably did not run, and nothing will retry it on its own. Release it: guard.resume.reconcile("${r.decisionId}", { outcome: "not_committed", reason: "claimed but never invoked" }).` });
1270
1275
  break;
1271
1276
  case "parked":
1272
- items.push({ decisionId: r.decisionId, action: r.action, state: "parked", guidance: `"${a}" is parked awaiting a human decision. Nothing has executed.` });
1277
+ if (!(await ctrl.store.get(r.decisionId))) {
1278
+ items.push({ decisionId: r.decisionId, action: r.action, state: "orphaned_state", guidance: `"${a}" is recorded as parked but its payload is gone, so nothing can execute it — an orphaned row (usually a legacy denial whose state was never written), not work awaiting a human. Nothing will run. It needs no action; remove its revision files if you want the listing clean.` });
1279
+ }
1280
+ else {
1281
+ items.push({ decisionId: r.decisionId, action: r.action, state: "parked", guidance: `"${a}" is parked awaiting a human decision. Nothing has executed.` });
1282
+ }
1273
1283
  break;
1274
1284
  case "discarded":
1275
1285
  if (r.executionDespiteDenial) {
package/dist/signing.d.ts CHANGED
@@ -74,6 +74,14 @@ export declare function assertJsonSafe(value: unknown, path?: string): void;
74
74
  * rule is about the WIRE, not which credential (the interactive sign-in enforced this for
75
75
  * session tokens while agent-token requests sailed through). localhost/127.0.0.1/[::1] stay
76
76
  * allowed for local dev; https always allowed. Throws before anything is sent. */
77
+ /** The URL as it may be ECHOED in a refusal: userinfo masked. The SDK treats URL userinfo as a credential
78
+ * (it is refused as one), so no refusal may print it - every branch of assertSafeTransport echoes through
79
+ * this, not the raw string (invariant-guard, 0.3.5). */
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;
77
85
  export declare function assertSafeTransport(url: string, hasCredential: boolean): void;
78
86
  /** The ONE answer to "does this request carry a credential worth protecting?" (A6, 0.1.12).
79
87
  * A signed identity proof is a credential too: sending it over remote plaintext disclosed
package/dist/signing.js CHANGED
@@ -106,7 +106,20 @@ export function assertJsonSafe(value, path = "args") {
106
106
  };
107
107
  walk(value, path);
108
108
  }
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]/;
109
113
  export function assertSafeTransport(url, hasCredential) {
114
+ if (typeof url !== "string") {
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.`);
116
+ }
117
+ for (const ch of url) {
118
+ const cp = ch.codePointAt(0);
119
+ if (cp <= 0x20 || (cp >= 0x7f && cp <= 0x9f) || cp === 0xfeff || URL_UNICODE_SPACE.test(ch)) {
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.`);
121
+ }
122
+ }
110
123
  let u;
111
124
  try {
112
125
  u = new URL(url);
@@ -114,9 +127,9 @@ export function assertSafeTransport(url, hasCredential) {
114
127
  catch {
115
128
  const spelled = url.replace(/[\t\r\n]/g, "").replace(/^[\s\u0000-\u001f\u0085\ufeff]+/, "");
116
129
  if (/^http:/i.test(spelled)) {
117
- throw new Error(`[decidio] refusing to ${hasCredential ? "send credentials" : "send a request"} over plaintext http to ${JSON.stringify(url)} — that is not a complete absolute URL, so where the request would actually connect is undefined (the path is appended to it, which can turn it into a remote host). Set a full https URL, or http://localhost for local dev. Nothing was sent.`);
130
+ throw new Error(`[decidio] refusing to ${hasCredential ? "send credentials" : "send a request"} over plaintext http to ${JSON.stringify(redactUrl(url))} — that is not a complete absolute URL, so where the request would actually connect is undefined (the path is appended to it, which can turn it into a remote host). Set a full https URL, or http://localhost for local dev. Nothing was sent.`);
118
131
  }
119
- throw new Error(`[decidio] the gate URL ${JSON.stringify(url)} is not an absolute URL, so there is no host to send to — where a request would actually connect depends entirely on whichever transport resolves it. Decidio needs a full https URL (or http://localhost for local dev). Nothing was sent.`);
132
+ throw new Error(`[decidio] the gate URL ${JSON.stringify(redactUrl(url))} is not an absolute URL, so there is no host to send to — where a request would actually connect depends entirely on whichever transport resolves it. Decidio needs a full https URL (or http://localhost for local dev). Nothing was sent.`);
120
133
  }
121
134
  if (u.protocol !== "http:" && u.protocol !== "https:") {
122
135
  throw new Error(`[decidio] the gate URL uses the ${u.protocol.replace(":", "")} scheme, which is not a Decidio endpoint — use https (or http://localhost for local dev). Nothing was sent.`);
@@ -125,14 +138,14 @@ export function assertSafeTransport(url, hasCredential) {
125
138
  throw new Error(`[decidio] the gate URL carries credentials in the URL itself (${u.protocol}//user:***@${u.host}). Decidio authenticates with a bearer token; remove the user:password from the URL. Nothing was sent.`);
126
139
  }
127
140
  if (u.hostname === "") {
128
- throw new Error(`[decidio] the gate URL ${JSON.stringify(url)} has no host — there is nowhere for the request to go. Decidio needs a full https URL (or http://localhost for local dev). Nothing was sent.`);
141
+ throw new Error(`[decidio] the gate URL ${JSON.stringify(redactUrl(url))} has no host — there is nowhere for the request to go. Decidio needs a full https URL (or http://localhost for local dev). Nothing was sent.`);
129
142
  }
130
143
  {
131
144
  const spelled = url.replace(/[\t\r\n]/g, "").replace(/^[\s\u0000-\u001f\u0085\ufeff]+/, "").toLowerCase();
132
145
  const origin = `${u.protocol}//${u.host}`.toLowerCase();
133
146
  const next = spelled.charAt(origin.length);
134
147
  if (!spelled.startsWith(origin) || (next !== "" && next !== "/" && next !== "?" && next !== "#" && next !== ":")) {
135
- throw new Error(`[decidio] the gate URL ${JSON.stringify(url)} is not written in canonical form — this parser reads it as ${origin}, but the raw spelling is what a transport receives, and a parser that normalizes differently can read it as another host entirely. Write it as ${origin}. Nothing was sent.`);
148
+ throw new Error(`[decidio] the gate URL ${JSON.stringify(redactUrl(url))} is not written in canonical form — this parser reads it as ${origin}, but the raw spelling is what a transport receives, and a parser that normalizes differently can read it as another host entirely. Write it as ${origin}. Nothing was sent.`);
136
149
  }
137
150
  }
138
151
  if (u.protocol !== "http:")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decidio/sdk",
3
- "version": "0.3.3",
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.3"
86
+ "@decidio/verify": "0.4.0"
86
87
  },
87
88
  "author": "OmniTwin Technologies Inc.",
88
89
  "bugs": {