@decidio/sdk 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,8 +1,41 @@
1
1
  # Changelog — @decidio/sdk
2
2
 
3
- Release notes for the client SDK. The Python package `decidio` ships the same changes under the same
4
- version (the two are conformance-locked twins). Each entry names what changed, what a reader must do
5
- on upgrade, and what proved it.
3
+ Release notes for the client SDK. The Python package `decidio` ships under the same version; the core -
4
+ the gate, signing, receipts and the verifier - is conformance-locked between the two, while the command line's
5
+ first-run surfaces (the fork, `--demo`, the code sign-in) are TypeScript-first and named as such in the parity
6
+ allowlist. Each entry names what changed, what a reader must do on upgrade, and what proved it.
7
+
8
+ ## 0.6.4 — the request closes when the agent gives up
9
+
10
+ - **The README links the agent skill file** published on the site (https://decidioai.com/skills/decidio-guard.md, one page for both languages) and the site's llms.txt. The file is served by the site, not shipped in the package; an in-package copy is a one-line publish-boundary decision for this release if wanted.
11
+ - **A blocking wait that hits `pollTimeoutMs` now WITHDRAWS its request** (`POST /agent/withdraw/<id>`
12
+ `{reason:"timeout"}`) before throwing `DecidioTimeoutError`. Before, the decision stayed open in Decidio
13
+ forever after this process had stopped waiting, so the queue carried a request nobody could act on and a
14
+ later tap sealed "approved" for an action that never ran. Now it leaves the queue as *withdrawn* and a
15
+ later approval is a no-op (`alreadyResolved`). `DecidioTimeoutError.withdrawn` says whether the call
16
+ landed; a failed withdraw changes nothing else (nothing executes either way). Withdrawing needs the
17
+ bound agent token and the signing key `init` writes — the gate refuses anyone else with 404, so an
18
+ unbound or unsigned caller simply sees `withdrawn:false`. If a human decided in the last poll window,
19
+ the gate answers 409 with that decision and the SDK honours it: the action runs as approved (and
20
+ confirms), or `DecidioRejectedError` is thrown — never a timeout that hides a decision. The durable
21
+ path is untouched — its parked action is exactly what a later approval resumes. The CLI tour and the
22
+ `--demo` stand-in say which of these happened. Nothing to do on upgrade.
23
+ Proved by: `smoke.ts` (the timeout vector now asserts exactly one withdraw after the last poll, and a
24
+ failing withdraw still ends in the same error with `withdrawn:false`; a 409 carrying `approved` runs and
25
+ confirms, `rejected` rejects; a wait that ends in a decision never withdraws) and, server-side,
26
+ `integration.smoke.ts` #W2 (a lab-posture boot with real bound tokens: another agent's token, an
27
+ anonymous caller and an unsigned receipt all get 404; the owner's later approve is `alreadyResolved`
28
+ with no sealed record; a second withdraw → 409) and #W (IdP posture: anonymous and IdP users → 404).
29
+
30
+ ## 0.6.3 — the two 0.6.2 reviews' small items
31
+
32
+ - **`DECIDIO_DEBUG_INPUT=1` no longer prints what you type** (review: Medium). The 0.6.2 trace hex-dumped every stdin
33
+ byte - passwords, codes and links, muted prompt or not. It now prints the SHAPE only: control bytes named (`\r`, `\n`,
34
+ ESC), every printable byte as `x`. A newline problem is visible in the shape alone.
35
+ - Four strings still described the sign-in as an emailed link to paste; they say the code now. The CHANGELOG's "twins"
36
+ line overstated CLI parity and now says exactly what is locked and what is TypeScript-first. Two README headings
37
+ still said "0.4". The offline local demo no longer prints "the terminal follows the web app or your phone" when it
38
+ has just said there are no such doors. `receipt --json` help says it prints a summary; the credential is the file.
6
39
 
7
40
  ## 0.6.2 — the fork after its first real run
8
41
 
package/README.md CHANGED
@@ -23,7 +23,7 @@ you approve right there (or from your Decidio app), executes on approval, then d
23
23
  sealed Authority Receipt and verifies it offline in front of you, pinned to your workspace's
24
24
  issuer. It mints a **7-day** tour token (`init` mints 90-day tokens — different jobs, different
25
25
  lifetimes) and ends by offering to clean itself up (`quickstart cleanup` works later too).
26
- Sign in once — `npx @decidio/sdk login`, or when the tour asks: the CLI emails you a link, you paste it, and
26
+ Sign in once — `npx @decidio/sdk login`, or when the tour asks: the CLI emails you an 8-character code, you type it, and
27
27
  the sign-in is kept in your profile (owner-only) for 30 days, or until this terminal closes, `logout`, or
