@decidio/sdk 0.1.12 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -90,8 +90,9 @@ const payInvoice = guard.protect(
90
90
  { mode: "blocking" }, // blocking = watch the whole loop live in one terminal sitting.
91
91
  ); // Production agents use durable mode instead — see below.
92
92
 
93
- const result = await payInvoice({ id: "INV-2026-001", amount: 86_000 });
94
- console.log("executed after approval:", result);
93
+ const { value, confirmation } = await payInvoice({ id: "INV-2026-001", amount: 86_000 });
94
+ console.log("executed after approval:", value);
95
+ console.log("Decidio recorded it as:", confirmation.evidenceTier); // e.g. application_confirmed
95
96
  ```
96
97
 
97
98
  **4. Trigger it:**
@@ -200,7 +201,7 @@ That's the whole contract: gate → human decision → your execution → owned,
200
201
  - **proceed** — your function runs immediately (auto-approved under a named, versioned rule).
201
202
  - **route** — the action suspends (durable) or waits (blocking) for a human decision in Decidio's queue, then runs your function (or throws `DecidioRejectedError`).
202
203
  - **block** — the wrapper throws `DecidioBlockedError`; your function never runs.
203
- 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).
204
+ 3. After your function runs, the wrapper reports the **real response it captured** back to Decidio (which minimizes + tokenizes before storing — the immutable record never stores raw payloads). The tier that report earns depends on what it can prove: `application_confirmed` when the agent has key material and signs the execution report, `agent_asserted` when it does not, and `execution_unconfirmed` when the reported value carries no usable evidence. The SDK tells you which it got; it never claims the top tier by default.
204
205
 
205
206
  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.
206
207
 
@@ -313,13 +314,42 @@ canonicalization preserves **value** semantics, not JavaScript object identity.
313
314
  pointing at the same object arrive as two equal objects, and mutating an argument inside your
314
315
  function does not affect the caller's copy.
315
316
 
316
- **What the SDK can and cannot promise about duplicates.** It prevents *concurrent* execution:
317
- an entry is claimed atomically before it runs, so two workers cannot both execute it and a
318
- duplicate signal finds nothing to replay. It cannot make an external side effect exactly-once —
319
- no client can. If your action succeeds and the process dies before the result is recorded,
320
- whether it ran is not knowable from this machine. So the contract is **at-least-once with
321
- honest recovery**, and your downstream action should be idempotent (the decision id makes a
322
- natural idempotency key).
317
+ **What the SDK can and cannot promise about duplicates.** The guarantee, stated exactly:
318
+
319
+ > Once invocation may have begun, the SDK never automatically invokes it again unless the
320
+ > downstream system provides an idempotency guarantee or an operator explicitly reconciles it.
321
+
322
+ It holds across processes and restarts, not just within one worker. Before your function is
323
+ called the SDK writes a durable `invoking` marker to the pending store; from that moment no
324
+ poll, no duplicate webhook, no fresh process and no worker cycle will call it again. Deletion of
325
+ the parked row is cleanup, not the guarantee — a store whose `delete()` silently fails cannot
326
+ cause a second execution.
327
+
328
+ **Closing the last window yourself.** Pass `withContext: true` and your action receives an
329
+ `ExecutionContext` as its first argument:
330
+
331
+ ```js
332
+ const payInvoice = guard.protect(
333
+ async (ctx, invoice) => stripe.paymentIntents.create(
334
+ { amount: invoice.amount, currency: "usd" },
335
+ { idempotencyKey: ctx.idempotencyKey }, // ← stable across every attempt at this decision
336
+ ),
337
+ (invoice) => ({ action: "payInvoice", amount: invoice.amount }),
338
+ { withContext: true },
339
+ );
340
+ ```
341
+
342
+ `ctx` is `{ decisionId, attemptId, idempotencyKey }`. It is opt-in rather than inferred from your
343
+ function's arity, because guessing wrong would hand a payment call a context object where it
344
+ expected an invoice. `describe` still receives your arguments only — it describes the *request*,
345
+ while the context describes the *execution*.
346
+
347
+ It still cannot make an external side effect exactly-once — no client can. If your action
348
+ succeeds and the process dies before the result is recorded, whether the downstream write
349
+ committed is not knowable from this machine. The difference is what the SDK does about it: that
350
+ decision becomes **`indeterminate`** and stops, rather than being retried into a duplicate
351
+ payment. You settle it with `reconcile()` once you have looked downstream. Pass the decision id
352
+ as your vendor's idempotency key and that window closes on their side too.
323
353
 
324
354
  Recovery is explicit rather than magical:
325
355
 
@@ -332,9 +362,12 @@ for (const item of await guard.resume.recover()) {
332
362
  // "parked" → still waiting on a human; nothing has executed
333
363
  ```
334
364
 
335
- The report a worker sends is journalled *before* the first attempt, so a lost confirmation is
336
- recoverable; and because the journal holds no handler binding, replaying it can only re-send
337
- evidence — never re-run your function.
365
+ The report a worker sends is written to the pending store's execution ledger *before* the first
366
+ send attempt, so a lost confirmation is recoverable; and because that record holds no handler
367
+ binding, replaying it can only re-send evidence — never re-run your function. The ledger lives in
368
+ the store you configured, so two workers sharing one Redis or Postgres store can reconfirm each
369
+ other's work. (Through 0.1.x this was a separate local-filesystem journal, which meant two
370
+ workers kept two disjoint journals and neither could. `reportDir` is accepted and ignored now.)
338
371
 
