@decidio/sdk 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -4,83 +4,108 @@ One line to put a human-or-policy approval gate in front of any AI-agent action
4
4
 
5
5
  ## Quickstart — seven steps to your first sealed receipt
6
6
 
7
- Work through these in order; a competent engineer lands the whole loop in under 30 minutes.
7
+ Under 30 minutes, no help needed. You'll need Node 20.6+, a terminal, and a Decidio workspace
8
+ (request one at [decidioai.com](https://decidioai.com/#early-access) — the hosted sandbox is
9
+ synthetic data, safe to experiment in). Shell commands below are Bash; on PowerShell, `export
10
+ NAME=value` becomes `$env:NAME="value"`.
8
11
 
9
12
  **1. Install**
10
13
 
11
14
  ```bash
15
+ mkdir decidio-quickstart && cd decidio-quickstart
12
16
  npm install @decidio/sdk
13
17
  ```
14
18
 
15
- **2. Register your agent**
19
+ **2. Point at your Decidio and register your agent**
16
20
 
17
21
  ```bash
22
+ export DECIDIO_API_URL=https://decidio-api.onrender.com # the hosted sandbox
18
23
  npx @decidio/sdk init my-agent
19
24
  ```
20
25
 
21
- `init` signs you into your Decidio workspace (email + password, or paste the sign-in link from
22
- your workspace email), generates the agent's Ed25519 keypair **locally** (only the public
23
- `did:key` is sent), registers the agent, mints its **floor-limited API token** (it can call the
24
- agent gate and nothing else — your workspace session never touches the agent's disk), and writes
25
- `.env`. No workspace yet? Request access at [decidioai.com](https://decidioai.com/#early-access).
26
+ `init` signs you into your workspace (email + password, or paste the sign-in link from your
27
+ workspace email), generates the agent's Ed25519 keypair **locally** (only the public `did:key`
28
+ is sent), registers the agent, mints its **floor-limited API token** (it can request approvals
29
+ and nothing else — your workspace session never touches the agent's disk), and writes `.env`.
30
+ Every later command reads `.env` from this directory automatically.
26
31
 
27
- **3. Protect one function** — rename the raw function, wrap it under the original name:
32
+ **3. Protect one function** — save this as `quickstart.mjs`:
28
33
 
29
- ```ts
34
+ ```js
30
35
  import { guard } from "@decidio/sdk";
31
36
 
32
- const createOpportunityRaw = (o) => sf.create("Opportunity", o);
37
+ // Your real action a Salesforce write, a payment, a DB change. Here: a stand-in.
38
+ const payInvoiceRaw = async (invoice) => ({ paid: true, id: invoice.id });
33
39
 
34
- const createOpportunity = guard.protect(
35
- createOpportunityRaw,
36
- (o) => ({ action: "createOpportunity", amount: o.Amount, scope: "Opportunity" }),
37
- );
40
+ // One line: wrap it. `describe` tells Decidio what is being asked.
41
+ const payInvoice = guard.protect(
42
+ payInvoiceRaw,
43
+ (invoice) => ({ action: "payInvoice", amount: invoice.amount, scope: "Invoice" }),
44
+ { mode: "blocking" }, // blocking = watch the whole loop live in one terminal sitting.
45
+ ); // Production agents use durable mode instead — see below.
46
+
47
+ const result = await payInvoice({ id: "INV-2026-001", amount: 86_000 });
48
+ console.log("executed after approval:", result);
38
49
  ```
39
50
 
40
- **4. Trigger a request:**
51
+ **4. Trigger it:**
41
52
 
42
- ```ts
43
- await createOpportunity({ Amount: 86_000 });
53
+ ```bash
54
+ node --env-file=.env quickstart.mjs
44
55
  ```
45
56
 
46
57
  A brand-new agent matches **no** auto-approve rule, so Decidio's deny-by-default **routes every
47
58
  request to a human** — that is the point: nothing executes without either a named policy rule or
48
- a person. In durable mode the call parks its arguments agent-side and throws
49
- `DecidioSuspendedError`; the process may exit. (To see a policy `proceed`, register the agent in
50
- the sandbox's pre-policied lane — `DECIDIO_SOURCE_SYSTEM=salesforce npx @decidio/sdk init` which
51
- auto-approves small amounts under a named, versioned rule when the request carries the agent's
52
- signed identity proof.)
59
+ a person. The script prints the decision id and waits. (To later see a policy `proceed`,
60
+ register a **new** agent in the sandbox's pre-policied lane an existing agent's lane can't be
61
+ changed: `DECIDIO_SOURCE_SYSTEM=salesforce npx @decidio/sdk init my-sf-agent`. That lane's
62
+ named rule auto-approves amounts under $1M when the request carries the agent's signed
63
+ identity proof, so even this $86k example would proceed there.)
53
64
 
54
- **5. Approve it** — the request is now in your Decidio queue (web or phone), or from the terminal:
65
+ **5. Approve it** — in a second terminal (same directory):
55
66
 
56
67
  ```bash
57
- npx @decidio/sdk approvals # list pending
68
+ npx @decidio/sdk approvals # see it pending
58
69
  npx @decidio/sdk approvals approve <decisionId>
59
70
  ```
60
71
 
61
- On approval Decidio signs a verdict back to your resume route (or `resume.worker()` picks it
62
- up), and **your own function** executesDecidio never holds your credentials.
72
+ The moment you approve, the first terminal wakes: **your own function executes** Decidio never
73
+ holds your credentials or runs your code and the SDK reports the captured result back, which
74
+ becomes the record's execution evidence.
63
75
 
64
- **6. Inspect the receipt** open the sealed record in the Decidio app → **Verify** →
65
- **Download receipt (.json)**, then verify it offline with the open, zero-dependency verifier:
76
+ **6. Download the receipt and verify it offline:**
66
77
 
67
78
  ```bash
68
- npx @decidio/verify receipt.json
79
+ npx @decidio/sdk receipt <decisionId>
80
+ npx @decidio/verify decidio-receipt-<decisionId>.json
69
81
  ```
70
82
 
71
- The signature, content binding, and chain position verify with no Decidio account and no
72
- network — the evidence is yours, not ours.
83
+ The receipt is a W3C Verifiable Credential: the signature and content binding verify with no
84
+ Decidio account and no network (after the one-time `npx` package download) — the evidence is
85
+ yours, not ours. To also prove *who* issued it, pin your workspace's issuer:
86
+ `npx @decidio/verify --issuer <did:key:...> receipt.json` (the DID is shown in your app's
87
+ record verify panel). One receipt proves itself; chain order across receipts is verified when
88
+ you pass several files at once.
89
+
90
+ What the credential contains, honestly: the sealed **authority decision** — who decided, what
91
+ was authorized, when, under which policy. It seals at decision time and is immutable, so
92
+ execution evidence that arrives *after* the seal (your function's captured result) lives on the
93
+ **record**, not inside the credential bytes: your Decidio record page shows the evidence tier
94
+ (`application_confirmed` once the wrapper's signed report lands). Immutable seal + accruing
95
+ evidence is the design, not an omission — corrections and later facts supersede, they never
96
+ rewrite.
73
97
 
74
- **7. Prove a replay is rejected** — the approval you just used is single-use. Try to use it again:
98
+ **7. Prove a replay is rejected:**
75
99
 
76
100
  ```bash
77
- npx @decidio/sdk approvals approve <the same decisionId> # → refused: already sealed
78
- curl -X POST http://localhost:4100/decidio/resume \
79
- -H 'content-type: application/json' -d '{"decisionId":"<id>","verdict":"approved"}'
80
- # → refused: the resume handler fails closed without a verified x-decidio-signature,
81
- # and the parked entry was deleted on first completion — a duplicate signal can't double-write.
101
+ npx @decidio/sdk approvals approve <the same decisionId>
82
102
  ```
83
103
 
104
+ Decisions are single-use: the CLI reports "already resolved — nothing changed" and exits
105
+ non-zero. The server answers replays idempotently — a second approve can never re-execute the
106
+ action. (The durable resume path has the same property: parked actions are claimed exactly
107
+ once, and its webhook handler fails closed without a verified signature.)
108
+
84
109
  That's the whole contract: gate → human decision → your execution → owned, verifiable evidence.
85
110
 
86
111
  ## What happens on a call
@@ -90,70 +115,84 @@ That's the whole contract: gate → human decision → your execution → owned,
90
115
  - **proceed** — your function runs immediately (auto-approved under a named, versioned rule).
91
116
  - **route** — the action suspends (durable) or waits (blocking) for a human decision in Decidio's queue, then runs your function (or throws `DecidioRejectedError`).
92
117
  - **block** — the wrapper throws `DecidioBlockedError`; your function never runs.
93
- 3. After your function runs, the wrapper reports the **real response it captured** back to Decidio, which seals an `application_confirmed` receipt (Decidio minimizes + tokenizes before sealing — the immutable record never stores raw payloads).
118
+ 3. After your function runs, the wrapper reports the **real response it captured** back to Decidio, which records it as `application_confirmed` execution evidence (Decidio minimizes + tokenizes before storing — the immutable record never stores raw payloads).
94
119
 
95
120
  Your agent executes its own action. **Decidio holds no write credentials for your system** — it authorizes the decision, records it, and (optionally) independently verifies it.
96
121
 
97
- ## Real approvals take time — durable async resume
98
-
99
- A real human approval takes minutes to days. Nobody watches a 60-second polling window, so blocking the call is only honest for short, supervised approvals. For real ones, add a **resume controller** and the *same wrap* becomes durable: a routed action **suspends** instead of blocking — it parks its call args in an **agent-side store** (Decidio stores none of your downstream payload) and throws `DecidioSuspendedError`. The requesting process can exit. When a human approves, **Decidio POSTs a signed verdict** to your agent's resume URL; the controller re-runs *your own function* and confirms the result. Decidio signals — it never executes.
100
-
101
- ```ts
102
- import { withApproval, createDecidioResume, FilePendingStore } from "@decidio/sdk";
103
- import { createServer } from "node:http";
122
+ ## Production mode: durable async resume
104
123
 
105
- const resume = createDecidioResume({
106
- apiUrl: process.env.DECIDIO_API_URL!,
107
- apiToken: process.env.DECIDIO_API_TOKEN,
108
- webhookSecret: process.env.DECIDIO_WEBHOOK_SECRET, // Decidio signs callbacks (HMAC); verified before re-exec
109
- resumeUrl: "https://my-agent.internal/decidio/resume", // where Decidio calls back on approval
110
- store: new FilePendingStore(".decidio-pending"), // swap for Redis/Postgres/your queue
111
- });
124
+ Real approvals take minutes to days, and nobody keeps a terminal open for them. Omit
125
+ `mode: "blocking"` (durable is the default) and a routed action **suspends** instead of
126
+ waiting: it parks its call arguments agent-side (`.decidio-pending/` by default — Decidio
127
+ stores none of your downstream payload) and throws `DecidioSuspendedError`. The process may
128
+ exit. Two ways the parked action resumes when a human decides:
112
129
 
113
- const createOpportunity = withApproval(createOpportunityRaw, describe, { ...guard, resume });
130
+ **The worker (self-serve start here).** One line in any long-lived process:
114
131
 
115
- // mount the resume listener (production webhook transport):
116
- createServer((req, res) => resume.webhookHandler()(req, res)).listen(4100);
132
+ ```js
133
+ import { guard } from "@decidio/sdk";
134
+ guard.worker(); // polls Decidio outward, re-executes parked actions on approval, exactly once
117
135
  ```
118
136
 
119
- No inbound URL (CLI / batch worker)? Use `resume.worker()` instead — a long-lived watcher that polls `/agent/status` for parked decisions and re-executes on approval. That polling is a durable *worker*, not the request path being kept alive. This is the same shape as HumanLayer's approval webhooks, Temporal Signals, Inngest `waitForEvent`, and Decidio's own A2A `input-required` resume. Re-execution is **single-use** (the parked entry is deleted on completion, so a duplicate signal can't double-write), and the webhook handler **fails closed** without a verified signature.
137
+ No inbound URL, no shared secrets, survives restarts. This is the transport to use against the
138
+ hosted sandbox.
120
139
 
121
- ### Already on a durable engine? Use its native wait (Inngest adapter)
140
+ **The signed webhook (operator deployments).** Decidio POSTs a signed verdict to your agent's
141
+ `resumeUrl`; the handler verifies the HMAC signature fail-closed, then re-runs your function:
122
142
 
123
- If your agent runs on Inngest/Temporal/LangGraph, the engine *is* the durable store — you don't need the file store at all. `@decidio/sdk/inngest` maps the gate onto Inngest's native `step.waitForEvent`, so a routed action durably suspends on the engine (zero compute, the worker can die for days) and resumes from its checkpoint:
143
+ ```js
144
+ // Express: app.post("/decidio/resume", guard.resumeHandler())
145
+ // Next.js: export const POST = (req) => guard.resumeFetchHandler()(req)
146
+ ```
124
147
 
125
- ```ts
126
- import { decidioGateStep, createInngestResumeBridge } from "@decidio/sdk/inngest";
148
+ Honest requirement: webhook signing uses a **shared secret configured on both sides** — your
149
+ `DECIDIO_WEBHOOK_SECRET` must equal the Decidio server's, and self-hosted production deployments
150
+ also allow-list resume hosts (default-deny). That's operator territory: if you run your own
151
+ Decidio (or we run a pilot with you), the webhook is set up then. Against the hosted sandbox,
152
+ use the worker — a webhook secret your server never learned would (correctly) fail closed.
127
153
 
128
- const fn = inngest.createFunction({ id: "create-opp", triggers: [{ event: "salesops/opportunity.requested" }] },
129
- async ({ event, step }) => decidioGateStep({
130
- step, config: { ...guard, resumeUrl: "https://my-agent/decidio/resume" },
131
- ctx: { action: "createOpportunity", amount: event.data.Amount, scope: "Opportunity" },
132
- run: () => sf.create("Opportunity", event.data), // YOUR write, on resume
133
- }));
154
+ Either way, re-execution is **single-use** the parked entry is claimed on completion, so a
155
+ duplicate signal can't double-write.
134
156
 
135
- // Decidio keeps its ONE signed-webhook contract; this bridge translates it into the
136
- // Inngest event that wakes the suspended run:
137
- const bridge = createInngestResumeBridge({ inngest, webhookSecret: process.env.DECIDIO_WEBHOOK_SECRET });
138
- ```
157
+ ### Already on a durable engine? Use its native wait
139
158
 
140
- `inngest` is an **optional peer dependency** the core SDK never imports it, so plain users pay nothing. See `example/inngest-agent.ts` in the repo for a runnable end-to-end (Inngest dev server + the bridge).
159
+ If your agent runs on Inngest/Temporal/LangGraph, the engine *is* the durable store.
160
+ `@decidio/sdk/inngest` maps the gate onto `step.waitForEvent`; `@decidio/sdk/langgraph` is a
161
+ drop-in via `interrupt()`; `@decidio/sdk/temporal` uses `condition` + signal;
162
+ `@decidio/sdk/openai` gates RunState approvals. All optional peer dependencies — the core SDK
163
+ imports none of them. See each adapter's header for its wiring, and `example/` in the repo for
164
+ runnable end-to-ends.
141
165
 
142
166
  ## One surface, every runtime — and Python too
143
167
 
144
- - **`guard.protect(fn, describe, { adapter })`** is the same call everywhere. Engine adapters map it onto each engine's native durable wait: `@decidio/sdk/langgraph` (drop-in via `interrupt()`), `@decidio/sdk/inngest` (`waitForEvent`), `@decidio/sdk/temporal` (`condition` + signal), `@decidio/sdk/openai` (RunState approvals). No engine? The core webhook/worker resume is the universal path.
168
+ - **`guard.protect(fn, describe, { adapter })`** is the same call everywhere; the engine adapters above translate it onto each engine's native durable wait. No engine? The worker/webhook resume is the universal path.
145
169
  - **Python twin:** `pip install decidio` exposes the identical `guard.protect` (plus the `@guard.approve` decorator, adapters, and the offline verifier); a conformance suite asserts both languages emit an identical request + receipt.
146
170
 
147
171
  ## CLI
148
172
 
149
- `npx @decidio/sdk <cmd>` — `init [agentId]` (sign in, register, mint the agent token, write `.env`) · `doctor` (config + connectivity + token-scope check) · `approvals [approve|reject <id> [reason]]` · `dev [--target URL]` (signal-only relay for a local agent). Commands that administer your workspace (`init`, `approvals`) use a **session** sign-in held in memory for the run; the only credential written to disk is the agent's floor-limited token.
173
+ `npx @decidio/sdk <cmd>`:
174
+
175
+ - `init [agentId]` — sign in, register, mint the agent token, write `.env` (+ `.gitignore` entries)
176
+ - `doctor` — config, connectivity, and what your current token can do
177
+ - `approvals` / `approvals approve|reject <id> [reason]` — the human side, from your terminal
178
+ - `receipt <id>` — download a sealed decision's Authority Receipt to a `.json` file
179
+ - `dev [--target URL]` — local relay for the resume webhook (operator setups)
180
+
181
+ Commands that administer your workspace (`init`, `approvals`, `receipt`) use a **session**
182
+ sign-in held in memory for that run. What lands on disk in `.env`: the agent's floor-limited
183
+ API token, its locally-generated private signing key, and a webhook secret (used only in
184
+ operator deployments) — `init` adds `.env` to `.gitignore` for you; move secrets to a secret
185
+ manager for production.
150
186
 
151
187
  ## Errors
152
188
 
153
189
  - `DecidioBlockedError` — policy blocked the action (incl. the universal deny-by-default backstop).
154
190
  - `DecidioRejectedError` — a human rejected the routed action.
155
- - `DecidioSuspendedError` — (resume mode) the action was routed and is now parked for async approval; the process may exit and resume later. Not a failure.
156
- - `DecidioTimeoutError` — (blocking mode only) no human decided within `pollTimeoutMs` (default 10 min).
191
+ - `DecidioSuspendedError` — (durable mode) the action was routed and is parked for async approval; the process may exit and resume later. Not a failure.
192
+ - `DecidioTimeoutError` — (blocking mode only) no human decided within `pollTimeoutMs` (default 10 min); the decision stays open in Decidio, nothing executed locally.
193
+
194
+ Every error carries the `decisionId`, so a suspended or timed-out action is always findable in
195
+ your queue.
157
196
 
158
197
  ## Notes
159
198
 
package/dist/cli.js CHANGED
@@ -3,26 +3,81 @@
3
3
  // init [agentId] sign in, register the agent, mint its API token, write .env
4
4
  // doctor check config + connectivity + durability readiness
5
5
  // approvals list pending agent approvals; `approvals approve|reject <id> [reason]`
6
+ // receipt <id> download a sealed decision's Authority Receipt to a .json file
6
7
  // dev [--target U] signal-only relay: forwards Decidio's webhook to your local agent URL
7
8
  //
8
9
  // TWO TOKEN KINDS, deliberately (self-serve design 2026-08-28): commands that ADMINISTER a
9
- // workspace (init's register+mint, approvals) need the owner's SESSION token — acquired
10
+ // workspace (init's register+mint, approvals, receipt) need the owner's SESSION token — acquired
10
11
  // interactively per run or from DECIDIO_SESSION_TOKEN, held in memory, NEVER written to disk.
11
12
  // What lands in .env as DECIDIO_API_TOKEN is the floor-limited AGENT token the runtime SDK
12
13
  // needs (it can call /agent/* and nothing else). A session-capable secret must never live in
13
14
  // an agent host's .env — that was the pre-publish design's hole.
14
- import { readFileSync, writeFileSync, existsSync } from "node:fs";
15
+ //
16
+ // MESSAGE DISCIPLINE (founder bar 2026-08-29, from the Codex acceptance run): every failure
17
+ // tells the developer WHAT happened, WHY, and the EXACT next command. Every success says what
18
+ // changed, where, and what to do next. No stack traces for expected failures; no green ✔ for a
19
+ // no-op (the replay-approve bug: the server's idempotent alreadyResolved was reported as a
20
+ // fresh success — an authority CLI may never dress a no-op as an action).
21
+ import { readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs";
15
22
  import { randomBytes } from "node:crypto";
16
23
  import { createServer } from "node:http";
17
24
  import { createInterface } from "node:readline";
25
+ import { createRequire } from "node:module";
18
26
  import { generateKeypair } from "./signing.js";
27
+ const VERSION = (() => {
28
+ try {
29
+ return createRequire(import.meta.url)("../package.json").version;
30
+ }
31
+ catch {
32
+ return "unknown";
33
+ }
34
+ })();
35
+ // ---- environment: load the CWD .env FIRST (Codex finding: init writes .env, but the next
36
+ // command ignored it and pointed at localhost). Process env always wins — .env only fills gaps.
37
+ function readEnvFile() {
38
+ const out = {};
39
+ if (existsSync(".env"))
40
+ for (const line of readFileSync(".env", "utf8").split(/\r?\n/)) {
41
+ const m = line.match(/^\s*([A-Z_]+)=(.*)$/);
42
+ if (m)
43
+ out[m[1]] = m[2];
44
+ }
45
+ return out;
46
+ }
47
+ for (const [k, v] of Object.entries(readEnvFile())) {
48
+ // NEVER autoload the session token from disk: the whole design splits session (memory-only)
49
+ // from the floor-limited agent token (.env). Silently honoring a parked session token would
50
+ // reward exactly the credential-at-rest the split exists to prevent (guard catch 2026-08-29).
51
+ if (k === "DECIDIO_SESSION_TOKEN") {
52
+ console.log("⚠ ignoring DECIDIO_SESSION_TOKEN found in .env — session tokens belong in your shell or CI secret store, never on disk. Remove that line; export it in the shell instead.");
53
+ continue;
54
+ }
55
+ if (process.env[k] === undefined)
56
+ process.env[k] = v;
57
+ }
19
58
  const API = (process.env.DECIDIO_API_URL ?? "http://localhost:4000").replace(/\/$/, "");
20
59
  const TOKEN = process.env.DECIDIO_API_TOKEN;
21
60
  const ENV_PATH = ".env";
22
61
  const log = (...a) => console.log(...a);
62
+ /** Fetch wrapper that turns transport failures into guidance instead of a stack trace. */
23
63
  async function api(path, init = {}, bearer) {
24
64
  const auth = bearer ?? TOKEN;
25
- const res = await fetch(API + path, { ...init, headers: { "content-type": "application/json", ...(auth ? { authorization: `Bearer ${auth}` } : {}), ...(init.headers ?? {}) } });
65
+ let res;
66
+ try {
67
+ res = await fetch(API + path, { ...init, headers: { "content-type": "application/json", ...(auth ? { authorization: `Bearer ${auth}` } : {}), ...(init.headers ?? {}) } });
68
+ }
69
+ catch (e) {
70
+ log(`✘ could not reach Decidio at ${API} (${e?.cause?.code ?? e?.message ?? "network error"}).`);
71
+ if (API.includes("localhost")) {
72
+ log(` You're pointed at localhost — for the hosted sandbox run:`);
73
+ log(` export DECIDIO_API_URL=https://decidio-api.onrender.com`);
74
+ log(` (or put that line in .env), then re-run this command.`);
75
+ }
76
+ else {
77
+ log(` Check your network, then re-run. If the URL is wrong, set DECIDIO_API_URL and retry.`);
78
+ }
79
+ process.exit(1);
80
+ }
26
81
  const text = await res.text();
27
82
  let body = null;
28
83
  try {
@@ -33,22 +88,23 @@ async function api(path, init = {}, bearer) {
33
88
  }
34
89
  return { status: res.status, body };
35
90
  }
36
- function readEnv() {
37
- const out = {};
38
- if (existsSync(ENV_PATH))
39
- for (const line of readFileSync(ENV_PATH, "utf8").split(/\r?\n/)) {
40
- const m = line.match(/^\s*([A-Z_]+)=(.*)$/);
41
- if (m)
42
- out[m[1]] = m[2];
43
- }
44
- return out;
45
- }
46
91
  function upsertEnv(updates) {
47
- const cur = readEnv();
92
+ const cur = readEnvFile();
48
93
  const merged = { ...cur, ...updates };
49
94
  const body = Object.entries(merged).map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
50
95
  writeFileSync(ENV_PATH, body);
51
96
  }
97
+ /** Keep secrets out of git without nagging: append the two entries to .gitignore when they're
98
+ * not already covered. Creating the file in a non-git directory is harmless. */
99
+ function ensureGitignore() {
100
+ const entries = [".env", ".decidio-pending/"];
101
+ const existing = existsSync(".gitignore") ? readFileSync(".gitignore", "utf8") : "";
102
+ const missing = entries.filter((e) => !existing.split(/\r?\n/).some((l) => l.trim() === e || l.trim() === e.replace(/\/$/, "")));
103
+ if (!missing.length)
104
+ return;
105
+ appendFileSync(".gitignore", (existing && !existing.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n");
106
+ log(`✔ added ${missing.join(" + ")} to .gitignore (your agent key and parked actions stay out of git)`);
107
+ }
52
108
  // ---- interactive input (session sign-in) ---------------------------------------------------
53
109
  function ask(question, mute = false) {
54
110
  return new Promise((resolve) => {
@@ -67,21 +123,27 @@ function ask(question, mute = false) {
67
123
  /** Owner SESSION token, memory-only. Sources in order: DECIDIO_SESSION_TOKEN env, interactive
68
124
  * email+password (`/demo/login`), pasted sign-in link (`/demo/login-consume` — for the
69
125
  * password-less accounts the welcome flow creates). Never persisted. */
70
- async function acquireSession() {
126
+ async function acquireSession(purpose) {
71
127
  if (process.env.DECIDIO_SESSION_TOKEN)
72
128
  return process.env.DECIDIO_SESSION_TOKEN;
73
129
  // Same rule the server applies to resume URLs: credentials never ride plaintext http to a
74
130
  // non-loopback host. Refuse before prompting, not after collecting a password.
75
131
  if (/^http:\/\//i.test(API) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])([:/]|$)/i.test(API)) {
76
- log(`✘ refusing to send sign-in credentials over plaintext http to ${API} — use https (or localhost for local dev).`);
132
+ log(`✘ refusing to send sign-in credentials over plaintext http to ${API}.`);
133
+ log(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
77
134
  return null;
78
135
  }
79
136
  if (!process.stdin.isTTY) {
80
- log("✘ a Decidio sign-in is needed and this terminal is non-interactive.");
81
- log(" Set DECIDIO_SESSION_TOKEN (grab a sign-in link from your workspace email; its ?login= value is the token).");
137
+ log(`✘ ${purpose} needs a Decidio workspace sign-in, and this terminal can't prompt for one.`);
138
+ log(` Fix: set DECIDIO_SESSION_TOKEN and re-run. To mint one:`);
139
+ log(` 1. Request a sign-in link from your workspace's sign-in screen (or use your welcome email).`);
140
+ log(` 2. POST its ?login= value — the response's "token" is your session token:`);
141
+ log(` curl -X POST ${API}/demo/login-consume -H "content-type: application/json" -d '{"token":"<login value>"}'`);
82
142
  return null;
83
143
  }
84
- log(`Sign in to your Decidio workspace (${API}). Press Enter at the password prompt to use an emailed sign-in link instead.`);
144
+ log(`${purpose} needs your Decidio workspace sign-in (${API}).`);
145
+ log(` The session stays in memory for this command only — it is never written to disk.`);
146
+ log(` Press Enter at the password prompt to use an emailed sign-in link instead.`);
85
147
  const email = await ask(" email: ");
86
148
  if (email) {
87
149
  const password = await ask(" password (Enter to skip): ", true);
@@ -89,27 +151,30 @@ async function acquireSession() {
89
151
  const r = await api("/demo/login", { method: "POST", body: JSON.stringify({ email, password }) }, "");
90
152
  if (r.status >= 200 && r.status < 300 && r.body?.token)
91
153
  return r.body.token;
92
- log(` ✘ sign-in failed (${r.status})${r.body?.error ? ` — ${r.body.error}` : ""}. Trying the link path…`);
154
+ log(` ✘ password sign-in failed (${r.status})${r.body?.error ? ` — ${r.body.error}` : ""}. No problem — the link path works for every account:`);
93
155
  }
94
156
  }
95
- log(" Request a sign-in link from the app's sign-in screen (or use your welcome email), then paste the LINK or its token here.");
157
+ log(` Request a sign-in link from the app's sign-in screen, then paste the LINK (or just its token) here.`);
96
158
  const pasted = await ask(" link or token: ");
97
- if (!pasted)
159
+ if (!pasted) {
160
+ log(" ✘ nothing pasted — re-run when you have the link.");
98
161
  return null;
162
+ }
99
163
  const m = pasted.match(/[?&]login=([^&\s]+)/);
100
164
  const linkToken = m ? decodeURIComponent(m[1]) : pasted;
101
165
  const r = await api("/demo/login-consume", { method: "POST", body: JSON.stringify({ token: linkToken }) }, "");
102
166
  if (r.status >= 200 && r.status < 300 && r.body?.token)
103
167
  return r.body.token;
104
- log(` ✘ link sign-in failed (${r.status})${r.body?.error ? ` — ${r.body.error}` : ""}`);
168
+ log(` ✘ that link didn't work (${r.status})${r.body?.error ? ` — ${r.body.error}` : ""}.`);
169
+ log(` Sign-in links are single-use and expire in 15 minutes — request a fresh one and re-run.`);
105
170
  return null;
106
171
  }
107
172
  async function cmdInit(agentId) {
108
173
  log(`Registering agent "${agentId}" with Decidio at ${API}…`);
109
174
  // CLIENT-SIDE KEYGEN: generate the Ed25519 keypair LOCALLY and register only the PUBLIC
110
175
  // did:key. The private key NEVER crosses the wire — it goes straight to this host's env.
111
- const existingKey = readEnv().DECIDIO_AGENT_KEY;
112
- const sourceSystem = process.env.DECIDIO_SOURCE_SYSTEM || readEnv().DECIDIO_SOURCE_SYSTEM;
176
+ const existingKey = readEnvFile().DECIDIO_AGENT_KEY;
177
+ const sourceSystem = process.env.DECIDIO_SOURCE_SYSTEM || readEnvFile().DECIDIO_SOURCE_SYSTEM;
113
178
  const { did: localDid, privateKeyBase64: localKey } = generateKeypair();
114
179
  const registerBody = JSON.stringify({ agentId, did: localDid, ...(sourceSystem ? { sourceSystem } : {}) });
115
180
  // First try whatever bearer the env already carries (the local-dev / operator path — a static
@@ -117,29 +182,35 @@ async function cmdInit(agentId) {
117
182
  let session = null;
118
183
  let r = await api("/api/agents/register", { method: "POST", body: registerBody });
119
184
  if (r.status === 401 || r.status === 403) {
120
- session = await acquireSession();
121
- if (!session) {
122
- log("✘ unauthorized — sign in (or set DECIDIO_SESSION_TOKEN / DECIDIO_API_TOKEN) and retry.");
185
+ session = await acquireSession("Registering an agent");
186
+ if (!session)
123
187
  return 1;
124
- }
125
188
  r = await api("/api/agents/register", { method: "POST", body: registerBody }, session);
126
189
  }
127
190
  let did, privateKey;
128
191
  if (r.status === 409) {
129
192
  // Already registered to a different (server-held) did. Reuse it; the local key we just
130
193
  // minted does NOT match, so don't write it — the operator must supply the existing key.
131
- log(`• agent already registered (did ${r.body?.did}); reusing it.`);
132
194
  did = r.body?.did;
133
- if (!existingKey)
134
- log("⚠ this agent was registered earlier — set its existing DECIDIO_AGENT_KEY to sign (auto-approve needs a proof).");
195
+ log(`• "${agentId}" is already registered in this workspace (did ${did}) — reusing it.`);
196
+ if (sourceSystem)
197
+ log(` Note: sourceSystem can't change on an existing agent — the registered lane stands. To use the "${sourceSystem}" lane, register a NEW name: npx @decidio/sdk init ${agentId}-${sourceSystem}`);
198
+ if (existingKey)
199
+ log(` Your .env already carries a DECIDIO_AGENT_KEY; if it's this agent's original key, signing keeps working.`);
200
+ else {
201
+ log(` ⚠ Its private key lives wherever this agent was FIRST initialized — keys never leave the machine that made them.`);
202
+ log(` Either restore that DECIDIO_AGENT_KEY into .env, or start fresh under a new name:`);
203
+ log(` npx @decidio/sdk init ${agentId}-2`);
204
+ }
135
205
  }
136
206
  else if (r.status >= 200 && r.status < 300) {
137
207
  did = r.body?.did;
138
208
  privateKey = localKey;
139
- log(`✔ registered (did ${did}) — key generated locally, public did only sent to Decidio`);
209
+ log(`✔ registered (did ${did}) — key generated locally; only the public half went to Decidio`);
140
210
  }
141
211
  else {
142
- log(`✘ register failed (${r.status}): ${JSON.stringify(r.body)}`);
212
+ log(`✘ registration failed (${r.status}): ${JSON.stringify(r.body)}`);
213
+ log(` Fix the cause above and re-run — init is safe to repeat.`);
143
214
  return 1;
144
215
  }
145
216
  // Mint the runtime credential: the floor-limited AGENT token (can call /agent/* only). This is
@@ -153,17 +224,18 @@ async function cmdInit(agentId) {
153
224
  if (mint.status >= 200 && mint.status < 300 && mint.body?.token) {
154
225
  agentToken = mint.body.token;
155
226
  workspaceId = mint.body.workspaceId;
156
- log(`✔ minted the agent API token (shown once server-side; expires ${mint.body.expiresAt ?? "per policy"})`);
227
+ log(`✔ minted the agent's API token floor-limited: it can request approvals and nothing else${mint.body.expiresAt ? ` (expires ${mint.body.expiresAt})` : ""}`);
157
228
  }
158
229
  else if (session) {
159
- log(`✘ could not mint the agent token (${mint.status}): ${JSON.stringify(mint.body)} — the SDK has no runtime credential; fix and re-run init.`);
230
+ log(`✘ could not mint the agent's API token (${mint.status}): ${JSON.stringify(mint.body)}`);
231
+ log(` Without it the SDK has no runtime credential. Fix the cause above and re-run init — it's safe to repeat.`);
160
232
  return 1;
161
233
  }
162
234
  else {
163
235
  log(`• no agent token minted (${mint.status}) — assuming local/operator auth via the existing DECIDIO_API_TOKEN.`);
164
236
  }
165
237
  }
166
- const secret = readEnv().DECIDIO_WEBHOOK_SECRET || randomBytes(32).toString("hex");
238
+ const secret = readEnvFile().DECIDIO_WEBHOOK_SECRET || randomBytes(32).toString("hex");
167
239
  const envOut = { DECIDIO_API_URL: API, DECIDIO_AGENT_ID: agentId, DECIDIO_WEBHOOK_SECRET: secret };
168
240
  if (sourceSystem)
169
241
  envOut.DECIDIO_SOURCE_SYSTEM = sourceSystem;
@@ -176,52 +248,75 @@ async function cmdInit(agentId) {
176
248
  if (workspaceId)
177
249
  envOut.DECIDIO_WORKSPACE_ID = workspaceId;
178
250
  upsertEnv(envOut);
179
- log(`✔ wrote ${ENV_PATH} (DECIDIO_API_URL, DECIDIO_AGENT_ID, DECIDIO_WEBHOOK_SECRET${did ? ", DECIDIO_AGENT_DID" : ""}${privateKey ? ", DECIDIO_AGENT_KEY" : ""}${agentToken ? ", DECIDIO_API_TOKEN [agent-scope], DECIDIO_WORKSPACE_ID" : ""})`);
251
+ log(`✔ wrote ${ENV_PATH} every decidio command reads it from this directory (your script loads it with node --env-file=.env)`);
252
+ ensureGitignore();
180
253
  if (privateKey)
181
- log(`\n✔ signing key stored locally as DECIDIO_AGENT_KEY in ${ENV_PATH} (keep that file out of git; move it to your secret manager for production).\n`);
182
- log("Add one line to your agent:");
183
- log(` import { guard } from "@decidio/sdk";`);
184
- log(` const createOpp = guard.protect(createOppRaw, o => ({ action: "createOpportunity", amount: o.Amount, scope: "Opportunity" }));`);
185
- log("Mount the resume route (durable async approval):");
186
- log(` // Express: app.post("/decidio/resume", guard.resumeHandler())`);
187
- log(` // Next.js: export const POST = (req) => guard.resumeFetchHandler()(req)`);
254
+ log(` (the agent's private signing key is in there for production, move it to your secret manager)`);
255
+ log("");
256
+ log("Next: protect one function and watch it route. Two steps:");
257
+ log("");
258
+ log(" 1. Save this as quickstart.mjs (the same snippet as the README's step 3):");
259
+ log("");
260
+ log(` import { guard } from "@decidio/sdk";`);
261
+ log("");
262
+ log(` const payInvoiceRaw = async (invoice) => ({ paid: true, id: invoice.id }); // your real action`);
263
+ log("");
264
+ log(` const payInvoice = guard.protect(`);
265
+ log(` payInvoiceRaw,`);
266
+ log(` (invoice) => ({ action: "payInvoice", amount: invoice.amount, scope: "Invoice" }),`);
267
+ log(` { mode: "blocking" }, // blocking = watch it happen live; durable is the production mode`);
268
+ log(` );`);
269
+ log("");
270
+ log(` const result = await payInvoice({ id: "INV-2026-001", amount: 86_000 });`);
271
+ log(` console.log("executed after approval:", result);`);
272
+ log("");
273
+ log(" 2. Run it, approve it, watch it complete:");
274
+ log("");
275
+ log(` node --env-file=.env quickstart.mjs # routes to a human, prints the decision id, waits`);
276
+ log(` npx @decidio/sdk approvals # (second terminal) see it pending`);
277
+ log(` npx @decidio/sdk approvals approve <id> # approve -> your function runs -> sealed`);
188
278
  log("");
189
279
  return cmdDoctor();
190
280
  }
191
281
  async function cmdDoctor() {
192
- const env = readEnv();
282
+ const env = readEnvFile();
193
283
  let ok = true;
194
284
  const line = (good, label) => { log(`${good ? "✔" : "✘"} ${label}`); ok = ok && good; };
195
- line(!!(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL), `API URL: ${API}`);
196
- line(!!(process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID), `agent id: ${process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID || "(unset)"}`);
285
+ line(!!(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL), `API URL: ${API}${(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL) ? "" : " (unset — defaulting to localhost; the hosted sandbox is https://decidio-api.onrender.com)"}`);
286
+ 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>)"}`);
197
287
  const secret = process.env.DECIDIO_WEBHOOK_SECRET || env.DECIDIO_WEBHOOK_SECRET;
198
- line(!!secret, secret ? "webhook secret: set (durable resume will fail-closed verify)" : "webhook secret: MISSING — resume webhook will fail closed");
199
- // Signing key — REQUIRED for auto-approve. Without it the SDK can't prove identity, so the
200
- // gate routes EVERY request to a human (safe, but surprising). Surface it loudly.
288
+ line(!!secret, secret ? "webhook secret: set (durable resume will fail-closed verify)" : "webhook secret: MISSING — the resume webhook will fail closed (init generates one)");
289
+ // Signing key — REQUIRED to be ELIGIBLE for auto-approve. Whether anything auto-approves is
290
+ // the workspace policy's call, not this key's (Codex finding: the old wording claimed
291
+ // "auto-approve is in effect" while deny-by-default routed everything).
201
292
  const signing = (process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY) && (process.env.DECIDIO_AGENT_DID || env.DECIDIO_AGENT_DID);
202
293
  if (signing)
203
- log("✔ signing key: set — agent proves identity, so policy auto-approve is in effect");
294
+ log("✔ signing key: set — 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).");
204
295
  else
205
- log("⚠ signing key: MISSING (DECIDIO_AGENT_KEY/DID) — without it the agent can't prove identity, so EVERY request routes to a human. Run `npx @decidio/sdk init` to generate one.");
296
+ log("⚠ 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.");
206
297
  // Reachability and token role are SEPARATE checks (the old probe called any <500 green, so a
207
298
  // wrong-kind token looked healthy). /api/health is unauthenticated truth about reachability;
208
- // /api/records then classifies the token: 2xx = session/admin scope, 401 = agent-floor scope
209
- // (correct for the runtime; init/approvals need a sign-in).
210
- try {
211
- const h = await api("/api/health", {}, "");
212
- line(h.status >= 200 && h.status < 300, `connectivity: API reachable (${h.status})`);
213
- const rec = await api("/api/records");
214
- if (rec.status >= 200 && rec.status < 300)
215
- log("✔ token scope: session/admin (init + approvals will work from this env)");
216
- else if (rec.status === 401)
217
- log("✔ token scope: agent floor correct for the runtime SDK (approvals/init will prompt for a sign-in)");
299
+ // /api/records then classifies the token.
300
+ const h = await api("/api/health", {}, "");
301
+ line(h.status >= 200 && h.status < 300, `connectivity: API reachable (${h.status})`);
302
+ const rec = await api("/api/records");
303
+ if (rec.status >= 200 && rec.status < 300)
304
+ log("✔ token scope: session/admin (init, approvals and receipt work from this env)");
305
+ else if (rec.status === 401) {
306
+ // A 401 off the agent floor could also mean an INVALID token (Codex catch: don't
307
+ // false-green expiry). Disambiguate with a floor probe: a valid agent token gets 404 for an
308
+ // unknown decision (authorized, not found); an invalid one gets 401 again.
309
+ const probe = await api("/agent/status/doctor-scope-probe");
310
+ if (probe.status === 404)
311
+ log("✔ token scope: agent floor — correct for the runtime SDK (approvals/receipt will ask you to sign in)");
312
+ else if (probe.status === 401)
313
+ log("✘ token: REJECTED on the agent floor too — it's invalid or expired. Re-run `npx @decidio/sdk init` to mint a fresh one.");
218
314
  else
219
- log(`⚠ token scope: unexpected ${rec.status} from /api/records`);
220
- }
221
- catch (e) {
222
- line(false, `connectivity: cannot reach ${API} — ${e?.message}`);
315
+ log(`⚠ token scope: agent-floor probe returned ${probe.status} inconclusive`);
223
316
  }
224
- log(ok ? "\ndoctor: all green — durable resume ready." : "\ndoctor: fix the ✘ items above.");
317
+ else
318
+ log(`⚠ token scope: unexpected ${rec.status} from /api/records`);
319
+ log(ok ? "\ndoctor: all green." : "\ndoctor: fix the ✘ items above, then re-run `npx @decidio/sdk doctor`.");
225
320
  return ok ? 0 : 1;
226
321
  }
227
322
  async function cmdApprovals(sub, id, reason) {
@@ -230,31 +325,51 @@ async function cmdApprovals(sub, id, reason) {
230
325
  let bearer;
231
326
  const probe = await api("/api/decisions/cards");
232
327
  if (probe.status === 401 || probe.status === 403) {
233
- const session = await acquireSession();
234
- if (!session) {
235
- log("✘ approvals need a workspace sign-in.");
328
+ const session = await acquireSession("Reviewing approvals");
329
+ if (!session)
236
330
  return 1;
237
- }
238
331
  bearer = session;
239
332
  }
240
333
  if (sub === "approve" || sub === "reject") {
241
334
  if (!id) {
242
- log(`usage: approvals ${sub} <decisionId> [reason]`);
335
+ log(`usage: npx @decidio/sdk approvals ${sub} <decisionId> [reason]`);
243
336
  return 2;
244
337
  }
245
338
  const r = await api(`/api/decisions/${encodeURIComponent(id)}/${sub === "approve" ? "approve" : "reject"}`, { method: "POST", body: JSON.stringify({ reason: reason ?? undefined }) }, bearer);
246
339
  if (r.status >= 200 && r.status < 300) {
340
+ // HONESTY over green checkmarks (Codex finding: the server resolves each decision exactly
341
+ // once and answers replays idempotently — a replay is a NO-OP and must read as one, never
342
+ // as a fresh approval). Non-zero exit so scripts can tell the difference.
343
+ if (r.body?.alreadyResolved) {
344
+ log(`• ${id} was already resolved — nothing changed.`);
345
+ log(` Decisions are single-use: a second ${sub} can never re-execute the action (that's the replay protection working).`);
346
+ log(` See its sealed record: npx @decidio/sdk receipt ${id}`);
347
+ return 1;
348
+ }
247
349
  log(`✔ ${sub}d ${id}${r.body?.resumeSignal ? ` — agent signalled (${r.body.resumeSignal.ok ? "ok" : r.body.resumeSignal.error})` : ""}`);
350
+ if (sub === "approve")
351
+ log(` The agent's function runs next — a blocking call resumes now; a durable one when its worker or resume route picks this up.`);
352
+ else
353
+ log(` The agent's action will NOT run; the rejection seals as a verifiable record.`);
354
+ log(` Download the receipt: npx @decidio/sdk receipt ${id}`);
248
355
  return 0;
249
356
  }
357
+ if (r.status === 404) {
358
+ log(`✘ no decision "${id}" in this workspace — check the id (npx @decidio/sdk approvals lists pending ones).`);
359
+ return 1;
360
+ }
250
361
  log(`✘ ${sub} failed (${r.status}): ${JSON.stringify(r.body)}`);
251
362
  return 1;
252
363
  }
253
364
  const r = bearer ? await api("/api/decisions/cards", {}, bearer) : probe;
365
+ if (!(r.status >= 200 && r.status < 300)) {
366
+ log(`✘ could not list approvals (${r.status}): ${JSON.stringify(r.body)}`);
367
+ return 1;
368
+ }
254
369
  const cards = r.body?.cards ?? [];
255
370
  const pending = cards.filter((c) => c.isAgentRequest && c.status !== "sealed");
256
371
  if (!pending.length) {
257
- log("No pending agent approvals.");
372
+ log("No pending agent approvals. Trigger one from your protected function, then re-run this.");
258
373
  return 0;
259
374
  }
260
375
  log(`Pending agent approvals (${pending.length}):`);
@@ -262,7 +377,51 @@ async function cmdApprovals(sub, id, reason) {
262
377
  const ar = c.agentRequest ?? {};
263
378
  log(` ${c.id} ${ar.action ?? "?"}${ar.amount != null ? ` $${Number(ar.amount).toLocaleString()}` : ""} — ${c.title ?? ""}`);
264
379
  }
265
- log(`\nApprove: decidio approvals approve <id> [reason] · Reject: decidio approvals reject <id> [reason]`);
380
+ log(`\nApprove: npx @decidio/sdk approvals approve <id> [reason]`);
381
+ log(`Reject: npx @decidio/sdk approvals reject <id> [reason]`);
382
+ return 0;
383
+ }
384
+ async function cmdReceipt(id) {
385
+ if (!id) {
386
+ log("usage: npx @decidio/sdk receipt <decisionId>");
387
+ log(" Downloads the decision's sealed Authority Receipt (a W3C Verifiable Credential) to a .json file.");
388
+ log(" Find decision ids via `npx @decidio/sdk approvals` or in your Decidio app.");
389
+ return 2;
390
+ }
391
+ // Receipts are workspace data — session scope, like approvals. (The record-detail /verify
392
+ // route carries the credential; the plain record route does not — Codex finding.)
393
+ let bearer;
394
+ let r = await api(`/api/records/${encodeURIComponent(id)}/verify`);
395
+ if (r.status === 401 || r.status === 403) {
396
+ const session = await acquireSession("Downloading a receipt");
397
+ if (!session)
398
+ return 1;
399
+ bearer = session;
400
+ r = await api(`/api/records/${encodeURIComponent(id)}/verify`, {}, bearer);
401
+ }
402
+ if (r.status === 404) {
403
+ log(`✘ no sealed record for "${id}" in this workspace.`);
404
+ log(` Receipts exist once a decision is SEALED (approved or rejected). Still pending? npx @decidio/sdk approvals`);
405
+ return 1;
406
+ }
407
+ if (!(r.status >= 200 && r.status < 300) || !r.body?.credential) {
408
+ log(`✘ could not fetch the receipt (${r.status}): ${JSON.stringify(r.body).slice(0, 300)}`);
409
+ return 1;
410
+ }
411
+ const name = `decidio-receipt-${id.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
412
+ writeFileSync(name, JSON.stringify(r.body.credential, null, 2) + "\n");
413
+ log(`✔ wrote ${name}`);
414
+ // The credential seals at DECISION time (immutable), so execution evidence that arrived later
415
+ // lives on the record — surface the CURRENT tier here so the reader doesn't need a second
416
+ // lookup to see it (Codex catch).
417
+ const detail = await api(`/api/records/${encodeURIComponent(id)}`, {}, bearer);
418
+ const conf = detail.body?.record?.confirmation;
419
+ if (conf?.status)
420
+ log(` Sealed credential: the authority decision (its confirmation field reads "pending" forever — immutable). Current record evidence tier: ${conf.status}${conf.confirmed ? " (confirmed)" : ""}.`);
421
+ log(` Verify it offline — no Decidio account or network needed:`);
422
+ log(` npx @decidio/verify ${name}`);
423
+ log(` To also prove WHO issued it, pin your workspace's issuer DID (shown in your app's record verify panel):`);
424
+ log(` npx @decidio/verify --issuer <did:key:...> ${name}`);
266
425
  return 0;
267
426
  }
268
427
  function cmdDev(target, port) {
@@ -292,12 +451,38 @@ function cmdDev(target, port) {
292
451
  }).listen(port);
293
452
  return 0;
294
453
  }
454
+ const USAGE = `decidio — the Decidio SDK command line
455
+
456
+ npx @decidio/sdk init [agentId] sign in, register the agent, mint its API token, write .env
457
+ npx @decidio/sdk doctor check config, connectivity, and what your token can do
458
+ npx @decidio/sdk approvals list pending agent approvals
459
+ npx @decidio/sdk approvals approve <id> [reason]
460
+ npx @decidio/sdk approvals reject <id> [reason]
461
+ npx @decidio/sdk receipt <id> download a sealed decision's Authority Receipt (.json)
462
+ npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
463
+ npx @decidio/sdk --version print the CLI version
464
+
465
+ Docs: the README that shipped with this package (quickstart, durable resume, adapters).`;
295
466
  async function main(argv) {
296
467
  const [cmd, a, b, ...rest] = argv.slice(2);
468
+ const wantsHelp = (s) => s === "help" || s === "--help" || s === "-h";
469
+ if (cmd === "--version" || cmd === "-v" || cmd === "version") {
470
+ log(VERSION);
471
+ return 0;
472
+ }
473
+ if (!cmd || wantsHelp(cmd)) {
474
+ log(USAGE);
475
+ return 0;
476
+ }
477
+ if (wantsHelp(a)) {
478
+ log(USAGE);
479
+ return 0;
480
+ }
297
481
  switch (cmd) {
298
- case "init": return cmdInit(a ?? process.env.DECIDIO_AGENT_ID ?? "salesops-agent");
482
+ case "init": return cmdInit((a && !a.startsWith("-") ? a : undefined) ?? process.env.DECIDIO_AGENT_ID ?? "my-agent");
299
483
  case "doctor": return cmdDoctor();
300
484
  case "approvals": return cmdApprovals(a, b, rest.join(" ") || undefined);
485
+ case "receipt": return cmdReceipt(a);
301
486
  case "dev": {
302
487
  const args = argv.slice(3);
303
488
  const flag = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
@@ -306,9 +491,9 @@ async function main(argv) {
306
491
  return cmdDev(target, port);
307
492
  }
308
493
  default:
309
- log("decidio <init|doctor|approvals|dev>\n init [agentId] · doctor · approvals [approve|reject <id> [reason]] · dev [--target URL] [--port N]");
310
- return cmd ? 1 : 0;
494
+ log(`✘ unknown command "${cmd}"\n\n${USAGE}`);
495
+ return 1;
311
496
  }
312
497
  }
313
498
  main(process.argv).then((code) => { if (code !== 0)
314
- process.exitCode = code; }).catch((e) => { console.error(e); process.exit(1); });
499
+ process.exitCode = code; }).catch((e) => { console.error(`✘ unexpected error: ${e?.message ?? e}`); process.exit(1); });
package/dist/index.js CHANGED
@@ -122,13 +122,16 @@ export function withApproval(fn, describe, config) {
122
122
  throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
123
123
  // 2. routed → either SUSPEND for async resume (durable) or BLOCK-poll (interactive)
124
124
  if (auth.decision === "route") {
125
- // Hand back a link straight to the decision. An agent that suspends and says only "waiting"
126
- // leaves the operator to go hunting through a queue; one that prints the URL does not.
125
+ // ALWAYS hand back the decision id an agent that suspends and says only "waiting" leaves
126
+ // the operator hunting through a queue, and an agent that says NOTHING (the pre-0.1.2 bug:
127
+ // the print was gated on appUrl, which init never writes) leaves them staring at a silent
128
+ // terminal. The Codex acceptance run failed exactly this claim; the id line is unconditional
129
+ // now, with the deep link when appUrl is configured and the CLI command when not.
127
130
  const approvalUrl = decisionUrl(config.appUrl, auth.decisionId);
128
131
  if (config.onPending)
129
132
  config.onPending({ decisionId: auth.decisionId, receiptId: auth.receiptId, url: approvalUrl });
130
- else if (approvalUrl)
131
- console.log(`[decidio] ${ctx.action} is awaiting approval — approve it at ${approvalUrl}`);
133
+ else
134
+ console.log(`[decidio] ${ctx.action} is awaiting approval (decision ${auth.decisionId}) — approve it ${approvalUrl ? `at ${approvalUrl}` : `in your Decidio queue, or: npx @decidio/sdk approvals approve ${auth.decisionId}`}`);
132
135
  // Durable transport: park the args agent-side, register the raw fn so a (cold)
133
136
  // resume can replay it, and SUSPEND. The process may exit now; the controller's
134
137
  // webhook handler or poll worker re-executes THIS fn when a human approves.
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.1.0
8
+ version: 0.1.2
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
@@ -107,6 +107,11 @@ components:
107
107
  OUTBOUND — Decidio → your resumeUrl when a routed decision is decided. The raw body is
108
108
  HMAC-signed; verify `x-decidio-signature: sha256=<hex>` with your DECIDIO_WEBHOOK_SECRET
109
109
  BEFORE acting (the SDK resume controllers fail closed). Re-execution is single-use.
110
+ OPERATOR DEPLOYMENTS ONLY: signing uses a shared secret configured on both the Decidio
111
+ server and the agent, and self-hosted production allow-lists resume hosts (default-deny).
112
+ Self-serve agents against the hosted sandbox use the SDK's poll worker (guard.worker())
113
+ instead — a locally generated secret the server never learned would (correctly) fail
114
+ closed here.
110
115
  required: [decisionId, verdict]
111
116
  properties:
112
117
  decisionId: { type: string }
@@ -149,7 +154,10 @@ paths:
149
154
  "404": { description: unknown decision }
150
155
  /agent/confirm:
151
156
  post:
152
- summary: Report the captured execution outcome (seals the receipt's evidence tier)
157
+ summary: >
158
+ Report the captured execution outcome — recorded as the RECORD's evidence tier
159
+ (application_confirmed). The credential sealed at decision time is immutable; evidence
160
+ accrues on the record, never inside the sealed bytes.
153
161
  requestBody:
154
162
  required: true
155
163
  content:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decidio/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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",
@@ -42,7 +42,7 @@
42
42
  "LICENSE"
43
43
  ],
44
44
  "engines": {
45
- "node": ">=20"
45
+ "node": ">=20.6"
46
46
  },
47
47
  "keywords": [
48
48
  "ai-agents",