28
28
  `quickstart cleanup`. At the human step the tour offers three doors: answer here, open the decision in
29
29
  the web app (a one-time link, signed in for you), or scan the QR with your phone — whichever answers
@@ -660,7 +660,7 @@ that fits every other confirm error.
660
660
  - **The token recovery journal** (`.decidio-token-journal`, owner-only, gitignored; ids and timestamps, never a token value; append-only by the CLI's behaviour — not OS-enforced or tamper-evident). One JSON row per event: `minted` (a token was issued, written BEFORE its credential file), `retired how=persisted` (the credential file now holds it - the token is live and that file is its handle; not revoked), `retired how=revoked` (revoked server-side), `probe` (a durability check). `doctor` names rows still outstanding; `quickstart cleanup` sweeps them.
661
661
  - `quickstart --demo` — the same tour with no account: an in-process stand-in of the API on 127.0.0.1 (shipped in this package) answers every door, a key generated for the run signs a real receipt (its content says `local-demo`; pinning it to the hosted issuer fails), and the web/phone door is a hosted demo decision page reached through a one-word relay (no account, no record, ten minutes). Your folder gains exactly one file, the receipt. Offline, the tour is terminal-only and says so.
662
662
  - `quickstart --phone web` — the QR at the human step opens the phone web rendering even when the Decidio app is installed (by default it opens the app when there is one).
663
- - `login [email]` / `logout` — sign in ONCE (the CLI emails you a link; you paste it) and forget that sign-in on this computer (the session token itself stays valid until it expires (up to 30 days) — there is no server-side revocation yet). The session is stored in your user profile, owner-only, bound to the terminal window that created it, for 30 days; `DECIDIO_SESSION_TOKEN` in the environment outranks it (headless shells).
663
+ - `login [email]` / `logout` — sign in ONCE (the CLI emails you an 8-character code; you type it) and forget that sign-in on this computer (the session token itself stays valid until it expires (up to 30 days) — there is no server-side revocation yet). The session is stored in your user profile, owner-only, bound to the terminal window that created it, for 30 days; `DECIDIO_SESSION_TOKEN` in the environment outranks it (headless shells).
664
664
  - `dev [--target URL] [--port N]` — local relay for the resume webhook (operator setups)
665
665
 
666
666
  The CLI's stream contract: **stdout carries results only** (safe to pipe — `--json` output is
@@ -680,7 +680,7 @@ manager for production.
680
680
  - `DecidioBlockedError` — policy blocked the action: a named block rule, or the universal backstop that fires when NO active policy governs agents at all (fail closed, no human involved). Distinct from routing-by-default — with an active policy and no matching auto-approve rule, a request is not blocked, it is ROUTED to a person.
681
681
  - `DecidioRejectedError` — a human rejected the routed action.
682
682
  - `DecidioSuspendedError` — (durable mode) the action was routed and is parked for async approval; the process may exit and resume later. Not a failure.
683
- - `DecidioTimeoutError` — (blocking mode only) no human decided within `pollTimeoutMs` (default 10 min); the decision stays open in Decidio, nothing executed locally.
683
+ - `DecidioTimeoutError` — (blocking mode only) no human decided within `pollTimeoutMs` (default 10 min); nothing executed locally. Since 0.6.4 the SDK's last act before throwing is to tell Decidio it gave up, so the request is closed as **withdrawn** (it leaves the queue; a later approval does nothing) — `error.withdrawn` is `true` when that landed and `false` when the call failed and the request may still be open. Withdrawing needs the bound agent token and the signing key `init` writes (an unbound or unsigned caller cannot withdraw anything — it just gets `withdrawn:false`). If a human decided in the last poll window, the SDK honours that decision instead: the action runs as approved, or `DecidioRejectedError` is thrown. The durable path never withdraws: its parked action is what a later approval resumes.
684
684
  - `DecidioRateLimitError` — the workspace's governed-action limit was hit; carries the server's `limit` / `remaining` / `resetAt`.
685
685
  - `DecidioHttpError` — the gate refused the call. Carries `status`, `endpoint`, the parsed `body`,
686
686
  and **`retryable`** — the field to branch on. A 401/403 is terminal (the agent token expired, or
@@ -703,7 +703,7 @@ Every error raised after a decision exists carries the `decisionId`, so a suspen
703
703
  timed-out action is always findable in
704
704
  your queue.
705
705
 
706
- ## What 0.4 does not yet claim
706
+ ## What the SDK does not yet claim
707
707
 
708
708
  The retest that graded 0.3.3 *A- for the core and the shipped stores* and the first-run reviews of
709
709
  0.3.3 and 0.3.5 agree on what is left, and none of it is a defect a patch release closes. Stated here so
@@ -729,7 +729,7 @@ the next reader grades a promise, not a moving target:
729
729
 
730
730
  ## Compatibility and support
731
731
 
732
- - **Stable in 0.4:** `guard.protect` / `withApproval` and their options; the durable resume
732
+ - **Stable surface:** `guard.protect` / `withApproval` and their options; the durable resume
733
733
  controller (`createDecidioResume`: `park`, `resolve`, `reconcile`, `reconfirm`, `recover`, the worker
734
734
  and both webhook handlers); the `PendingStore` contract and both shipped stores, including the on-disk
735
735
  revision-chain layout; `EvidenceOutcome` (a root export) and `ResumeStatus` / `RecoveryState` (on the
@@ -744,6 +744,7 @@ the next reader grades a promise, not a moving target:
744
744
 
745
745
  ## Notes
746
746
 
747
+ - **Coding agents and assistants:** a one-page skill file for adding the gate to an agent — TypeScript and Python on the same page, what the gate does and does not do, the wrapper, first run vs production, the typed errors, how to verify a receipt — is published at https://decidioai.com/skills/decidio-guard.md (the site summary for machines is https://decidioai.com/llms.txt).
747
748
  - **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).
748
749
  - **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.
749
750
  - **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.
package/dist/cli.js CHANGED
@@ -501,7 +501,11 @@ function ask(question, mute = false) {
501
501
  });
502
502
  }