339
372
  **Parked arguments must be JSON-serializable.** A suspended action is signed and then written to
340
373
  disk as JSON, so values that don't survive `JSON.stringify` round-tripping — `Date`, `Map`,
@@ -380,6 +413,10 @@ for engines you don't. Each adapter's shipped `.d.ts` header documents its wirin
380
413
  `npx @decidio/sdk <cmd>`:
381
414
 
382
415
  - `quickstart` — the guided two-minute tour (gate → approve → execute → proof)
416
+ - `--auto-approve-demo` approves the tour's own synthetic decision so it can run headless; it
417
+ then CLEANS UP the tour agent and token automatically. Pass `--keep-agent` to keep them.
418
+ - An interactive run keeps the agent by default and offers cleanup at the end;
419
+ `quickstart cleanup` works later too.
383
420
  - `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.
384
421
  - `doctor` — config, connectivity, and what your current token can do
385
422
  - `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)
@@ -392,7 +429,7 @@ for engines you don't. Each adapter's shipped `.d.ts` header documents its wirin
392
429
  The CLI's stream contract: **stdout carries results only** (safe to pipe — `--json` output is
393
430
  pure JSON); errors, usage shown on *error* paths, and the per-run target-host announcement go
394
431
  to **stderr**. Asked-for help (an explicit `--help`, or invoking the bare CLI) is itself a
395
- result and prints on stdout. Exit codes: 0 success · 1 failure or honest no-op · 2 usage error.
432
+ result and prints on stdout. Exit codes: 0 success · 1 failure or honest no-op · 2 usage error · 3 `quickstart` stopped at the human moment (headless: the decision routed, but nothing executed and no receipt was written, so the run is deliberately not reported as a completed tour).
396
433
 
397
434
  Commands that administer your workspace (`init`, `approvals`, `receipt`) use a **session**
398
435
  sign-in held in memory for that run. What lands on disk in `.env`: the agent's floor-limited
@@ -431,6 +468,6 @@ your queue.
431
468
  ## Notes
432
469
 
433
470
  - **Framework-agnostic:** it wraps at the tool-call boundary, so it works with any agent runtime (LangGraph, OpenAI Agents SDK, CrewAI, a raw tool loop) and any action (Salesforce, M365, SAP, a DB write).
434
- - **Confirmation integrity:** the wrapper runs in your process, so it captures the genuine API response — a materially stronger signal than an agent self-report, but it trusts the host. For high-assurance, Decidio can *independently* read the source back (`source_confirmed`) where it holds a read connection.
471
+ - **Confirmation integrity:** the wrapper runs in your process, so it captures the genuine API response — a materially stronger signal than an agent self-report, but it trusts the host. For high-assurance, Decidio can *independently* read the source back (`independently_reconciled`) where it holds a read connection.
435
472
  - **Zero-code alternative:** an agent that speaks MCP (e.g. Claude on the web) can reach the same gate via Decidio's `request_approval` MCP tool with no code at all.
436
473
  - **API surface:** the agent-gate REST contract this SDK speaks is described in [`openapi.yaml`](./openapi.yaml), shipped in the package.
@@ -1,4 +1,4 @@
1
- import { type GuardConfig, type ApprovalContext } from "../index.js";
1
+ import { type GuardConfig, type ApprovalContext, type EvidenceOutcome } from "../index.js";
2
2
  /** The ONLY authorization verdicts this SDK understands. A gate answer outside this set means
3
3
  * the SDK does not understand the decision — and an answer you do not understand means NO. */
4
4
  export declare const AUTHORIZE_VERDICTS: readonly ["proceed", "route", "block"];
@@ -16,7 +16,13 @@ export interface GateAuth {
16
16
  * fourth verdict) is the realistic trigger, and it must never mean "run it". */
17
17
  export declare function assertAuthorizeVerdict(auth: unknown, fallbackDecisionId?: string): GateAuth;
18
18
  export declare function gateAuthorize(config: GuardConfig, ctx: ApprovalContext): Promise<GateAuth>;
19
- export declare function gateConfirm(config: GuardConfig, decisionId: string, result: unknown): Promise<void>;
19
+ /** Report an adapter-path execution and RETURN what Decidio recorded.
20
+ *
21
+ * Two 0.2.0 changes, both closing the same gap. It applies the caller's `evidence` mapper — the
22
+ * warning below tells you to use that option, and on every engine adapter it did nothing, because
23
+ * the mapper never reached this function. And it RETURNS the outcome instead of swallowing it, so
24
+ * an adapter can hand its caller the same confirmation the core path now does. */
25
+ export declare function gateConfirm(config: GuardConfig, decisionId: string, result: unknown, evidence?: (r: any) => unknown): Promise<EvidenceOutcome>;
20
26
  /** Normalize a resume payload ({verdict} | "approved" | ...) to a verdict string. */
