@decidio/sdk 0.4.4 → 0.6.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 CHANGED
@@ -4,6 +4,77 @@ Release notes for the client SDK. The Python package `decidio` ships the same ch
4
4
  version (the two are conformance-locked twins). Each entry names what changed, what a reader must do
5
5
  on upgrade, and what proved it.
6
6
 
7
+ ## 0.6.0 — `quickstart --demo`: the whole tour on your machine, no account
8
+
9
+ An uninvited developer could not run the tour at all: a workspace needs an invite (no open signup, by ruling). A
10
+ hosted sandbox was designed, red-teamed by the privacy council (BLOCK as written; eight design-level highs) and then
11
+ costed against the alternatives: the cheapest, least-maintenance path for a stranger's first two minutes is a LOCAL
12
+ demo with a hosted page for the phone moment. Founder ruling 2026-09-05.
13
+
14
+ - **`npx @decidio/sdk quickstart --demo`** runs the SAME tour code against an in-process stand-in of the Decidio API
15
+ on 127.0.0.1 (`demo-gate.ts`, shipped in this package): register, mint, gate, route, approve or reject, execute
16
+ once, and a REAL Ed25519 receipt signed by a key generated for the run — verifiable offline with the shipped
17
+ verifier, and provably NOT the hosted issuer's (pinning it to the hosted DID fails). Nothing hosted, no account, no
18
+ workspace: the tour runs in a temporary folder and leaves exactly one file behind, the receipt (never over an
19
+ existing one). Its content says `recordSeries: local-demo`.
20
+ - **The web and phone doors still work.** The stand-in asks a hosted RELAY (`POST /demo/relay`) for a code, a link to
21
+ the demo decision page (`app.decidioai.com/demo/<code>`) and a QR; the page shows the product's decision surface for
22
+ the tour's fixed scenario and writes one word — approve or reject — which the stand-in reads while the tour waits.
23
+ The relay holds no account, no record and no personal data; a code lives ten minutes, takes one verdict, and every
24
+ failure answers with one shape. Offline (relay unreachable) the tour is terminal-only and says so.
25
+ - **Honesty is asserted, not hoped:** the frames say LOCAL DEMO; ④ says the issuer is a key this demo generated and
26
+ where real tours pin theirs; the smoke plants the forbidden phrases ("PINNED to your workspace issuer", "hosted
27
+ synthetic sandbox", "your trust anchor") and asserts the directory diff is the receipt alone; a decision made on the
28
+ page reaches the terminal with no keystroke. The sign-in dead end now offers `--demo`.
29
+ - **On a phone the demo IS the phone app's screen.** A phone that opens the demo link (the QR, or the link on a phone)
30
+ lands on the phone build's own decision screen — the same Detail the installed app renders, with Approve and Reject
31
+ where they always are — not the desktop panel squeezed into a phone viewport (founder UAT round 2). It holds no
32
+ session and says LOCAL DEMO in one line; the decided moment says the terminal seals. Host-side only: nothing in this
33
+ package changed for it.
34
+ - **The demo leaves your sign-in alone.** `quickstart --demo` never signs in, so its cleanup no longer ends a stored
35
+ sign-in that happens to be on the computer (it did, in the founder's UAT; the smoke now plants one and asserts it
36
+ survives, silently).
37
+ - TypeScript CLI first — `npx @decidio/sdk quickstart` is where an uninvited developer arrives; the Python twin gains
38
+ `--demo` when asked for (the version bump is in lockstep).
39
+
40
+ ## 0.5.0 — `verify` pins by default
41
+
42
+ **Breaking, on purpose (founder ruling 2026-09-04, after the prodsec review of 0.4.3).** Every CLI verify door - `npx
43
+ @decidio/sdk verify`, the standalone `decidio-verify`, `python -m decidio verify` and `python -m decidio.verify` -
44
+ now REFUSES to run without `--issuer <did:key>` (exit 2, with the remedy printed). An unpinned VALID only proves the
45
+ file agrees with itself: the reviewer forged a self-signed "wire all funds" receipt and the exit code said yes. Scripts
46
+ trust exit status, not warnings, so the safe check is now the default and the structural check is a named choice:
47
+ `--allow-unpinned` runs it, and its verdict carries the UNPINNED warning as before. `--allow-unpinned` together with
48
+ `--issuer` is refused as ambiguous, like a disagreeing repeated pin.
49
+
50
+ - **What to change:** add `--issuer <your workspace DID>` to any scripted verify (the `receipt` command prints the
51
+ DID; the app's record-verify panel shows it), or `--allow-unpinned` where you genuinely want structure only.
52
+ - **Unchanged:** the LIBRARY (`verifyReceipt(vc)` / `verify_receipt(vc)`) still verifies unpinned when no issuer is
53
+ passed - a caller reads `issuer` in the result and decides; the CLIs are what scripts trust blindly.
54
+ - Vectors in all three suites: the refusal (exit 2, names `--issuer` and `--allow-unpinned`), the named unpinned run
55
+ reaching a verdict, both flags refused together. The usage pages, both READMEs and the `receipt` command's hint
56
+ say the new rule; the live selftest names its one unpinned run.
57
+ - **The sealed payload names two fields for what they are (schemaVersion 2).** `deciders` → `nominatedDeciders` (the
58
+ people nominated IN ADVANCE; the ratifier is who decided, so `nominated deciders: 0, ratified: yes` reads as it
59
+ should) and, inside `authority`, `appliedLimits` → `evaluatedLimits` (what the request was measured against - the
60
+ requested amount, the auto-approve threshold, the hard cap - not limits that "applied"). New records carry the v2
61
+ `payloadSchemaUri`; the canonicalization (`jcs-v1`) is unchanged, so verification is untouched. Both verifiers print
62
+ `nominated deciders: N` and read the v1 names too (every record sealed before 0.5.0 is lab test data). Founder
63
+ ruling 2026-09-04: rename now, while no customer record carries the old names.
64
+ - **From the 0.4.4 first-run audit (B+), the small items** (founder: go): quickstart checks connectivity BEFORE its
65
+ first disk write (a dead host leaves the directory untouched and says "Nothing was written"); `tokens list --mine`
66
+ after cleanup reads this directory's agents from the token recovery journal; `receipt` prints resolved identities
67
+ only on a terminal (a pipe or a log gets a one-line note; `--json` hands them over explicitly); the ④ frame says the
68
+ issuer DID was first seen over this connection and where to cross-check it (`<api>/api/did`, public, no sign-in);
69
+ the help page and README say what cleanup leaves behind; the README no longer calls the lab ONE SHARED workspace
70
+ (every invited tester has their own), explains the in-memory quota counter, lists `receipt --json` and `dev --port`,
71
+ and calls the journal append-only by behaviour, not by enforcement. Vectors: the dead-host tour, `--mine` from the
72
+ journal, the receipt in a pipe vs on a terminal.
73
+ - Two Lows from the prodsec retest of 0.4.4: `quickstart cleanup` with nothing to clean still ENDS the stored sign-in
74
+ (the early "Nothing to clean up here" return skipped it, contradicting the documented promise; vector in both
75
+ suites), and the resume module's comments say `lost_race` / `already_resolved` where the runtime does (they still
76
+ said `unknown`); `onResolved` is documented as firing for every outcome except `unknown`.
77
+
7
78
  ## 0.4.4 — the small findings of the 0.4.3 prodsec run: frames at 80 columns, a local install upgrades in place, a key-less .env survives the tour
8
79
 
9
80
  An external prodsec reviewer ran 0.4.3 from a stranger's install (2026-09-04; grade B, "trust in the core mechanics
package/README.md CHANGED
@@ -9,7 +9,8 @@ is the two-minute tour, the sign-in and the token admin around it. Install, wrap
9
9
  ## The two-minute tour
10
10
 
11
11
  ```bash
12
- npx @decidio/sdk quickstart
12
+ npx @decidio/sdk quickstart # against your workspace (you need an invite - request one below)
13
+ npx @decidio/sdk quickstart --demo # no invite yet? the same tour on this machine, against a local stand-in of the API
13
14
  ```
14
15
 
15
16
  One command, guided: it registers a fresh agent (key generated locally), gates a demo action
@@ -29,17 +30,18 @@ prints the approve command and exits rather than approve its own request (CI can
29
30
 
30
31
  ## Quickstart — seven steps to your first sealed receipt
31
32
 
32
- Under 30 minutes, no help needed. You'll need Node 20.6+ and a terminal.
33
+ Under 30 minutes for all seven steps (the tour itself is two minutes), no help needed. You'll need Node 20.6+ and a terminal.
33
34
 
34
35
  **You'll need a Decidio workspace.** → Request one at [decidioai.com](https://decidioai.com/#early-access)
35
36
 
36
- The hosted sandbox is synthetic data, safe to experiment in. Note it is ONE SHARED workspace:
37
- workspace-wide views like `tokens list` or `approvals` show every lab member's agents and
38
- decisions — the shared-lab boundary working as designed. Production workspaces are per-tenant.
37
+ The hosted sandbox is synthetic data, safe to experiment in. Every invited tester gets their OWN
38
+ workspace: workspace-wide views like `tokens list` or `approvals` show every agent and decision in
39
+ your workspace — yours alone unless you invite others into it. Production workspaces are per-tenant too.
39
40
 
40
41
  Lab workspaces have a **governed-action allowance** (currently 30 actions/hour); hitting it raises
41
42
  a typed `DecidioRateLimitError` naming the limit and reset time — nothing is routed or executed —
42
- and `tokens list` shows your remaining allowance any time.
43
+ and `tokens list` shows your remaining allowance any time. The counter is in memory, per API instance: a
44
+ deploy resets it, so `30/30` right after a governed action means the API restarted, not that the action was free.
43
45
 
44
46
  **PowerShell users** — the equivalents for every Bash idiom used below:
45
47
 
@@ -66,7 +68,7 @@ npx @decidio/sdk init --unique my-agent # --unique appends a random suffix: co
66
68
  > Just ran `npx @decidio/sdk quickstart` in this directory and kept its agent? Its `.env` already
67
69
  > works — skip this step and continue at step 3.
68
70
 
69
- Every CLI command talks to the hosted sandbox by default and announces its target host on
71
+ Every network command talks to the hosted sandbox by default (`verify` is offline) and announces its target host on
70
72
  stderr each run — set `DECIDIO_API_URL` (env or `.env`) to point at another Decidio instead.
71
73
 
72
74
  `init` signs you into your workspace (email + password, or paste the sign-in link from your
@@ -152,9 +154,10 @@ npx @decidio/sdk verify --issuer <did:key:...> decidio-receipt-<decisionId>.json
152
154
  ```
153
155
 
154
156
  Lead with the pin: `--issuer` binds the check to YOUR workspace's DID (the `receipt` command
155
- prints it from the authenticated response, so it is already on your screen). An UNPINNED
156
- `verify` proves internal consistency only — any keyholder could have issued such a file — and
157
- says so; pinned verification proves *who*. The download is an authenticated fetch from your
157
+ prints it from the authenticated response, so it is already on your screen). Since 0.5.0 the
158
+ CLI refuses to run without it: an UNPINNED check proves internal consistency only — any keyholder
159
+ could have issued such a file — so it is a named choice (`--allow-unpinned`), and its verdict says
160
+ so; pinned verification proves *who*. The download is an authenticated fetch from your
158
161
  workspace; the *verification* is fully offline — the verifier ships inside this package (no second download, no registry probe, same
159
162
  implementation as the standalone `@decidio/verify`). The receipt is a W3C Verifiable
160
163
  Credential: signature and content binding verify with no Decidio account and no network — the
@@ -523,14 +526,14 @@ ever wanted it is an offline job with every writer stopped.
523
526
  The cryptography is only half the evidence. These are the fields that an outside reader has
524
527
  actually misread on a first run, and what each one means.
525
528
 
526
- **`ratified: yes` next to `deciders: 0` is not a contradiction.** `deciders` is the list of people
529
+ **`ratified: yes` next to `nominated deciders: 0` is not a contradiction.** `nominatedDeciders` is the list of people
527
530
  nominated **in advance** to decide. When an agent's request is *routed* rather than pre-assigned,
528
531
  that list is empty — nobody was nominated, because the request had not happened yet. The person who
529
532
  actually decided is the **ratifier** (`ratifiedBy`). So a receipt for a human-approved action reads
530
- `deciders: 0, ratified: yes`, and the human is recorded, in the field named for the role they
533
+ `nominated deciders: 0, ratified: yes`, and the human is recorded, in the field named for the role they
531
534
  played. The verifier prints this explanation whenever the pair appears.
532
535
 
533
- **`appliedLimits` is what the request was MEASURED AGAINST, not a rule that fired.** It holds up to
536
+ **`evaluatedLimits` is what the request was MEASURED AGAINST, not a rule that fired.** (Receipts sealed before 0.5.0 spell these two `deciders` and `appliedLimits` - schemaVersion 1; the verifier reads both.) It holds up to
534
537
  three entries, and only those that have a value: `requested_amount` (the amount actually asked for),
535
538
  `auto_approve_threshold` and `hard_cap` (the ceilings on the agent's own token). So a decision routed
536
539
  to a human because *no rule matched* can still list an `auto_approve_threshold` — that is the bar the
@@ -551,14 +554,14 @@ the roadmap; today the URN is an identifier only, and this note exists so it doe
551
554
 
552
555
  **`prevHash` proves a pointer, not an ancestry.** A non-null `prevHash` shows this record was
553
556
  sealed after a specific predecessor, but a single-file verification cannot check that predecessor
554
- exists or is what it claims. Verify a chain (`verify a.json b.json c.json`) when the sequence
557
+ exists or is what it claims. Verify a chain (`verify --issuer <did> a.json b.json c.json`) when the sequence
555
558
  matters.
556
559
 
557
560
  **Pinning is trust-on-first-use.** The first time you fetch a receipt, the record *and* the issuer
558
561
  DID arrive in the same authenticated response — so that first check trusts the connection, not the
559
562
  key. What pinning buys is everything afterwards: record the DID once, out of band if you can, and
560
563
  every later verification is checked against a key you chose rather than one you were handed. The
561
- verifier says so on every unpinned run.
564
+ verifier refuses an unpinned run unless you ask for one (`--allow-unpinned`), and marks that verdict.
562
565
 
563
566
  ## Upgrading from 0.2.x to 0.3.0
564
567
 
@@ -638,21 +641,23 @@ that fits every other confirm error.
638
641
 
639
642
  - `quickstart` — the guided two-minute tour (gate → approve → execute → proof)
640
643
  - `--auto-approve-demo` approves the tour's own synthetic decision so it can run headless; it
641
- then CLEANS UP the tour agent and token automatically. Pass `--keep-agent` to keep them.
644
+ then revokes the tour token and deletes its local credentials automatically (the inert
645
+ registration row and the permanent sealed record stay). Pass `--keep-agent` to keep them.
642
646
  - An interactive run keeps the agent by default and offers cleanup at the end;
643
647
  `quickstart cleanup` works later too.
644
648
  - `init [agentId] [--unique] [--force] [--expires-days N]` — sign in, register, mint the agent token, write `.env` (+ `.gitignore` entries). Refuses to overwrite a *different* agent's key in this directory; `--force` backs the old `.env` up first.
645
649
  - `doctor` — config, connectivity, and what your current token can do
646
650
  - `approvals [--mine] [--json]` / `approvals approve|reject <id> [reason]` — the human side, from your terminal (the list is workspace-wide; `--mine` narrows it to this directory's agent)
647
- - `receipt <id>` — download a sealed decision's Authority Receipt to a `.json` file
651
+ - `receipt <id> [--json]` — download a sealed decision's Authority Receipt to a `.json` file (`--json`: stdout is the JSON result only; the identities your access covers print only on a terminal, or in the JSON)
648
652
  - `verify [--issuer <did>] <file>...` — verify receipts offline (the canonical verifier; the standalone `@decidio/verify` package is the same implementation, for auditors who want zero Decidio dependency — the sdk depends on the verifier released alongside it, at the same version). Unknown flags are refused, never ignored. Fully offline AND fully decoupled: it touches no network and reads no `.env` — a receipt verifies identically from an empty directory.
649
653
  - `tokens list [--mine]` / `tokens revoke <jti>` — see and kill agent tokens (values are never re-shown; the jti is the revocation handle). Each row carries its status — `active`, `expired`, or `REVOKED <date>` — so the list confirms the revocations it performs; re-revoking is an honest no-op (exit 1). The list is workspace-wide (a shared workspace shows every member's agents); `--mine` narrows to this directory's agent
650
654
  - `quickstart cleanup` — revoke the tour's token and delete its local credentials. What stays, honestly: sealed records (permanent by design) and the agent's registration row (inert without key or token; server-side deregistration is on the roadmap) Also ends the stored sign-in.
651
655
  - **Update check.** Network commands ask the public npm registry once a day whether a newer version exists; if so, an interactive run asks `Upgrade now? [y/n]` first (`y` upgrades the way you installed - `npm i @decidio/sdk@latest` in the project for a local install, `-g` for a global one, and under `npx` it prints the `@latest` form - then re-runs your command once; `n` exits), a headless run prints the notice and continues (`DECIDIO_REQUIRE_LATEST=1` makes it exit). `DECIDIO_NO_UPDATE_CHECK=1` skips it. Nothing is sent to Decidio; `verify` never checks.
652
- - **The token recovery journal** (`.decidio-token-journal`, owner-only, gitignored; ids and timestamps, never a token value). One JSON row per event: `minted` (a token was issued, written BEFORE its credential file), `retired how=persisted` (the credential file now holds it - the token is live and that file is its handle; not revoked), `retired how=revoked` (revoked server-side), `probe` (a durability check). `doctor` names rows still outstanding; `quickstart cleanup` sweeps them.
656
+ - **The token recovery journal** (`.decidio-token-journal`, owner-only, gitignored; ids and timestamps, never a token value; append-only by the CLI's behaviour — not OS-enforced or tamper-evident). One JSON row per event: `minted` (a token was issued, written BEFORE its credential file), `retired how=persisted` (the credential file now holds it - the token is live and that file is its handle; not revoked), `retired how=revoked` (revoked server-side), `probe` (a durability check). `doctor` names rows still outstanding; `quickstart cleanup` sweeps them.
657
+ - `quickstart --demo` — the same tour with no account: an in-process stand-in of the API on 127.0.0.1 (shipped in this package) answers every door, a key generated for the run signs a real receipt (its content says `local-demo`; pinning it to the hosted issuer fails), and the web/phone door is a hosted demo decision page reached through a one-word relay (no account, no record, ten minutes). Your folder gains exactly one file, the receipt. Offline, the tour is terminal-only and says so.
653
658
  - `quickstart --phone web` — the QR at the human step opens the phone web rendering even when the Decidio app is installed (by default it opens the app when there is one).
654
659
  - `login [email]` / `logout` — sign in ONCE (the CLI emails you a link; you paste it) and forget that sign-in on this computer (the session token itself stays valid until it expires (up to 30 days) — there is no server-side revocation yet). The session is stored in your user profile, owner-only, bound to the terminal window that created it, for 30 days; `DECIDIO_SESSION_TOKEN` in the environment outranks it (headless shells).
655
- - `dev [--target URL]` — local relay for the resume webhook (operator setups)
660
+ - `dev [--target URL] [--port N]` — local relay for the resume webhook (operator setups)
656
661
 
657
662
  The CLI's stream contract: **stdout carries results only** (safe to pipe — `--json` output is
658
663
  pure JSON); errors, usage shown on *error* paths, and the per-run target-host announcement go
@@ -706,7 +711,7 @@ the next reader grades a promise, not a moving target:
706
711
  - **The issuer DID is trust on first use.** The tour and `receipt` print your workspace's issuer DID
707
712
  from the same authenticated response that carries the receipt. Pinned verification is only as strong
708
713
  as where you keep that DID: record it through a channel you trust independently (your app's
709
- record-verify panel today; a published well-known location is on the roadmap).
714
+ record-verify panel, and published in the open by the Decidio host you use at `<api>/api/did` — for the hosted sandbox https://decidio-api.onrender.com/api/did).
710
715
  - **Verification is cryptographic, not schema validation.** `VALID` means the issuer's key signed these
711
716
  canonical claims; the sealed-payload schema the receipt names by hash is not shipped, so a
712
717
  structurally odd but correctly signed document is not refused on shape. Shipping the schema is on
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync, openSync, writeSync, fsyncSync, closeSync, renameSync, mkdirSync } from "node:fs";
2
+ import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync, openSync, writeSync, fsyncSync, closeSync, renameSync, mkdirSync, mkdtempSync, readdirSync, copyFileSync } from "node:fs";
3
3
  import { randomBytes } from "node:crypto";
4
- import { homedir } from "node:os";
4
+ import { homedir, tmpdir } from "node:os";
5
5
  import { join as joinPath, resolve as resolvePath, sep as pathSep } from "node:path";
6
6
  import { createServer } from "node:http";
7
7
  import { execFileSync, spawnSync } from "node:child_process";
@@ -12,7 +12,8 @@ import { createRequire } from "node:module";
12
12
  import { generateKeypair, assertSafeTransport, redactUrl } from "./signing.js";
13
13
  import { hardenFile } from "./resume.js";
14
14
  import { withApproval, DecidioRateLimitError, DecidioRejectedError, DecidioTimeoutError, didFromPrivateKey } from "./index.js";
15
- import { verifyReceipt, runVerify } from "@decidio/verify";
15
+ import { verifyReceipt, runVerify, pinRequiredMessage } from "@decidio/verify";
16
+ import { startDemoGate } from "./demo-gate.js";
16
17
  const VERSION = (() => {
17
18
  try {
18
19
  return createRequire(import.meta.url)("../package.json").version;
@@ -65,7 +66,13 @@ if (!OFFLINE_CMD)
65
66
  if (process.env[k] === undefined)
66
67
  process.env[k] = v;
67
68
  }
68
- const API = (process.env.DECIDIO_API_URL ?? "https://decidio-api.onrender.com").replace(/\/$/, "");
69
+ let API = (process.env.DECIDIO_API_URL ?? "https://decidio-api.onrender.com").replace(/\/$/, "");
70
+ let DEMO = false;
71
+ function enterDemo(url) { API = url; process.env.DECIDIO_API_URL = url; DEMO = true; }
72
+ const pinWords = () => (DEMO ? "PINNED to this demo's own key" : "PINNED to your workspace issuer");
73
+ const issuerNote = () => (DEMO
74
+ ? "· a key this demo generated for this run — real tours pin the host's published DID at https://decidio-api.onrender.com/api/did"
75
+ : `· your trust anchor — first seen over this signed-in connection; cross-check ${API}/api/did, then verify --issuer <did> <receipt.json>`);
69
76
  const TOKEN = () => process.env.DECIDIO_API_TOKEN;
70
77
  const ENV_PATH = ".env";
71
78
  const log = (...a) => console.log(...a);
@@ -399,6 +406,8 @@ function recordKindOf(credential) {
399
406
  }
400
407
  }
401
408
  function sessionReminder() {
409
+ if (DEMO)
410
+ return;
402
411
  log(` ⚠ agent code launched from THIS terminal inherits your sign-in — run logout first (it forgets the sign-in here; the token itself stays valid until it expires); if you`);
403
412
  log(` exported DECIDIO_SESSION_TOKEN, unset it ${dim("(bash/zsh: unset DECIDIO_SESSION_TOKEN · PowerShell: Remove-Item Env:DECIDIO_SESSION_TOKEN)")}`);
404
413
  }
@@ -564,6 +573,7 @@ async function loginFlow(purpose, emailArg) {
564
573
  }
565
574
  log(` ✔ sign-in email requested for ${email} — check your inbox and spam; paste the link (or its login= code; single-use, 15 minutes)`);
566
575
  log(dim(` invite-only lab: a link arrives only for an invited address — not invited yet? request access: decidioai.com/#early-access`));
576
+ log(dim(` or see the whole story on this machine right now, no account: npx @decidio/sdk quickstart --demo`));
567
577
  const pasted = await ask(" link or code: ");
568
578
  if (!pasted) {
569
579
  log(" ✘ nothing pasted — re-run when you have the email.");
@@ -1040,7 +1050,10 @@ async function cmdReceipt(id, jsonMode = false) {
1040
1050
  }
1041
1051
  if (conf?.status)
1042
1052
  log(` Sealed credential: the authority decision (its confirmation field reads "pending" forever — immutable). Current record evidence tier: ${conf.status}${conf.confirmed ? " (confirmed)" : ""}.`);
1043
- if (resolved && Object.keys(resolved).length) {
1053
+ if (resolved && Object.keys(resolved).length && !process.stdout.isTTY) {
1054
+ log(` ${Object.keys(resolved).length} identity field(s) your access covers are shown only on a terminal (this is a pipe or a log) — use --json to receive them explicitly.`);
1055
+ }
1056
+ else if (resolved && Object.keys(resolved).length) {
1044
1057
  log(` Resolved for you (${Object.keys(resolved).length} field${Object.keys(resolved).length === 1 ? "" : "s"} your access covers — the FILE keeps the tokens, so it stays verifiable and safe to hand on):`);
1045
1058
  for (const [tok, val] of Object.entries(resolved))
1046
1059
  log(` ${tok} → ${val}`);
@@ -1051,11 +1064,12 @@ async function cmdReceipt(id, jsonMode = false) {
1051
1064
  log(``);
1052
1065
  log(`${issuer}`);
1053
1066
  log(``);
1067
+ log(` Cross-check it out of band — the same DID is published by this Decidio host at ${API}/api/did (public, no sign-in).`);
1054
1068
  log(` Then verify, pinned to it (the same DID, inline — copy from the line above, not this one,`);
1055
1069
  log(` which your terminal may wrap):`);
1056
1070
  log(` npx @decidio/sdk verify --issuer ${issuer} ${name}`);
1057
- log(` Without --issuer the verifier proves the receipt is internally consistent, but NOT who`);
1058
- log(` issued it; it says so in its output.`);
1071
+ log(` The verifier refuses to run without --issuer: a pin is what makes VALID mean "issued by your`);
1072
+ log(` workspace"; --allow-unpinned runs a structural check only, and says so.`);
1059
1073
  }
1060
1074
  else {
1061
1075
  log(` To also prove WHO issued it, pin your workspace's issuer DID (shown in your app's record verify panel):`);
@@ -1065,6 +1079,7 @@ async function cmdReceipt(id, jsonMode = false) {
1065
1079
  }
1066
1080
  function cmdVerify(argsIn) {
1067
1081
  let expectedIssuer;
1082
+ let allowUnpinned = false;
1068
1083
  const files = [];
1069
1084
  for (let i = 0; i < argsIn.length; i++) {
1070
1085
  const a = argsIn[i];
@@ -1072,6 +1087,10 @@ function cmdVerify(argsIn) {
1072
1087
  files.push(...argsIn.slice(i + 1));
1073
1088
  break;
1074
1089
  }
1090
+ if (a === "--allow-unpinned") {
1091
+ allowUnpinned = true;
1092
+ continue;
1093
+ }
1075
1094
  if (a === "--issuer") {
1076
1095
  const v = argsIn[i + 1];
1077
1096
  if (v === undefined || v === "" || v.startsWith("-")) {
@@ -1102,10 +1121,18 @@ function cmdVerify(argsIn) {
1102
1121
  }
1103
1122
  }
1104
1123
  if (!files.length) {
1105
- err("usage: npx @decidio/sdk verify [--issuer <did:key>] <receipt.json> [more.json ...]");
1124
+ err("usage: npx @decidio/sdk verify --issuer <did:key> <receipt.json> [more.json ...] (or --allow-unpinned)");
1106
1125
  err(" Verifies Authority Receipts OFFLINE — signature, content binding, and (for several files) chain order.");
1107
1126
  return 2;
1108
1127
  }
1128
+ if (expectedIssuer !== undefined && allowUnpinned) {
1129
+ err("✘ --allow-unpinned and --issuer together — an unpinned check and a pinned one are different questions; ask one.");
1130
+ return 2;
1131
+ }
1132
+ if (expectedIssuer === undefined && !allowUnpinned) {
1133
+ err(pinRequiredMessage("npx @decidio/sdk verify"));
1134
+ return 2;
1135
+ }
1109
1136
  const texts = new Map();
1110
1137
  for (const f of files) {
1111
1138
  try {
@@ -1129,6 +1156,38 @@ function valuedOpt(args, flag) {
1129
1156
  return eq ? eq.slice(flag.length + 1) : undefined;
1130
1157
  }
1131
1158
  async function cmdQuickstart(opts = {}) {
1159
+ if (!opts.demo)
1160
+ return cmdQuickstartInner(opts);
1161
+ const relayBase = SHELL_ENV.DECIDIO_DEMO_RELAY_URL === undefined ? "https://decidio-api.onrender.com" : (SHELL_ENV.DECIDIO_DEMO_RELAY_URL || null);
1162
+ const gate = await startDemoGate({ relayBase });
1163
+ enterDemo(gate.url);
1164
+ const origin = process.cwd();
1165
+ const work = mkdtempSync(joinPath(tmpdir(), "decidio-demo-"));
1166
+ process.chdir(work);
1167
+ try {
1168
+ return await cmdQuickstartInner(opts);
1169
+ }
1170
+ finally {
1171
+ process.chdir(origin);
1172
+ for (const f of readdirSync(work)) {
1173
+ if (!/^decidio-receipt-.*\.json$/.test(f))
1174
+ continue;
1175
+ let target = joinPath(origin, f);
1176
+ if (existsSync(target))
1177
+ target = joinPath(origin, f.replace(/\.json$/, `.${Date.now()}.json`));
1178
+ try {
1179
+ copyFileSync(joinPath(work, f), target);
1180
+ log(` receipt kept here: ${target.slice(origin.length + 1)} ${dim("(the demo's agent, token and files lived in a temporary folder, now removed)")}`);
1181
+ }
1182
+ catch (e) {
1183
+ err(` ⚠ could not copy the receipt into this folder (${e?.message ?? e}) — it is at ${joinPath(work, f)}`);
1184
+ }
1185
+ }
1186
+ rmSync(work, { recursive: true, force: true });
1187
+ await gate.close();
1188
+ }
1189
+ }
1190
+ async function cmdQuickstartInner(opts = {}) {
1132
1191
  const t0 = Date.now();
1133
1192
  EXPLAIN = !!opts.explain;
1134
1193
  if (opts.phone !== undefined && opts.phone !== "app" && opts.phone !== "web") {
@@ -1136,7 +1195,10 @@ async function cmdQuickstart(opts = {}) {
1136
1195
  return 2;
1137
1196
  }
1138
1197
  log(bold("Decidio quickstart") + " — gate an action, route it to a human, execute on approval, hold the proof.");
1139
- log(` Target: ${API}${process.env.DECIDIO_API_URL ? "" : dim(" (the hosted synthetic sandbox — set DECIDIO_API_URL to use your own)")}`);
1198
+ if (DEMO)
1199
+ log(` Target: LOCAL DEMO — an in-process stand-in for the Decidio API on this machine (${API}); nothing hosted, nothing kept`);
1200
+ else
1201
+ log(` Target: ${API}${process.env.DECIDIO_API_URL ? "" : dim(" (the hosted synthetic sandbox — set DECIDIO_API_URL to use your own)")}`);
1140
1202
  hostAnnounced = true;
1141
1203
  if (EXPLAIN) {
1142
1204
  log(" This tour will: register one synthetic agent · mint a 7-DAY floor-limited token ·");
@@ -1146,19 +1208,29 @@ async function cmdQuickstart(opts = {}) {
1146
1208
  log(" · create one synthetic decision and its sealed record (records are permanent by design) · wait up to");
1147
1209
  log(" 5 minutes for your y (approve) or n (reject) at the human step. Cleanup offer at the end.");
1148
1210
  }
1211
+ else if (DEMO) {
1212
+ log(dim(" Creates here: the receipt only (decidio-receipt-<id>.json). The demo's agent, token and working files live in a temporary"));
1213
+ log(dim(" folder that is removed at the end; the web/phone door is a hosted demo page — nothing signs in, no record exists anywhere else."));
1214
+ }
1149
1215
  else {
1150
1216
  log(dim(" Creates here: a synthetic agent + 7-day token (.env), .decidio-token-journal (revocation ids, no secrets), .gitignore entries incl."));
1151
1217
  log(dim(" .decidio-pending/ (the durable store), the receipt as decidio-receipt-<id>.json, one sealed record (permanent). --explain for the long form."));
1152
1218
  }
1153
1219
  log("");
1154
- const session = await acquireSession("The quickstart");
1220
+ const session = DEMO ? "demo-session" : await acquireSession("The quickstart");
1155
1221
  if (!session)
1156
1222
  return 1;
1157
1223
  const prior = readEnvFile();
1158
1224
  const envFile = ["DECIDIO_AGENT_KEY", "DECIDIO_AGENT_ID", "DECIDIO_AGENT_DID", "DECIDIO_API_TOKEN"].some((k) => (prior[k] ?? "").trim()) ? ".env.quickstart" : ENV_PATH;
1159
1225
  if (!assertNotTracked(envFile))
1160
1226
  return 1;
1161
- ensureGitignore();
1227
+ const reach = await api("/api/health", {}, "", true);
1228
+ if (!(reach.status >= 200 && reach.status < 300)) {
1229
+ err(`✘ cannot reach ${API}${reach.status ? ` (HTTP ${reach.status})` : " (no response)"} — check the network (or DECIDIO_API_URL) and re-run. Nothing was written.`);
1230
+ return 1;
1231
+ }
1232
+ if (!DEMO)
1233
+ ensureGitignore();
1162
1234
  let agentId = `quickstart-${randomBytes(5).toString("hex")}`;
1163
1235
  if (EXPLAIN)
1164
1236
  log(` Registering agent "${agentId}"…`);
@@ -1282,7 +1354,7 @@ async function cmdQuickstart(opts = {}) {
1282
1354
  block("①", "Register a synthetic agent", [
1283
1355
  `${bold("agent")} ${agentId}`,
1284
1356
  `${bold("key")} generated locally · only the public half was registered`,
1285
- `${bold("token")} 7-day, floor-limited${mint.body.expiresAt ? ` · expires ${String(mint.body.expiresAt).slice(0, 10)}` : ""} · written to ${envFile}`,
1357
+ `${bold("token")} ${DEMO ? "demo token (one hour, this machine only)" : "7-day, floor-limited"}${mint.body.expiresAt ? ` · expires ${String(mint.body.expiresAt).slice(0, 10)}` : ""} · written to ${envFile}`,
1286
1358
  ]);
1287
1359
  if (EXPLAIN) {
1288
1360
  log(` The tour runs exactly this, the BLOCKING primitive (your real agent uses guard.protect instead:`);
@@ -1342,10 +1414,12 @@ async function cmdQuickstart(opts = {}) {
1342
1414
  const plainLink = `${APP_URL}/queue?focus=${encodeURIComponent(decisionId)}`;
1343
1415
  const rows = [
1344
1416
  `${bold("here")} Type y to approve or n to reject, then Enter ${dim("(waits up to 5 minutes)")}`,
1345
- `${bold("web app")} ${link(doorLink ?? plainLink)} ${dim(doorLink ? "(one-time sign-in · 5 minutes)" : "(sign in there if it asks)")}`,
1417
+ DEMO
1418
+ ? (doorLink ? `${bold("web page")} ${link(doorLink)} ${dim("(the demo decision page · nothing signs in)")}` : `${bold("web page")} ${dim("the web and phone doors need the internet — the demo relay was unreachable, so this run is terminal-only")}`)
1419
+ : `${bold("web app")} ${link(doorLink ?? plainLink)} ${dim(doorLink ? "(one-time sign-in · 5 minutes)" : "(sign in there if it asks)")}`,
1346
1420
  ];
1347
1421
  if (qr) {
1348
- rows.push(`${bold("your phone")} scan — the same one-time sign-in${opts.phone === "web" ? ", in the phone browser (--phone web)" : ": the Decidio app if it is installed, the browser if not"}`);
1422
+ rows.push(DEMO ? `${bold("your phone")} scan — the same demo decision page, in your phone's browser` : `${bold("your phone")} scan — the same one-time sign-in${opts.phone === "web" ? ", in the phone browser (--phone web)" : ": the Decidio app if it is installed, the browser if not"}`);
1349
1423
  for (const l of qr.split("\n"))
1350
1424
  if (l.trim().length)
1351
1425
  rows.push(" " + l);
@@ -1469,17 +1543,17 @@ async function cmdQuickstart(opts = {}) {
1469
1543
  return failTour(1);
1470
1544
  const issuer = rec.body?.issuer;
1471
1545
  const v = verifyReceipt(rec.body.credential, issuer);
1472
- const kindLine = `${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? ", PINNED to your workspace issuer" : ""}) · record kind: ${recordKindOf(rec.body.credential) ?? "?"}`;
1546
+ const kindLine = `${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? ", " + pinWords() : ""}) · record kind: ${recordKindOf(rec.body.credential) ?? "?"}`;
1473
1547
  if (!v.valid || !issuer) {
1474
1548
  log(` ${kindLine}`);
1475
1549
  err(` ✘ the denial's receipt did not verify PINNED (${!issuer ? "no issuer DID from the server" : v.reason ?? "no reason given"}) — the tour did not complete.`);
1476
1550
  return failTour(1);
1477
1551
  }
1478
1552
  block("④", "The record", [
1479
- `✔ VALID — ${fname} ${dim("(signature + content binding, offline, PINNED to your workspace issuer)")}`,
1553
+ `✔ VALID — ${fname} ${dim(`(signature + content binding, offline, ${pinWords()})`)}`,
1480
1554
  `${bold("decision")} payInvoice $86,000 · REJECTED · routed to a human by policy (no auto-approve rule for this agent)`,
1481
1555
  `${bold("record")} record kind: ${recordKindOf(rec.body.credential) ?? "?"} ${dim("— the denial is sealed too")}`,
1482
- `${bold("issuer")} ${issuer} ${dim("· your trust anchor: verify --issuer <did> <receipt.json>")}`,
1556
+ `${bold("issuer")} ${issuer} ${dim(issuerNote())}`,
1483
1557
  ], "bad");
1484
1558
  denialVerified = true;
1485
1559
  }
@@ -1507,7 +1581,8 @@ async function cmdQuickstart(opts = {}) {
1507
1581
  }
1508
1582
  log("");
1509
1583
  sessionReminder();
1510
- log(`${bold("Where next")} your queue ${link(APP_URL)} · README "Production mode: durable async resume" ${dim("· built by OmniTwin — decidioai.com/#early-access")}`);
1584
+ if (!DEMO)
1585
+ log(`${bold("Where next")} your queue ${link(APP_URL)} · README "Production mode: durable async resume" ${dim("· built by OmniTwin — decidioai.com/#early-access")}`);
1511
1586
  }
1512
1587
  else {
1513
1588
  const ap = decidedOutside ? { status: 200, body: { alreadyResolved: true } } : await api(`/api/decisions/${encodeURIComponent(decisionId)}/approve`, { method: "POST", body: JSON.stringify({ reason: opts.autoApproveDemo && !interactive ? "quickstart --auto-approve-demo (automated)" : "quickstart" }) }, session);
@@ -1543,7 +1618,7 @@ async function cmdQuickstart(opts = {}) {
1543
1618
  return failTour(1);
1544
1619
  const issuer = rec.body?.issuer;
1545
1620
  const v = verifyReceipt(rec.body.credential, issuer);
1546
- const kindLine = `${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? ", PINNED to your workspace issuer" : ""}) · record kind: ${recordKindOf(rec.body.credential) ?? "?"}`;
1621
+ const kindLine = `${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? ", " + pinWords() : ""}) · record kind: ${recordKindOf(rec.body.credential) ?? "?"}`;
1547
1622
  if (!v.valid || !issuer)
1548
1623
  log(` ${kindLine}`);
1549
1624
  if (!v.valid) {
@@ -1561,33 +1636,44 @@ async function cmdQuickstart(opts = {}) {
1561
1636
  const detail = await api(`/api/records/${encodeURIComponent(decisionId)}`, {}, session);
1562
1637
  const tier = detail.body?.record?.confirmation?.status;
1563
1638
  block("④", "The receipt", [
1564
- `✔ VALID — ${fname} ${dim("(signature + content binding, offline, PINNED to your workspace issuer)")}`,
1639
+ `✔ VALID — ${fname} ${dim(`(signature + content binding, offline, ${pinWords()})`)}`,
1565
1640
  `${bold("decision")} payInvoice $86,000 · APPROVED · routed to a human by policy (no auto-approve rule for this agent)`,
1566
1641
  `${bold("record")} record kind: ${recordKindOf(rec.body.credential) ?? "?"}${tier ? ` · evidence: ${tier}` : ""} ${dim("— the receipt seals the DECISION; execution evidence lives on the record")}`,
1567
- `${bold("issuer")} ${issuer} ${dim("· your trust anchor: verify --issuer <did> <receipt.json>")}`,
1642
+ `${bold("issuer")} ${issuer} ${dim(issuerNote())}`,
1568
1643
  ], "good");
1569
1644
  const secs = Math.round((Date.now() - t0) / 1000);
1570
1645
  block("✔", `Done in ${secs}s`, [
1571
1646
  `${bold("gated")} payInvoice $86,000 — ROUTED to a human as ${decisionId}`,
1572
1647
  `${bold("decided")} ${decidedOutside ? "approved in the web app (or on your phone)" : opts.autoApproveDemo ? "approved by --auto-approve-demo (automated, and labeled so on the record)" : "approved by you, here"}`,
1573
1648
  `${bold("executed")} your function ran once, AFTER approval`,
1574
- `${bold("receipt")} VALID — verified offline, pinned to your workspace issuer (④ above)`,
1649
+ `${bold("receipt")} VALID — verified offline, pinned to ${DEMO ? "this demo's own key" : "your workspace issuer"} (④ above)`,
1575
1650
  dim("gated → routed to a human → approved → executed once → a receipt you can verify offline"),
1576
1651
  ], "good");
1577
1652
  log("");
1578
- log(bold("Take it to your real agent — the .env here already works:"));
1579
- log(` import { guard } from "@decidio/sdk";`);
1580
- log(` const doIt = guard.protect(doItRaw, (a) => ({ action: "yourAction", amount: a.amount, scope: "YourScope" }));${dim(" // durable by default")}`);
1581
- log("");
1582
- if (EXPLAIN) {
1583
- log(` The README's seven-step flow is this same blocking flow in your own code; if you keep this agent, its .env`);
1584
- log(` already works here, so skip that flow's init step. Production is DURABLE mode (default) + one worker.`);
1653
+ if (DEMO) {
1654
+ log(bold("That was the mechanism — on this machine, against a stand-in. The product runs it against your workspace:"));
1655
+ log(` request access: decidioai.com/#early-access · have an invite? npx @decidio/sdk login, then npx @decidio/sdk quickstart`);
1656
+ log("");
1657
+ }
1658
+ else {
1659
+ log(bold("Take it to your real agent — the .env here already works:"));
1660
+ log(` import { guard } from "@decidio/sdk";`);
1661
+ log(` const doIt = guard.protect(doItRaw, (a) => ({ action: "yourAction", amount: a.amount, scope: "YourScope" }));${dim(" // durable by default")}`);
1662
+ log("");
1663
+ if (EXPLAIN) {
1664
+ log(` The README's seven-step flow is this same blocking flow in your own code; if you keep this agent, its .env`);
1665
+ log(` already works here, so skip that flow's init step. Production is DURABLE mode (default) + one worker.`);
1666
+ }
1585
1667
  }
1586
1668
  sessionReminder();
1587
- log(`${bold("Where next")} your queue ${link(APP_URL)} · README "Production mode: durable async resume" ${dim("· built by OmniTwin — decidioai.com/#early-access")}`);
1669
+ if (!DEMO)
1670
+ log(`${bold("Where next")} your queue ${link(APP_URL)} · README "Production mode: durable async resume" ${dim("· built by OmniTwin — decidioai.com/#early-access")}`);
1588
1671
  }
1589
1672
  let doCleanup = false;
1590
- if (interactive) {
1673
+ if (DEMO) {
1674
+ doCleanup = true;
1675
+ }
1676
+ else if (interactive) {
1591
1677
  const answer = await ask(`\nKeep this agent for development? [Enter = keep · c + Enter = clean up now]: `);
1592
1678
  doCleanup = answer.trim().toLowerCase() === "c";
1593
1679
  if (!doCleanup)
@@ -1735,7 +1821,7 @@ async function quickstartCleanup(session, envFile) {
1735
1821
  });
1736
1822
  if (kept.length === 0) {
1737
1823
  rmSync(envFile);
1738
- log(` ✔ ${envFile} deleted (the agent's local key is gone — the registered agent is now inert)`);
1824
+ log(` ✔ ${envFile} deleted (the agent's local key is gone — ${DEMO ? "the temporary folder follows" : "the registered agent is now inert"})`);
1739
1825
  }
1740
1826
  else {
1741
1827
  writeSecretFile(envFile, kept.join("\n") + "\n");
@@ -1751,9 +1837,13 @@ async function quickstartCleanup(session, envFile) {
1751
1837
  log(` npx @decidio/sdk approvals reject ${c.id} "quickstart cleanup"`);
1752
1838
  }
1753
1839
  }
1754
- log(` • What remains, by design: the sealed decision record (records are permanent — that is the product)`);
1755
- log(` and the agent's registration row (inert without key or token; server-side deregistration is on the roadmap).`);
1756
- if (existsSync(JOURNAL_PATH))
1840
+ if (DEMO)
1841
+ log(` • nothing remains: the demo's stand-in agent, token and files go with the temporary folder — only the receipt is kept`);
1842
+ else {
1843
+ log(` • What remains, by design: the sealed decision record (records are permanent — that is the product)`);
1844
+ log(` and the agent's registration row (inert without key or token; server-side deregistration is on the roadmap).`);
1845
+ }
1846
+ if (!DEMO && existsSync(JOURNAL_PATH))
1757
1847
  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.`);
1758
1848
  if (existsSync(".decidio-pending")) {
1759
1849
  log(` • Also kept: .decidio-pending/ — this agent's own execution ledger: whatever this agent parked (call arguments,`);
@@ -1761,6 +1851,8 @@ async function quickstartCleanup(session, envFile) {
1761
1851
  log(` pending and you do not want the history, remove it:`);
1762
1852
  log(` ${process.platform === "win32" ? "Remove-Item -Recurse .decidio-pending" : "rm -r .decidio-pending"}`);
1763
1853
  }
1854
+ if (DEMO)
1855
+ return 0;
1764
1856
  if (endSession())
1765
1857
  log(` ✔ signed out — the stored sign-in is removed from this computer (the token itself stays valid until it expires; npx @decidio/sdk login to sign in again).`);
1766
1858
  else if (process.env.DECIDIO_SESSION_TOKEN)
@@ -1778,7 +1870,16 @@ async function cmdTokens(sub, jti, mineOnly = false) {
1778
1870
  err("usage: npx @decidio/sdk tokens revoke <jti> (find the jti with: npx @decidio/sdk tokens list)");
1779
1871
  return 2;
1780
1872
  }
1781
- if (mineOnly && !process.env.DECIDIO_AGENT_ID) {
1873
+ const journalAgents = (() => {
1874
+ try {
1875
+ return [...new Set(journalRows().map((row) => row?.agentId).filter((a) => typeof a === "string" && a.length > 0))];
1876
+ }
1877
+ catch {
1878
+ return [];
1879
+ }
1880
+ })();
1881
+ const myAgents = process.env.DECIDIO_AGENT_ID ? [process.env.DECIDIO_AGENT_ID] : journalAgents;
1882
+ if (mineOnly && !myAgents.length) {
1782
1883
  err(`✘ --mine needs an agent identity, and there is no DECIDIO_AGENT_ID in this directory's .env or your environment.`);
1783
1884
  err(` Run this from your agent's directory (where init wrote .env), or drop --mine for the workspace-wide list.`);
1784
1885
  return 2;
@@ -1818,8 +1919,8 @@ async function cmdTokens(sub, jti, mineOnly = false) {
1818
1919
  return 1;
1819
1920
  }
1820
1921
  const all = r.body?.tokens ?? r.body ?? [];
1821
- const myAgent = process.env.DECIDIO_AGENT_ID;
1822
- const tokens = mineOnly ? all.filter((t) => t.agentId === myAgent) : all;
1922
+ const myAgent = process.env.DECIDIO_AGENT_ID || (journalAgents.length ? `${journalAgents.join(", ")} (from the token recovery journal)` : undefined);
1923
+ const tokens = mineOnly ? all.filter((t) => myAgents.includes(t.agentId)) : all;
1823
1924
  if (!Array.isArray(tokens) || !tokens.length) {
1824
1925
  log(mineOnly ? `No agent tokens for "${myAgent}"${all.length ? ` (${all.length} elsewhere in this workspace — drop --mine to see them)` : ""}.` : "No agent tokens in this workspace.");
1825
1926
  return 0;
@@ -1888,9 +1989,12 @@ THE TOUR
1888
1989
  automated; ends with a keep-or-cleanup choice; --explain prints
1889
1990
  the long narration of each step; --phone web makes the QR open
1890
1991
  the phone web rendering even when the Decidio app is installed)
1891
- npx @decidio/sdk quickstart cleanup revoke the tour's token + delete its local credentials
1992
+ npx @decidio/sdk quickstart cleanup revoke the tour's token + delete its local credentials (the inert
1993
+ registration row and the permanent sealed record stay)
1892
1994
  (and end the stored sign-in)
1893
1995
  npx @decidio/sdk quickstart --auto-approve-demo headless tour; cleans up after itself
1996
+ npx @decidio/sdk quickstart --demo the same tour against a LOCAL stand-in of the API — no account needed; the
1997
+ web/phone door is a hosted demo page; the receipt is the one thing kept
1894
1998
  (add --keep-agent to KEEP the tour agent + token)
1895
1999
 
1896
2000
  SIGN IN (once)
@@ -1922,7 +2026,8 @@ APPROVALS AND RECEIPTS
1922
2026
  ... approve <id> -- "-50% cut, per the board"
1923
2027
  ... approve <id> --reason "-50% cut, per the board"
1924
2028
  npx @decidio/sdk receipt <id> [--json] download a sealed decision's Authority Receipt (.json)
1925
- npx @decidio/sdk verify [--issuer <did>] <receipt.json> [more...] verify receipts OFFLINE
2029
+ npx @decidio/sdk verify --issuer <did> <receipt.json> [more...] verify receipts OFFLINE, pinned to your
2030
+ workspace's issuer (--allow-unpinned: structural check only)
1926
2031
 
1927
2032
  DEVELOPMENT
1928
2033
  npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
@@ -2056,14 +2161,14 @@ async function main(argv) {
2056
2161
  }
2057
2162
  const argsIn = argv.slice(3);
2058
2163
  const KNOWN = {
2059
- quickstart: { flags: ["--explain", "--auto-approve-demo", "--keep-agent"], valued: ["--phone"] },
2164
+ quickstart: { flags: ["--explain", "--auto-approve-demo", "--keep-agent", "--demo"], valued: ["--phone"] },
2060
2165
  login: { flags: [] },
2061
2166
  logout: { flags: [] },
2062
2167
  init: { flags: ["--unique", "--force"], valued: ["--expires-days"] },
2063
2168
  doctor: { flags: [] },
2064
2169
  approvals: { flags: ["--json", "--mine"], valued: ["--reason"] },
2065
2170
  receipt: { flags: ["--json"] },
2066
- verify: { flags: [], valued: ["--issuer"] },
2171
+ verify: { flags: ["--allow-unpinned"], valued: ["--issuer"] },
2067
2172
  tokens: { flags: ["--mine"] },
2068
2173
  dev: { flags: [], valued: ["--target", "--port"] },
2069
2174
  };
@@ -2143,6 +2248,8 @@ async function main(argv) {
2143
2248
  log(held.length
2144
2249
  ? `Nothing to clean up here: no tour credentials. The token recovery journal names ${held.length} live token(s) held by a credential file here (${held.map((j) => j.jti).join(", ")}) — not tour tokens, left alone; doctor lists them.`
2145
2250
  : "Nothing to clean up here: no .env.quickstart, .env doesn't belong to a quickstart agent, and the token recovery journal has no live token to act on.");
2251
+ if (endSession())
2252
+ log(` ✔ signed out — the stored sign-in is removed from this computer (the token itself stays valid until it expires).`);
2146
2253
  return 0;
2147
2254
  }
2148
2255
  const session = await acquireSession("Cleaning the quickstart up");
@@ -2150,7 +2257,7 @@ async function main(argv) {
2150
2257
  return 1;
2151
2258
  return quickstartCleanup(session, envFile);
2152
2259
  }
2153
- return cmdQuickstart({ autoApproveDemo: argsIn.includes("--auto-approve-demo"), keepAgent: argsIn.includes("--keep-agent"), explain: argsIn.includes("--explain"), phone: valuedOpt(argsIn, "--phone") });
2260
+ return cmdQuickstart({ demo: argsIn.includes("--demo"), autoApproveDemo: argsIn.includes("--auto-approve-demo"), keepAgent: argsIn.includes("--keep-agent"), explain: argsIn.includes("--explain"), phone: valuedOpt(argsIn, "--phone") });
2154
2261
  }
2155
2262
  case "init": {
2156
2263
  const unique = argsIn.includes("--unique");
@@ -0,0 +1,16 @@
1
+ export interface DemoGate {
2
+ /** http://127.0.0.1:<port> — point the tour here. */
3
+ url: string;
4
+ /** The did:key of the key generated for THIS run (never the hosted issuer's). */
5
+ issuerDid: string;
6
+ /** Whether the hosted relay answered (the web/phone door exists) — null until session-link was asked. */
7
+ relayReachable: () => boolean | null;
8
+ close(): Promise<void>;
9
+ }
10
+ export interface DemoGateOptions {
11
+ /** Base URL of the hosted relay; null = offline (terminal-only). Default: the hosted sandbox API. */
12
+ relayBase?: string | null;
13
+ /** Milliseconds to wait for the relay on each call. */
14
+ relayTimeoutMs?: number;
15
+ }
16
+ export declare function startDemoGate(opts?: DemoGateOptions): Promise<DemoGate>;
@@ -0,0 +1,161 @@
1
+ import { createServer } from "node:http";
2
+ import { createHash, generateKeyPairSync, randomBytes, sign as edSign } from "node:crypto";
3
+ import { jcsCanonicalize } from "@decidio/verify";
4
+ const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
5
+ function b58(buf) {
6
+ const digits = [0];
7
+ for (const byte of buf) {
8
+ let carry = byte;
9
+ for (let i = 0; i < digits.length; i++) {
10
+ carry += digits[i] << 8;
11
+ digits[i] = carry % 58;
12
+ carry = (carry / 58) | 0;
13
+ }
14
+ while (carry) {
15
+ digits.push(carry % 58);
16
+ carry = (carry / 58) | 0;
17
+ }
18
+ }
19
+ let out = "";
20
+ for (const b of buf) {
21
+ if (b === 0)
22
+ out += B58[0];
23
+ else
24
+ break;
25
+ }
26
+ return out + digits.reverse().map((d) => B58[d]).join("");
27
+ }
28
+ const DECISION_ID = "D-DEMO-1";
29
+ const D_NUMBER = "D·0001";
30
+ export async function startDemoGate(opts = {}) {
31
+ const relayBase = opts.relayBase === undefined ? "https://decidio-api.onrender.com" : opts.relayBase;
32
+ const relayTimeoutMs = opts.relayTimeoutMs ?? 2000;
33
+ const issuerKey = generateKeyPairSync("ed25519");
34
+ const issuerRaw = Buffer.from(issuerKey.publicKey.export({ format: "der", type: "spki" })).subarray(-32);
35
+ const issuerDid = "did:key:z" + b58(Buffer.concat([Buffer.from([0xed, 0x01]), issuerRaw]));
36
+ const sealedAt = new Date().toISOString();
37
+ let verdict = null;
38
+ let relayCode = null;
39
+ let relayOk = null;
40
+ const relayFetch = async (path, init = {}) => {
41
+ if (!relayBase)
42
+ return null;
43
+ const ctl = new AbortController();
44
+ const t = setTimeout(() => ctl.abort(), relayTimeoutMs);
45
+ try {
46
+ const r = await fetch(`${relayBase.replace(/\/$/, "")}${path}`, { ...init, signal: ctl.signal, headers: { "content-type": "application/json", ...(init.headers ?? {}) } });
47
+ if (r.status === 204)
48
+ return {};
49
+ if (!r.ok)
50
+ return null;
51
+ return await r.json().catch(() => ({}));
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ finally {
57
+ clearTimeout(t);
58
+ }
59
+ };
60
+ const issueReceipt = (kind) => {
61
+ const canonical = `{"accountableParty":"[PERSON_1]","action":"payInvoice","amount":"[AMOUNT_2]","recordKind":"${kind}","recordSeries":"local-demo"}`;
62
+ const contentHash = createHash("sha256").update(canonical, "utf8").digest("hex");
63
+ const context = ["https://www.w3.org/ns/credentials/v2"];
64
+ const doc = {
65
+ "@context": context,
66
+ type: ["VerifiableCredential", "DecidioAuthorityRecord"],
67
+ issuer: issuerDid,
68
+ validFrom: sealedAt,
69
+ credentialSubject: { id: `urn:decidio:decision:${DECISION_ID}`, recordId: DECISION_ID, dNumber: D_NUMBER, contentHash, prevHash: null, recordCanonical: canonical },
70
+ };
71
+ const cfg = {
72
+ "@context": context, type: "DataIntegrityProof", cryptosuite: "eddsa-jcs-2022",
73
+ created: sealedAt, verificationMethod: `${issuerDid}#${issuerDid.slice("did:key:".length)}`, proofPurpose: "assertionMethod",
74
+ };
75
+ const signingInput = Buffer.concat([
76
+ createHash("sha256").update(Buffer.from(jcsCanonicalize(cfg), "utf8")).digest(),
77
+ createHash("sha256").update(Buffer.from(jcsCanonicalize(doc), "utf8")).digest(),
78
+ ]);
79
+ return { ...doc, proof: { ...cfg, proofValue: "z" + b58(edSign(null, signingInput, issuerKey.privateKey)) } };
80
+ };
81
+ const server = createServer((req, res) => {
82
+ let raw = "";
83
+ req.on("data", (c) => (raw += c));
84
+ req.on("end", async () => {
85
+ let body;
86
+ try {
87
+ body = raw ? JSON.parse(raw) : undefined;
88
+ }
89
+ catch {
90
+ body = undefined;
91
+ }
92
+ const path = (req.url ?? "").split("?")[0];
93
+ const send = (o, status = 200) => { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(o)); };
94
+ try {
95
+ if (path === "/api/health")
96
+ return send({ ok: true, demo: true });
97
+ if (path === "/api/did")
98
+ return send({ did: issuerDid, method: "did:key", alg: "Ed25519", demo: true });
99
+ if (path === "/api/decisions/cards")
100
+ return send({ cards: [] });
101
+ if (path === "/api/agents/register")
102
+ return send({ agentId: body?.agentId, did: body?.did });
103
+ if (path === "/api/workspace/agent-token" && req.method === "POST")
104
+ return send({ token: "demo-agent-token", workspaceId: "local-demo", jti: `jti-demo-${randomBytes(4).toString("hex")}`, expiresAt: new Date(Date.now() + 3_600_000).toISOString() });
105
+ if (path === "/api/workspace/agent-token" && req.method === "GET")
106
+ return send({ tokens: [] });
107
+ if (/^\/api\/workspace\/agent-token\/[^/]+$/.test(path) && req.method === "DELETE")
108
+ return send({ ok: true, changed: true });
109
+ if (path === "/agent/authorize") {
110
+ verdict = null;
111
+ return send({ decision: "route", decisionId: DECISION_ID, receiptId: "R-DEMO-1" });
112
+ }
113
+ if (path.startsWith("/agent/status/")) {
114
+ if (!verdict && relayCode) {
115
+ const r = await relayFetch(`/demo/relay/${relayCode}`);
116
+ if (r && (r.verdict === "approve" || r.verdict === "reject"))
117
+ verdict = r.verdict;
118
+ }
119
+ return send(verdict === "reject" ? { state: "rejected", reason: "quickstart: rejected by the approver" } : { state: verdict === "approve" ? "approved" : "pending" });
120
+ }
121
+ if (/^\/api\/decisions\/[^/]+\/(approve|reject)$/.test(path)) {
122
+ const already = verdict !== null;
123
+ verdict = path.endsWith("/reject") ? "reject" : "approve";
124
+ if (relayCode && !already)
125
+ void relayFetch(`/demo/relay/${relayCode}`, { method: "PUT", body: JSON.stringify({ verdict }) });
126
+ return send({ ok: true, decisionId: DECISION_ID, sealed: true, ...(already ? { alreadyResolved: true } : {}) });
127
+ }
128
+ if (path === "/agent/confirm")
129
+ return send({ ok: true, confirmation: "application_confirmed", recordId: DECISION_ID });
130
+ if (path === "/demo/session-link") {
131
+ const r = await relayFetch("/demo/relay", { method: "POST", body: JSON.stringify({ decisionId: DECISION_ID }) });
132
+ if (r && typeof r.code === "string" && /^[0-9A-HJKMNP-TV-Z]{13}$/.test(r.code) && typeof r.link === "string" && /^https?:\/\//.test(r.link)) {
133
+ relayCode = r.code;
134
+ relayOk = true;
135
+ return send({ status: "ok", link: r.link, qr: typeof r.qr === "string" ? r.qr : null, expiresAt: r.expiresAt ?? null });
136
+ }
137
+ relayOk = false;
138
+ return send({ status: "unavailable", error: "the demo relay is unreachable — this run is terminal-only" }, 503);
139
+ }
140
+ if (/^\/api\/records\/[^/]+\/verify$/.test(path)) {
141
+ const cred = issueReceipt(verdict === "reject" ? "denial" : "approval");
142
+ return send({ dNumber: D_NUMBER, issuer: issuerDid, alg: "Ed25519", valid: true, signatureValid: true, contentBound: true, matchesRecord: true, payloadMatches: true, hash: cred.credentialSubject.contentHash, prevHash: null, credential: cred });
143
+ }
144
+ if (/^\/api\/records\/[^/]+$/.test(path))
145
+ return send({ record: { confirmation: verdict === "approve" ? { status: "application_confirmed", confirmed: true } : { status: "pending", confirmed: false } } });
146
+ return send({ error: "not part of the local demo" }, 404);
147
+ }
148
+ catch (e) {
149
+ return send({ error: String(e?.message ?? e) }, 500);
150
+ }
151
+ });
152
+ });
153
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
154
+ const port = server.address().port;
155
+ return {
156
+ url: `http://127.0.0.1:${port}`,
157
+ issuerDid,
158
+ relayReachable: () => relayOk,
159
+ close: () => new Promise((r) => server.close(() => r())),
160
+ };
161
+ }
package/dist/resume.d.ts CHANGED
@@ -470,7 +470,7 @@ export type RecoveryState =
470
470
  "parked"
471
471
  /** Claimed by an attempt that never reached the invocation boundary, so the handler PROVABLY
472
472
  * did not run — but the row is not parked either: `list()` excludes it and every ordinary
473
- * signal answers `unknown`, so nothing will pick it up on its own. It is stalled until an
473
+ * signal answers `lost_race`, so nothing will pick it up on its own. It is stalled until an
474
474
  * operator releases it. Reporting this as "parked" (as 0.2.0's first draft did) told a UI an
475
475
  * approved action was still queued to resume when in fact it had been silently dropped. */
476
476
  | "claimed_stalled"
@@ -582,8 +582,9 @@ export interface ResumeConfig {
582
582
  /** The callback URL Decidio should POST the verdict to (webhook transport). Sent
583
583
  * on /agent/authorize so the decision carries it; omit when using the poll worker. */
584
584
  resumeUrl?: string;
585
- /** Fired after every terminal resolution (executed / indeterminate / rejected / blocked /
586
- * no_handler), via either transport. Use it to log, close a listener, or exit a CLI.
585
+ /** Fired for every outcome except `unknown` (executed / indeterminate / rejected / blocked /
586
+ * no_handler / already_resolved / lost_race), via either transport. Use it to log, close a
587
+ * listener, or exit a CLI.
587
588
  * `indeterminate` is new in 0.2.0 and DOES fire here: a handler that ran and then threw is a
588
589
  * terminal outcome for automation — nothing will retry it — so a listener that only handled
589
590
  * the old four would silently miss the one case that needs a human. */
@@ -601,8 +602,9 @@ export interface ResumeController extends ResumeConfig {
601
602
  /** Park a routed action WITH its integrity binding (prefer this over store.put — the
602
603
  * binding is what lets resolve() refuse a tampered payload). */
603
604
  park(p: Omit<PendingAction, "createdAt" | "argsSig">): Promise<void>;
604
- /** Drive a parked decision to completion once a verdict is known. Idempotent:
605
- * a second call after success finds nothing parked → "unknown" (no double-write). */
605
+ /** Drive a parked decision to completion once a verdict is known. Idempotent: a second call
606
+ * after success answers `already_resolved` (no double-write); an id this SDK never parked
607
+ * answers `unknown`; a call that loses the pre-invocation claim answers `lost_race`. */
606
608
  resolve(input: {
607
609
  decisionId: string;
608
610
  verdict: "approved" | "rejected" | "blocked";
package/openapi.yaml CHANGED
@@ -5,7 +5,7 @@
5
5
  openapi: 3.1.0
6
6
  info:
7
7
  title: Decidio Agent Gate
8
- version: 0.4.4 # tracks the SDK release - checked by packages/conformance/manifests.mjs
8
+ version: 0.6.0 # tracks the SDK release - checked by packages/conformance/manifests.mjs
9
9
  description: >
10
10
  Authorize an AI-agent action (proceed | route | block), confirm the captured outcome, and
11
11
  receive the signed resume webhook when a routed action is decided. Every outcome seals a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decidio/sdk",
3
- "version": "0.4.4",
3
+ "version": "0.6.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",
@@ -84,7 +84,7 @@
84
84
  "prepublishOnly": "npm run typecheck && npm run build && node ../publish-boundary/check.mjs --only @decidio/sdk"
85
85
  },
86
86
  "dependencies": {
87
- "@decidio/verify": "0.4.4"
87
+ "@decidio/verify": "0.6.0"
88
88
  },
89
89
  "author": "OmniTwin Technologies Inc.",
90
90
  "bugs": {