503
503
  if (process.env.DECIDIO_DEBUG_INPUT && process.stdin.isTTY) {
504
- process.stdin.on("data", (c) => { process.stderr.write(`[stdin ${Buffer.from(c).toString("hex")}]\n`); });
504
+ process.stdin.on("data", (c) => {
505
+ const b = Buffer.from(c);
506
+ const shape = [...b].map((x) => x === 0x0d ? "\\r" : x === 0x0a ? "\\n" : x === 0x1b ? "ESC" : x < 0x20 ? `^${String.fromCharCode(x + 64)}` : x === 0x7f ? "DEL" : "x").join("");
507
+ process.stderr.write(`[stdin ${b.length}B ${shape}]\n`);
508
+ });
505
509
  }
506
510
  function terminalId() {
507
511
  for (const k of ["WT_SESSION", "VSCODE_IPC_HOOK_CLI", "TERM_SESSION_ID", "ITERM_SESSION_ID", "TMUX_PANE", "KONSOLE_DBUS_SESSION", "GNOME_TERMINAL_SCREEN", "WINDOWID"]) {
@@ -575,7 +579,7 @@ async function loginFlow(purpose, emailArg) {
575
579
  log(" ✘ no email given - re-run when you have it.");
576
580
  return null;
577
581
  }
578
- const password = await ask(" password (Enter = emailed sign-in link): ", true);
582
+ const password = await ask(" password (Enter = emailed sign-in code): ", true);
579
583
  if (password) {
580
584
  const r = await api("/demo/login", { method: "POST", body: JSON.stringify({ email, password }) }, "");
581
585
  if (r.status >= 200 && r.status < 300 && r.body?.token) {
@@ -1554,7 +1558,8 @@ async function cmdQuickstartInner(opts = {}) {
1554
1558
  }
1555
1559
  else if (doorLink)
1556
1560
  rows.push(`${bold("your phone")} ${dim("the link above works on a phone too (no QR came back this time)")}`);
1557
- rows.push(dim("whichever answers first wins — the terminal follows the web app or your phone."));
1561
+ if (!DEMO || doorLink)
1562
+ rows.push(dim("whichever answers first wins — the terminal follows the web app or your phone."));
1558
1563
  block("③", "Your call", rows, "choice");
1559
1564
  let settledState = null;
1560
1565
  const settledP = running.then(() => { settledState = "approved"; }, (e) => { settledState = e instanceof DecidioRejectedError ? "rejected" : "other"; });
@@ -1605,8 +1610,12 @@ async function cmdQuickstartInner(opts = {}) {
1605
1610
  log(`③ the tour's blocking call gave up after 5 minutes while waiting for your answer, so your answer is NOT posted:`);
1606
1611
  if (DEMO)
1607
1612
  log(` the demo ends here; nothing is kept (the demo's agent, token and folder go now). Re-run and answer within 5 minutes.`);
1613
+ else if (earlyError.withdrawn) {
1614
+ log(` the request was WITHDRAWN from your queue (it no longer waits for an answer; deciding it there does nothing). Re-run the tour and answer within 5 minutes.`);
1615
+ log(` NOTE: this tour's credentials are still live in ${envFile} - remove them: npx @decidio/sdk quickstart cleanup`);
1616
+ }
1608
1617
  else {
1609
- log(` the request is still ROUTED in your queue - decide it there, or re-run the tour and answer within 5 minutes.`);
1618
+ log(` the request is still ROUTED in your queue (the withdraw call did not land) - decide it there, or re-run the tour and answer within 5 minutes.`);
1610
1619
  log(` NOTE: this tour's credentials are still live in ${envFile} - remove them: npx @decidio/sdk quickstart cleanup`);
1611
1620
  }
1612
1621
  log("");
@@ -2186,7 +2195,9 @@ APPROVALS AND RECEIPTS
2186
2195
  If it starts with a dash, put it after -- or use --reason:
2187
2196
  ... approve <id> -- "-50% cut, per the board"
2188
2197
  ... approve <id> --reason "-50% cut, per the board"
2189
- npx @decidio/sdk receipt <id> [--json] download a sealed decision's Authority Receipt (.json)
2198
+ npx @decidio/sdk receipt <id> [--json] download a sealed decision's Authority Receipt to a .json FILE; --json prints
2199
+ a summary to stdout (file name, evidence, issuer, the identities you may see) -
2200
+ the credential itself is the file
2190
2201
  npx @decidio/sdk verify --issuer <did> <receipt.json> [more...] verify receipts OFFLINE, pinned to your
2191
2202
  workspace's issuer (--allow-unpinned: structural check only)
2192
2203
 
package/dist/demo-gate.js CHANGED
@@ -35,6 +35,7 @@ export async function startDemoGate(opts = {}) {
35
35
  const issuerDid = "did:key:z" + b58(Buffer.concat([Buffer.from([0xed, 0x01]), issuerRaw]));
36
36
  const sealedAt = new Date().toISOString();
37
37
  let verdict = null;
38
+ let withdrawnDemo = false;
38
39
  let decidedBy = "the human at the terminal";
39
40
  let lastReason = "quickstart";
40
41
  let relayCode = null;
@@ -121,8 +122,16 @@ export async function startDemoGate(opts = {}) {
121
122
  lastReason = "decided on the demo page";
122
123
  }
123
124
  }
125
+ if (withdrawnDemo && !verdict)
126
+ return send({ state: "rejected", reason: "quickstart: withdrawn — the tour stopped waiting" });
124
127
  return send(verdict === "reject" ? { state: "rejected", reason: "quickstart: rejected by the approver" } : { state: verdict === "approve" ? "approved" : "pending" });
125
128
  }
129
+ if (path.startsWith("/agent/withdraw/")) {
130
+ if (verdict)
131
+ return send({ code: "NOT_OPEN", state: verdict === "approve" ? "approved" : "rejected", decisionId: DECISION_ID }, 409);
132
+ withdrawnDemo = true;
133
+ return send({ ok: true, withdrawn: true, state: "rejected", decisionId: DECISION_ID, reason: "requester_stopped_waiting" });
134
+ }
126
135
  if (/^\/api\/decisions\/[^/]+\/(approve|reject)$/.test(path)) {
127
136
  const already = verdict !== null;
128
137
  verdict = path.endsWith("/reject") ? "reject" : "approve";
package/dist/guard.js CHANGED
@@ -65,8 +65,8 @@ export class Guard {
65
65
  if ((opts?.mode ?? "durable") === "blocking") {
66
66
  const ceilingMs = opts?.pollTimeoutMs ?? base.pollTimeoutMs ?? 600_000;
67
67
  console.warn(`[decidio] WARNING: blocking wait — this call gives up after ${Math.round(ceilingMs / 1000)}s ` +
68
- `(pollTimeoutMs), and when it does, the decision stays OPEN in Decidio while this process stops waiting: ` +
69
- `a later approval cannot resume it. Only safe for short, watched approvals — omit mode:"blocking" for ` +
68
+ `(pollTimeoutMs), and when it does, this process stops waiting and the request is withdrawn in Decidio: ` +
69
+ `a later approval does nothing. Only safe for short, watched approvals — omit mode:"blocking" for ` +
70
70
  `durable async resume on real (minutes-to-days) approvals.`);
71
71
  return withApproval(fn, describe, base, { ...(opts?.evidence ? { evidence: opts.evidence } : {}), ...(opts?.withContext ? { withContext: true } : {}) });
72
72
  }
package/dist/index.d.ts CHANGED
@@ -82,7 +82,12 @@ export declare class DecidioRejectedError extends Error {
82
82
  }
83
83
  export declare class DecidioTimeoutError extends Error {
84
84
  decisionId: string;
85
- constructor(decisionId: string);
85
+ withdrawn: boolean;
86
+ /** `withdrawn` (0.6.4+): true when the SDK told Decidio it gave up and the request was closed as
87
+ * WITHDRAWN (the queue no longer carries it; a later approval does nothing); false when that
88
+ * best-effort call failed, so the request may still be open in Decidio. Nothing was executed
89
+ * locally either way. */
90
+ constructor(decisionId: string, withdrawn?: boolean);
86
91
  }
87
92
  /** A transport failure with GUIDANCE instead of a raw fetch stack. Carries the endpoint, the
88
93
  * operation, the underlying code, and the safety state a caller cares about most: whether
package/dist/index.js CHANGED
@@ -31,9 +31,11 @@ export class DecidioRejectedError extends Error {
31
31
  }
32
32
  export class DecidioTimeoutError extends Error {
33
33
  decisionId;
34
- constructor(decisionId) {
34
+ withdrawn;
35
+ constructor(decisionId, withdrawn = false) {
35
36
  super(`Timed out waiting for human approval (${decisionId})`);
36
37
  this.decisionId = decisionId;
38
+ this.withdrawn = withdrawn;
37
39
  this.name = "DecidioTimeoutError";
38
40
  }
39
41
  }
@@ -192,8 +194,24 @@ export function withApproval(fn, describe, config, opts) {
192
194
  if (st.state === "blocked")
193
195
  throw new DecidioBlockedError(st.reason ?? "blocked", auth.decisionId);
194
196
  if (Date.now() > deadline) {
195
- console.warn(`[decidio] blocking wait hit its ${Math.round(pollTimeout / 1000)}s limit — decision ${auth.decisionId} is still open in Decidio, but this process stopped waiting and nothing was executed. Re-run to request again, or use durable resume (guard.worker()) so an approval is never lost.`);
196
- throw new DecidioTimeoutError(auth.decisionId);
197
+ let withdrawn = false;
198
+ let lateState = null;
199
+ try {
200
+ await post(config, `/agent/withdraw/${encodeURIComponent(auth.decisionId)}`, { reason: "timeout" });
201
+ withdrawn = true;
202
+ }
203
+ catch (e) {
204
+ if (e instanceof DecidioHttpError && e.status === 409 && typeof e.body?.state === "string")
205
+ lateState = e.body.state;
206
+ }
207
+ if (lateState === "approved" || lateState === "auto_approved")
208
+ break;
209
+ if (lateState === "rejected")
210
+ throw new DecidioRejectedError("rejected by approver", auth.decisionId);
211
+ if (lateState === "blocked")
212
+ throw new DecidioBlockedError("blocked", auth.decisionId);
213
+ console.warn(`[decidio] blocking wait hit its ${Math.round(pollTimeout / 1000)}s limit — decision ${auth.decisionId} ${withdrawn ? "was withdrawn in Decidio (the queue no longer shows it; a later approval does nothing)" : "is still open in Decidio (the withdraw call failed), but this process stopped waiting"} and nothing was executed. Re-run to request again, or use durable resume (guard.worker()) so an approval is never lost.`);
214
+ throw new DecidioTimeoutError(auth.decisionId, withdrawn);
197
215
  }
198
216
  await sleep(pollInterval);
199
217
  }
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.6.2 # tracks the SDK release - checked by packages/conformance/manifests.mjs
8
+ version: 0.6.4 # tracks the SDK release - checked by packages/conformance/manifests.mjs
9
9
  description: >
10
10
  Authorize an AI-agent action (proceed | route | block), confirm the captured outcome, and
11
11
  receive the signed resume webhook when a routed action is decided. Every outcome seals a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decidio/sdk",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "One-line approval gate for AI-agent actions. Wrap a risky tool call with guard.protect(); Decidio's policy engine decides proceed | route | block, a human approves routed actions, and every outcome is sealed as a verifiable Authority Receipt.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -84,7 +84,7 @@
84
84
  "prepublishOnly": "npm run typecheck && npm run build && node ../publish-boundary/check.mjs --only @decidio/sdk"
85
85
  },
86
86
  "dependencies": {
87
- "@decidio/verify": "0.6.2"
87
+ "@decidio/verify": "0.6.4"
88
88
  },
89
89
  "author": "OmniTwin Technologies Inc.",
90
90
  "bugs": {