21
27
  export declare function verdictFrom(payload: any): string;
22
28
  /** Throw the right error for a non-approving verdict; return for approved/auto_approved. */
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, carriesCredential, canonicalize, readConfirmResponse, buildConfirmBody } from "../index.js";
2
+ import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, carriesCredential, canonicalize, readConfirmResponse, buildConfirmBody, decisionIdProblem } from "../index.js";
3
3
  export const AUTHORIZE_VERDICTS = ["proceed", "route", "block"];
4
4
  export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
5
5
  const a = auth;
@@ -7,8 +7,9 @@ export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
7
7
  if (!a || !AUTHORIZE_VERDICTS.includes(a.decision)) {
8
8
  throw new DecidioBlockedError(`unrecognised authorization verdict ${JSON.stringify(a?.decision ?? null)} — refusing to execute. The gate answers ${AUTHORIZE_VERDICTS.join(" | ")}; anything else means this SDK version does not understand the decision (upgrade @decidio/sdk), and an answer you do not understand is never permission.`, decisionId);
9
9
  }
10
- if (typeof a.decisionId !== "string" || !a.decisionId) {
11
- throw new DecidioBlockedError(`the gate answered "${a.decision}" with no usable decisionId (${JSON.stringify(a.decisionId ?? null)}) — refusing to execute. Every authorized action must be addressable: without an id it cannot be polled, resumed, confirmed or audited.`, decisionId);
10
+ const idProblem = decisionIdProblem(a.decisionId);
11
+ if (idProblem) {
12
+ throw new DecidioBlockedError(`the gate answered "${a.decision}" with no usable decisionId (${JSON.stringify(a.decisionId ?? null)} — ${idProblem}) — refusing to execute. Every authorized action must be addressable: without an id it cannot be polled, resumed, confirmed or audited.`, decisionId);
12
13
  }
13
14
  return a;
14
15
  }
@@ -40,19 +41,23 @@ export async function gateAuthorize(config, ctx) {
40
41
  }
41
42
  return assertAuthorizeVerdict(await res.json(), "unknown");
42
43
  }
43
- export async function gateConfirm(config, decisionId, result) {
44
+ export async function gateConfirm(config, decisionId, result, evidence) {
44
45
  const f = config.fetchImpl ?? fetch;
46
+ const failed = (why, retryable = true) => ({
47
+ reportRecorded: false, executionConfirmed: false, confirmationRetryable: retryable, reportJournalled: false,
48
+ diagnostic: `your action RAN and Decidio does NOT have its execution report (${why}). Record the outcome from your own system; do NOT re-run the action.`,
49
+ });
45
50
  try {
46
51
  assertSafeTransport(config.apiUrl, carriesCredential(config));
47
52
  }
48
53
  catch (e) {
49
54
  console.warn(`[decidio] confirm failed (action succeeded): ${e.message}`);
50
- return;
55
+ return failed(e.message, false);
51
56
  }
52
57
  let reported = null;
53
58
  let evidenceProblem;
54
59
  try {
55
- reported = canonicalize(result ?? null, "result");
60
+ reported = canonicalize((evidence ? evidence(result) : result) ?? null, evidence ? "evidence(result)" : "result");
56
61
  }
57
62
  catch (e) {
58
63
  evidenceProblem = String(e?.message ?? e);
@@ -69,7 +74,7 @@ export async function gateConfirm(config, decisionId, result) {
69
74
  });
70
75
  if (!res.ok) {
71
76
  console.warn(`[decidio] confirm returned ${res.status} (action succeeded)`);
72
- return;
77
+ return { ...failed(`HTTP ${res.status}`, res.status === 408 || res.status === 429 || res.status >= 500), evidenceError: evidenceProblem };
73
78
  }
74
79
  const ev = readConfirmResponse(await res.json().catch(() => null), { signed: !!requesterIdentity, capturedResponse: reported });
75
80
  if (!ev.reportRecorded)
@@ -78,9 +83,13 @@ export async function gateConfirm(config, decisionId, result) {
78
83
  console.warn(`[decidio] execution evidence for ${decisionId} recorded as ${ev.evidenceTier} — ${ev.diagnostic}`);
79
84
  if (evidenceProblem)
80
85
  console.warn(`[decidio] ${decisionId}: the execution IS recorded, but with an empty captured response — see the evidence note above.`);
86
+ if (evidenceProblem)
87
+ ev.evidenceError = evidenceProblem;
88
+ return ev;
81
89
  }
82
90
  catch (e) {
83
91
  console.warn(`[decidio] confirm failed (action succeeded): ${e.message}`);
92
+ return { ...failed(e.message), evidenceError: evidenceProblem };
84
93
  }
85
94
  }
86
95
  export function verdictFrom(payload) {
@@ -11,7 +11,9 @@ import { type GuardConfig, type ApprovalContext } from "../index.js";
11
11
  * (This wiring doc ships in the .d.ts — the README points here.)
12
12
  */
13
13
  /** Gate `run()` behind Decidio inside a LangGraph node. */
14
- export declare function decidioGate<R>(config: GuardConfig, ctx: ApprovalContext, run: () => Promise<R>): Promise<R>;
14
+ export declare function decidioGate<R>(config: GuardConfig, ctx: ApprovalContext, run: () => Promise<R>, opts?: {
15
+ evidence?: (r: R) => unknown;
16
+ }): Promise<R>;
15
17
  /** Translate Decidio's signed resume webhook into the LangGraph resume Command. The host
16
18
  * maps decisionId → thread_id and passes this to graph.invoke/stream. */
17
19
  export declare function decidioResumeCommand(body: {
@@ -1,7 +1,7 @@
1
1
  import { interrupt, Command } from "@langchain/langgraph";
2
2
  import { DecidioBlockedError } from "../index.js";
3
3
  import { gateAuthorize, gateConfirm, verdictFrom, assertVerdict, checkEngineVersion } from "./_gate.js";
4
- export async function decidioGate(config, ctx, run) {
4
+ export async function decidioGate(config, ctx, run, opts) {
5
5
  const auth = await gateAuthorize(config, ctx);
6
6
  if (auth.decision === "block")
7
7
  throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
@@ -10,7 +10,7 @@ export async function decidioGate(config, ctx, run) {
10
10
  assertVerdict(verdictFrom(resumed), auth.decisionId, resumed?.reason);
11
11
  }
12
12
  const result = await run();
13
- await gateConfirm(config, auth.decisionId, result);
13
+ await gateConfirm(config, auth.decisionId, result, opts?.evidence);
14
14
  return result;
15
15
  }
16
16
  export function decidioResumeCommand(body) {
@@ -1,4 +1,4 @@
1
- import type { GuardConfig, ApprovalContext } from "../index.js";
1
+ import { type GuardConfig, type ApprovalContext } from "../index.js";
2
2
  type ToolApprovalItem = any;
3
3
  interface RunStateLike {
4
4
  getInterruptions(): ToolApprovalItem[];
@@ -41,6 +41,11 @@ export declare function gateInterruptions(config: GuardConfig, state: RunStateLi
41
41
  resolved: Resolved[];
42
42
  pending: Pending[];
43
43
  }>;
44
- /** Apply a Decidio verdict to a resumed RunState (after RunState.fromString). */
44
+ /** Apply a Decidio verdict to a resumed RunState (after RunState.fromString).
45
+ *
46
+ * VALIDATES BEFORE MUTATING. `else state.reject(item)` treated every unrecognised string as a
47
+ * rejection, so a garbled payload, a version-skew verdict or an empty string became a rejection
48
+ * that a caller could not tell apart from a human deciding no — and the RunState was already
49
+ * mutated by the time anyone could look. The recognised set is exactly the one `resume` uses. */
45
50
  export declare function applyResume(state: RunStateLike, item: ToolApprovalItem, verdict: string): void;
46
51
  export {};
@@ -1,4 +1,5 @@
1
1
  import { gateAuthorize, checkEngineVersion } from "./_gate.js";
2
+ import { DecidioBlockedError } from "../index.js";
2
3
  export async function gateInterruptions(config, state, describe) {
3
4
  const resolved = [];
4
5
  const pending = [];
@@ -12,15 +13,22 @@ export async function gateInterruptions(config, state, describe) {
12
13
  state.reject(item);
13
14
  resolved.push({ item, verdict: "blocked", decisionId: auth.decisionId });
14
15
  }
15
- else
16
+ else if (auth.decision === "route")
16
17
  pending.push({ item, decisionId: auth.decisionId });
18
+ else
19
+ throw new DecidioBlockedError(`unhandled authorization verdict ${JSON.stringify(auth.decision)}`, auth.decisionId);
17
20
  }
18
21
  return { resolved, pending };
19
22
  }
20
23
  export function applyResume(state, item, verdict) {
21
- if (verdict === "approved" || verdict === "auto_approved")
24
+ if (verdict === "approved" || verdict === "auto_approved") {
22
25
  state.approve(item);
23
- else
26
+ return;
27
+ }
28
+ if (verdict === "rejected" || verdict === "blocked") {
24
29
  state.reject(item);
30
+ return;
31
+ }
32
+ throw new DecidioBlockedError(`unrecognised resume verdict ${JSON.stringify(verdict)} — refusing to apply it. This SDK understands approved / auto_approved / rejected / blocked; anything else is a version skew, and guessing would turn "I do not understand" into a decision. The RunState was NOT modified.`, "");
25
33
  }
26
34
  checkEngineVersion("openai");
@@ -21,11 +21,15 @@ export declare function installDecidioSignal(): Map<string, {
21
21
  verdict: string;
22
22
  reason?: string;
23
23
  }>;
24
- export interface DecidioGateOpts {
24
+ export interface DecidioGateOpts<R = unknown> {
25
25
  authorize?: () => Promise<GateAuth>;
26
26
  confirm?: (decisionId: string, result: unknown) => Promise<void>;
27
+ /** Map your action's return value to the evidence Decidio records — the same option the core
28
+ * path takes. Every adapter's stderr note named it and no adapter accepted one, so on this
29
+ * path the advice could not be followed. */
30
+ evidence?: (result: R) => unknown;
27
31
  }
28
32
  export declare function decidioGate<R>(config: GuardConfig, ctx: ApprovalContext, run: () => Promise<R>, verdicts: Map<string, {
29
33
  verdict: string;
30
34
  reason?: string;
31
- }>, opts?: DecidioGateOpts): Promise<R>;
35
+ }>, opts?: DecidioGateOpts<R>): Promise<R>;
@@ -22,6 +22,8 @@ export async function decidioGate(config, ctx, run, verdicts, opts = {}) {
22
22
  }
23
23
  const result = await run();
24
24
  if (opts.confirm) {
25
+ if (opts.evidence)
26
+ console.warn("[decidio] temporal: `evidence` is ignored when you supply `confirm` — your callback owns the report, so map the result inside it. Remove one of the two to make the intent clear.");
25
27
  await opts.confirm(auth.decisionId, result);
26
28
  if (!confirmWarned) {
27
29
  confirmWarned = true;
@@ -29,7 +31,7 @@ export async function decidioGate(config, ctx, run, verdicts, opts = {}) {
29
31
  }
30
32
  }
31
33
  else {
32
- await gateConfirm(config, auth.decisionId, result);
34
+ await gateConfirm(config, auth.decisionId, result, opts?.evidence);
33
35
  }
34
36
  return result;
35
37
  }
@@ -15,4 +15,4 @@ export declare function canonicalize<T = JsonValue>(value: unknown, path?: strin
15
15
  export declare function canonicalizeArgs(args: readonly unknown[]): JsonValue[];
16
16
  /** RFC 8785-shaped canonical JSON of an already-canonical value — the exact bytes hashed for an
17
17
  * execution report, so both languages produce the same digest. */
18
- export declare function canonicalJson(value: JsonValue): string;
18
+ export declare function canonicalJson(value: JsonValue, path?: string): string;
package/dist/canonical.js CHANGED
@@ -114,15 +114,19 @@ function deepFreeze(v) {
114
114
  export function canonicalizeArgs(args) {
115
115
  return args.map((a, i) => deepFreeze(walk(a, `args[${i}]`, new Set())));
116
116
  }
117
- export function canonicalJson(value) {
117
+ export function canonicalJson(value, path = "canonicalJson(...)") {
118
118
  if (value === null)
119
119
  return "null";
120
120
  if (typeof value === "boolean" || typeof value === "number")
121
121
  return JSON.stringify(value);
122
- if (typeof value === "string")
122
+ if (typeof value === "string") {
123
+ assertNoLoneSurrogate(value, path);
123
124
  return JSON.stringify(value);
125
+ }
124
126
  if (Array.isArray(value))
125
- return `[${value.map(canonicalJson).join(",")}]`;
127
+ return `[${value.map((v, i) => canonicalJson(v, `${path}[${i}]`)).join(",")}]`;
126
128
  const keys = Object.keys(value).sort();
127
- return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`;
129
+ for (const k of keys)
130
+ assertNoLoneSurrogate(k, `${path} key`);
131
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k], `${path}.${k}`)}`).join(",")}}`;
128
132
  }
package/dist/cli.js CHANGED
@@ -258,6 +258,18 @@ async function cmdInit(agentId, opts = {}) {
258
258
  }
259
259
  const { did: localDid, privateKeyBase64: localKey } = generateKeypair();
260
260
  const registerBody = JSON.stringify({ agentId, did: localDid, ...(sourceSystem ? { sourceSystem } : {}) });
261
+ let envPrepared = false;
262
+ const prepareEnvForWrite = () => {
263
+ if (envPrepared)
264
+ return;
265
+ envPrepared = true;
266
+ ensureGitignore();
267
+ if (opts.force && existingKey && existingEnv.DECIDIO_AGENT_ID && existingEnv.DECIDIO_AGENT_ID !== agentId) {
268
+ const backup = `.env.${existingEnv.DECIDIO_AGENT_ID.replace(/[^\w.-]/g, "_")}.bak`;
269
+ writeSecretFile(backup, readFileSync(ENV_PATH, "utf8"));
270
+ log(`✔ backed the previous agent's .env up to ${backup} (its key would otherwise be unrecoverable)`);
271
+ }
272
+ };
261
273
  let session = null;
262
274
  let r = await api("/api/agents/register", { method: "POST", body: registerBody });
263
275
  if (r.status === 401 || r.status === 403) {
@@ -288,6 +300,8 @@ async function cmdInit(agentId, opts = {}) {
288
300
  did = r.body?.did;
289
301
  privateKey = localKey;
290
302
  log(`✔ registered (did ${did}) — key generated locally; only the public half went to Decidio`);
303
+ prepareEnvForWrite();
304
+ upsertEnv({ DECIDIO_AGENT_ID: agentId, DECIDIO_AGENT_DID: did ?? localDid, DECIDIO_AGENT_KEY: localKey });
291
305
  }
292
306
  else {
293
307
  err(`✘ registration failed (${r.status}): ${JSON.stringify(r.body)}`);
@@ -310,7 +324,7 @@ async function cmdInit(agentId, opts = {}) {
310
324
  }
311
325
  else if (session) {
312
326
  err(`✘ could not mint the agent's API token (${mint.status}): ${JSON.stringify(mint.body)}`);
313
- err(` Without it the SDK has no runtime credential. Fix the cause above and re-run init — it's safe to repeat.`);
327
+ err(` Without it the SDK has no runtime credential. The agent IS registered and its key is saved in ${ENV_PATH} — fix the cause above and re-run init; it will reuse that key and mint again.`);
314
328
  return 1;
315
329
  }
316
330
  else {
@@ -331,12 +345,7 @@ async function cmdInit(agentId, opts = {}) {
331
345
  envOut.DECIDIO_WORKSPACE_ID = workspaceId;
332
346
  if (tokenJti)
333
347
  envOut.DECIDIO_TOKEN_JTI = tokenJti;
334
- ensureGitignore();
335
- if (opts.force && existingKey && existingEnv.DECIDIO_AGENT_ID && existingEnv.DECIDIO_AGENT_ID !== agentId) {
336
- const backup = `.env.${existingEnv.DECIDIO_AGENT_ID.replace(/[^\w.-]/g, "_")}.bak`;
337
- writeSecretFile(backup, readFileSync(ENV_PATH, "utf8"));
338
- log(`✔ backed the previous agent's .env up to ${backup} (its key would otherwise be unrecoverable)`);
339
- }
348
+ prepareEnvForWrite();
340
349
  upsertEnv(envOut);
341
350
  for (const [k, v] of Object.entries(envOut))
342
351
  process.env[k] = v;
@@ -358,8 +367,9 @@ async function cmdInit(agentId, opts = {}) {
358
367
  log(` { mode: "blocking" }, // blocking = watch it happen live; durable is the production mode`);
359
368
  log(` );`);
360
369
  log("");
361
- log(` const result = await payInvoice({ id: "INV-2026-001", amount: 86_000 });`);
362
- log(` console.log("executed after approval:", result);`);
370
+ log(` const { value, confirmation } = await payInvoice({ id: "INV-2026-001", amount: 86_000 });`);
371
+ log(` console.log("executed after approval:", value);`);
372
+ log(` console.log("Decidio recorded it as:", confirmation.evidenceTier);`);
363
373
  log("");
364
374
  log(" 2. Run it, approve it, watch it complete:");
365
375
  log("");
@@ -411,7 +421,7 @@ async function cmdDoctor() {
411
421
  if (typeof payload?.exp === "number") {
412
422
  const msLeft = payload.exp * 1000 - Date.now();
413
423
  const days = Math.floor((msLeft + 60_000) / 86_400_000);
414
- const left = msLeft < 86_400_000 ? `${Math.max(1, Math.floor(msLeft / 3_600_000))} hour(s)` : `${days} day(s)`;
424
+ const left = msLeft < 86_400_000 ? `${Math.max(1, Math.floor(msLeft / 3_600_000))} hour(s)` : `${Math.round(msLeft / 86_400_000)} day(s)`;
415
425
  const daysRounded = Math.round(msLeft / 86_400_000);
416
426
  if (msLeft < 0)
417
427
  line(false, `agent token: EXPIRED ${-days} day(s) ago — re-run \`npx @decidio/sdk init ${env.DECIDIO_AGENT_ID ?? "<agent>"}\` to mint a fresh one.`);
@@ -577,12 +587,18 @@ async function cmdReceipt(id, jsonMode = false) {
577
587
  const detail = await api(`/api/records/${encodeURIComponent(id)}`, {}, bearer);
578
588
  const conf = detail.body?.record?.confirmation;
579
589
  const issuer = r.body?.issuer;
590
+ const resolved = r.body?.resolved;
580
591
  if (jsonMode) {
581
- log(JSON.stringify({ file: name, decisionId: id, evidenceTier: conf?.status ?? null, confirmed: !!conf?.confirmed, issuer: issuer ?? null }, null, 2));
592
+ log(JSON.stringify({ file: name, decisionId: id, evidenceTier: conf?.status ?? null, confirmed: !!conf?.confirmed, issuer: issuer ?? null, ...(resolved ? { resolved } : {}) }, null, 2));
582
593
  return 0;
583
594
  }
584
595
  if (conf?.status)
585
596
  log(` Sealed credential: the authority decision (its confirmation field reads "pending" forever — immutable). Current record evidence tier: ${conf.status}${conf.confirmed ? " (confirmed)" : ""}.`);
597
+ if (resolved && Object.keys(resolved).length) {
598
+ 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):`);
599
+ for (const [tok, val] of Object.entries(resolved))
600
+ log(` ${tok} → ${val}`);
601
+ }
586
602
  log(` Verify it offline — no Decidio account or network needed (the verifier is built in):`);
587
603
  if (issuer) {
588
604
  log(` npx @decidio/sdk verify --issuer ${issuer} ${name}`);
@@ -656,6 +672,10 @@ async function cmdQuickstart(opts = {}) {
656
672
  const session = await acquireSession("The quickstart");
657
673
  if (!session)
658
674
  return 1;
675
+ const envFile = readEnvFile().DECIDIO_AGENT_KEY ? ".env.quickstart" : ENV_PATH;
676
+ if (!assertNotTracked(envFile))
677
+ return 1;
678
+ ensureGitignore();
659
679
  let agentId = `quickstart-${randomBytes(5).toString("hex")}`;
660
680
  log(`① Registering agent "${agentId}"…`);
661
681
  const { did, privateKeyBase64 } = generateKeypair();
@@ -690,30 +710,36 @@ async function cmdQuickstart(opts = {}) {
690
710
  DECIDIO_WORKSPACE_ID: mint.body.workspaceId ?? "",
691
711
  ...(mint.body.jti ? { DECIDIO_TOKEN_JTI: mint.body.jti } : {}),
692
712
  };
693
- let envFile = ENV_PATH;
694
- if (readEnvFile().DECIDIO_AGENT_KEY) {
695
- envFile = ".env.quickstart";
696
- if (!assertNotTracked(envFile))
697
- return 1;
698
- ensureGitignore();
713
+ if (envFile !== ENV_PATH) {
699
714
  if (existsSync(envFile)) {
700
715
  const prev = readFileSync(envFile, "utf8");
701
716
  const prevId = prev.match(/^DECIDIO_AGENT_ID=(.+)$/m)?.[1];
717
+ const prevJti = prev.match(/^DECIDIO_TOKEN_JTI=(.+)$/m)?.[1];
702
718
  if (prevId && prevId !== agentId) {
703
- const bak = `.env.quickstart.${prevId.replace(/[^\w.-]/g, "_")}.bak`;
704
- writeSecretFile(bak, prev);
705
- log(` ✔ previous tour agent (${prevId}) backed up to ${bak} — its key would otherwise be unrecoverable (one backup PER agent: serial tours never clobber an earlier backup)`);
719
+ if (prevJti) {
720
+ const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(prevJti)}`, { method: "DELETE" }, session);
721
+ if (rv.status >= 200 && rv.status < 300)
722
+ log(` ✔ the previous tour agent (${prevId}) had its token REVOKED before this tour replaced it — no live credential is left behind`);
723
+ else
724
+ log(` ⚠ could not revoke the previous tour's token (jti ${prevJti}, ${rv.status}) — revoke it by hand: npx @decidio/sdk tokens revoke ${prevJti}`);
725
+ }
726
+ else {
727
+ log(` ⚠ the previous tour file carried no revocation id, so its token (if any) must be revoked from your Decidio app.`);
728
+ }
706
729
  }
707
730
  }
708
731
  writeSecretFile(envFile, Object.entries(envOut).map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
709
732
  log(` ✔ this directory already has an agent in .env — tour credentials went to ${envFile} (your agent is untouched)`);
710
733
  }
711
734
  else {
712
- if (!assertNotTracked(ENV_PATH))
713
- return 1;
714
- ensureGitignore();
715
735
  upsertEnv(envOut);
716
736
  }
737
+ const failTour = async (code) => {
738
+ log("");
739
+ log(" The tour did not complete — cleaning up the credentials it created:");
740
+ await quickstartCleanup(session, envFile).catch(() => { });
741
+ return code;
742
+ };
717
743
  log(` ✔ key generated locally (public half registered) · 7-day floor-limited token minted${mint.body.expiresAt ? ` (expires ${mint.body.expiresAt})` : ""} · ${envFile} written`);
718
744
  log("");
719
745
  log(`② Gating a demo action — the tour runs this (blocking mode: watch it live):`);
@@ -740,15 +766,15 @@ async function cmdQuickstart(opts = {}) {
740
766
  err(`✘ this workspace's governed-action allowance is used up${earlyError.limit != null ? ` (${earlyError.limit} actions/hour on lab workspaces)` : ""}.`);
741
767
  err(` Nothing was routed or executed — the cap is the sandbox protecting itself, not a fault${earlyError.resetAt ? `. It resets ${earlyError.resetAt}` : ""}.`);
742
768
  err(` Re-run the tour after the reset (or talk to us about a production workspace).`);
743
- return 1;
769
+ return failTour(1);
744
770
  }
745
771
  if (!decisionId && earlyError) {
746
772
  err(`✘ the gate refused before routing: ${earlyError?.message ?? earlyError}`);
747
- return 1;
773
+ return failTour(1);
748
774
  }
749
775
  if (!decisionId) {
750
776
  err("✘ the gate did not route within 30s — check connectivity (npx @decidio/sdk doctor) and re-run.");
751
- return 1;
777
+ return failTour(1);
752
778
  }
753
779
  log(` ✔ $86,000 payInvoice ROUTED to a human (decision ${decisionId}) — this agent has no auto-approve rule,`);
754
780
  log(` so deny-by-default sends everything to a person. Nothing executes until someone decides.`);
@@ -776,29 +802,35 @@ async function cmdQuickstart(opts = {}) {
776
802
  log("");
777
803
  log(" (exit 3 = the tour stopped at the human moment. Nothing executed and no receipt was");
778
804
  log(" written, so this run is deliberately not reported as a completed tour.)");
805
+ log("");
806
+ log(` NOTE: this tour's credentials are still live in ${envFile} (a 7-day token + the agent's`);
807
+ log(` key), because the decision above is still waiting for you. Once you have approved it —`);
808
+ log(` or if you abandon the tour — remove them: npx @decidio/sdk quickstart cleanup`);
779
809
  process.exit(3);
780
810
  }
781
811
  const ap = await api(`/api/decisions/${encodeURIComponent(decisionId)}/approve`, { method: "POST", body: JSON.stringify({ reason: opts.autoApproveDemo && !interactive ? "quickstart --auto-approve-demo (automated)" : "quickstart" }) }, session);
782
812
  if (!(ap.status >= 200 && ap.status < 300)) {
783
813
  err(`✘ approve failed (${ap.status}): ${JSON.stringify(ap.body)}`);
784
- return 1;
814
+ return failTour(1);
785
815
  }
786
816
  log(` ✔ approved${ap.body?.alreadyResolved ? " (it was already approved from your app — even better)" : ""}`);
787
- let result;
817
+ let done;
788
818
  try {
789
- result = await running;
819
+ done = await running;
790
820
  }
791
821
  catch (e) {
792
822
  err(`✘ the gated call did not complete: ${e?.message ?? e}`);
793
- return 1;
823
+ return failTour(1);
794
824
  }
795
- log(` ✔ your function executed AFTER approval: ${JSON.stringify(result)}`);
825
+ log(` ✔ your function executed AFTER approval: ${JSON.stringify(done.value)}`);
826
+ if (done.confirmation?.evidenceTier)
827
+ log(` …and the call itself told you what was recorded: confirmation.evidenceTier = ${done.confirmation.evidenceTier}`);
796
828
  log("");
797
829
  log("④ Downloading the sealed Authority Receipt and verifying it OFFLINE…");
798
830
  const rec = await api(`/api/records/${encodeURIComponent(decisionId)}/verify`, {}, session);
799
831
  if (!(rec.status >= 200 && rec.status < 300) || !rec.body?.credential) {
800
832
  err(`✘ receipt fetch failed (${rec.status})`);
801
- return 1;
833
+ return failTour(1);
802
834
  }
803
835
  const fname = `decidio-receipt-${decisionId.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
804
836
  writeFileSync(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
@@ -838,9 +870,8 @@ async function cmdQuickstart(opts = {}) {
838
870
  doCleanup = true;
839
871
  log(`\nAutomated run: cleaning the tour up (pass --keep-agent to keep the agent + token).`);
840
872
  }
841
- if (doCleanup)
842
- await quickstartCleanup(session, envFile);
843
- return v.valid ? 0 : 1;
873
+ const cleanupCode = doCleanup ? await quickstartCleanup(session, envFile) : 0;
874
+ return v.valid ? (cleanupCode || 0) : 1;
844
875
  }
845
876
  async function quickstartCleanup(session, envFile) {
846
877
  const env = (() => {
@@ -1009,6 +1040,8 @@ const USAGE = `decidio — the Decidio SDK command line
1009
1040
  at the routed decision; --auto-approve-demo for CI, labeled
1010
1041
  automated; ends with a keep-or-cleanup choice)
1011
1042
  npx @decidio/sdk quickstart cleanup revoke the tour's token + delete its local credentials
1043
+ npx @decidio/sdk quickstart --auto-approve-demo headless tour; cleans up after itself
1044
+ (add --keep-agent to KEEP the tour agent + token)
1012
1045
  npx @decidio/sdk tokens list [--mine] agent tokens with status (active/expired/REVOKED) by
1013
1046
  jti — token values are never re-shown; the list is
1014
1047
  workspace-wide, --mine narrows to this directory's agent
package/dist/errors.d.ts CHANGED
@@ -1,8 +1,3 @@
1
- /** A gate response this SDK could not accept, carrying what a caller needs to BRANCH on rather
2
- * than a stringified body they would have to parse. `status` is the HTTP status; `retryable`
3
- * distinguishes a terminal refusal (401/403 — a credential problem no retry fixes) from a
4
- * transient one (5xx, 429). Every long-running agent hits the terminal case eventually: an
5
- * agent token expires, or `init` is re-run and revokes it. */
6
1
  export declare class DecidioHttpError extends Error {
7
2
  status: number;
8
3
  endpoint: string;
@@ -10,3 +5,15 @@ export declare class DecidioHttpError extends Error {
10
5
  readonly retryable: boolean;
11
6
  constructor(status: number, endpoint: string, body?: unknown | undefined);
12
7
  }
8
+ /** ONE rule for what counts as a usable decision id, returning a REASON rather than throwing, so
9
+ * every door can apply the same check and raise its own error type. (`resume.ts` cannot import
10
+ * `index.ts`, which is why the check was copy-pasted into four places and drifted.)
11
+ *
12
+ * All four copies tested truthiness only, so `" "` passed every one of them: the blocking path
13
+ * would poll `/agent/status/%20%20%20`, the durable path would park under a whitespace key, and
14
+ * the confirmation would attest a decision nobody can look up. Control characters are refused for
15
+ * the same reason a reason-string is sanitised — an id goes into a URL path, a filename and an
16
+ * audit line. Nothing is silently trimmed: an id that needs trimming is not the id the server
17
+ * sent, and quietly rewriting an identifier is how two systems end up disagreeing about which
18
+ * decision they mean. */
19
+ export declare function decisionIdProblem(raw: unknown): string | null;