@decidio/sdk 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  One line to put a human-or-policy approval gate in front of any AI-agent action — and get a signed, verifiable Authority Receipt for every decision, with execution evidence accruing on the record.
4
4
 
5
+ `@decidio/sdk` is the client for Decidio — the system of record for enterprise authority, from OmniTwin Technologies.
6
+
5
7
  ## The two-minute tour
6
8
 
7
9
  ```bash
@@ -21,15 +23,17 @@ prints the approve command and exits rather than approve its own request (CI can
21
23
 
22
24
  ## Quickstart — seven steps to your first sealed receipt
23
25
 
24
- Under 30 minutes, no help needed. You'll need Node 20.6+, a terminal, and a Decidio workspace
25
- (request one at [decidioai.com](https://decidioai.com/#early-access) — the hosted sandbox is
26
- synthetic data, safe to experiment in — and it is ONE SHARED workspace: workspace-wide views
27
- like `tokens list` or `approvals` show every lab member's agents and decisions there, which is
28
- the shared-lab boundary working as designed; production workspaces are per-tenant). Lab
29
- workspaces have a **governed-action allowance**
30
- (currently 30 actions/hour); hitting it raises a typed `DecidioRateLimitError` naming the limit
31
- and reset time — nothing is routed or executed — and `tokens list` shows your remaining
32
- allowance any time.
26
+ Under 30 minutes, no help needed. You'll need Node 20.6+ and a terminal.
27
+
28
+ **You'll need a Decidio workspace.** → Request one at [decidioai.com](https://decidioai.com/#early-access)
29
+
30
+ The hosted sandbox is synthetic data, safe to experiment in. Note it is ONE SHARED workspace:
31
+ workspace-wide views like `tokens list` or `approvals` show every lab member's agents and
32
+ decisions — the shared-lab boundary working as designed. Production workspaces are per-tenant.
33
+
34
+ Lab workspaces have a **governed-action allowance** (currently 30 actions/hour); hitting it raises
35
+ a typed `DecidioRateLimitError` naming the limit and reset time — nothing is routed or executed —
36
+ and `tokens list` shows your remaining allowance any time.
33
37
 
34
38
  **PowerShell users** — the equivalents for every Bash idiom used below:
35
39
 
@@ -382,10 +386,30 @@ for (const item of await guard.resume.recover()) {
382
386
  console.log(item.state, item.decisionId, item.guidance);
383
387
  }
384
388
  // "executed_unreported" → guard.resume.reconfirm(id) re-sends the saved report (never re-runs)
385
- // "indeterminate" → a process died mid-flight; reconcile downstream, do NOT blind-retry
389
+ // "indeterminate" → no outcome was recorded; check downstream, then reconcile. Never blind-retry
386
390
  // "parked" → still waiting on a human; nothing has executed
387
391
  ```
388
392
 
393
+ Two things `reconcile()` will refuse, because both would undo an authority decision rather than
394
+ record one. **It refuses by returning, not by throwing** — check `reportRecorded`, and read
395
+ `diagnostic` to see why:
396
+
397
+ ```js
398
+ // A denial is final. There is no outcome to record — nothing ran — so neither outcome is accepted.
399
+ const r = await guard.resume.reconcile(deniedId, { outcome: "not_committed" });
400
+ r.reportRecorded; // false — r.diagnostic says the decision was DENIED
401
+
402
+ // A decision that is still `invoking` may be running the action at this moment. Releasing it would
403
+ // let a second worker run the same approved action, so it takes an explicit assertion — from you,
404
+ // after you have checked. Nothing here can see another machine's process table, so this is your
405
+ // judgement, and it is stamped into the ledger as such.
406
+ await guard.resume.reconcile(id, { outcome: "not_committed", reason: "…" }); // refused while invoking
407
+ await guard.resume.reconcile(id, { outcome: "not_committed", reason: "…", workerStopped: true }); // released
408
+ ```
409
+
410
+ `indeterminate` needs no such flag: the handler has already returned or thrown, so nothing is in
411
+ flight — only the downstream result is unknown.
412
+
389
413
  The report a worker sends is written to the pending store's execution ledger *before* the first
390
414
  send attempt, so a lost confirmation is recoverable; and because that record holds no handler
391
415
  binding, replaying it can only re-send evidence — never re-run your function. The ledger lives in
@@ -420,6 +444,53 @@ imports none of them — install the engine you use alongside the SDK (`npm i in
420
444
  for engines you don't. Each adapter's shipped `.d.ts` header documents its wiring; `npx
421
445
  @decidio/sdk quickstart` is the fastest runnable end-to-end.
422
446
 
447
+
448
+ ## Upgrading from 0.2.x to 0.3.0
449
+
450
+ 0.3.0 is a breaking release. Every item below changes behaviour a 0.2.x caller may depend on, so
451
+ none of it is left to be discovered at runtime.
452
+
453
+ **If you wrote a custom `PendingStore`, it will fail to construct.** `markDiscarded(decisionId,
454
+ verdict, reason?)` is now REQUIRED — it writes the tombstone that makes a denied decision
455
+ permanently unclaimable, and a store that cannot record a denial cannot uphold the guarantee, so
456
+ this is refused at construction rather than deep inside a resolve. `delete` also takes an optional
457
+ owning attempt (`delete(id, attemptId?)`) so a superseded attempt cannot clean up the live one's
458
+ payload, and `settle` takes `workerStopped`. The shipped stores show the shape, and
459
+ `packages/conformance/vectors/ownership.json` is the corpus your store should pass.
460
+
461
+ **`recover()` now throws instead of returning `[]`** when the ledger cannot be read
462
+ (`DecidioRecoveryUnavailableError`). `[]` and "I cannot tell you what you owe" are the same value
463
+ to a caller and mean opposite things, so a script reading `if (!(await recover()).length)` treated
464
+ a permissions change as proof that nothing was outstanding. Wrap the call if you poll it.
465
+
466
+ **`reconcile()` refuses two things it used to allow.** A DENIED decision cannot be reconciled on
467
+ either outcome, and releasing a decision that is still `invoking` takes an explicit
468
+ `workerStopped: true` — that is the one state where the handler may be running right now, and
469
+ releasing it hands one approved action to two live workers. It refuses by RETURNING, not throwing:
470
+ check `reportRecorded` and read `diagnostic`.
471
+
472
+ **The adapters return a different shape.** `decidioGate` (LangGraph, Temporal) returns
473
+ `ProtectedExecution` — `{ value, decisionId, confirmation }` — instead of the bare value.
474
+ `decidioGateStep` (Inngest) renames `result` to `value` and carries `confirmation`.
475
+ `gateInterruptions` is now `authorizeInterruptions` and returns `confirmationSupported: false`
476
+ (the old name remains as a deprecated alias). Read `.value` where you previously read the result.
477
+
478
+ **The transport rule refuses URLs it used to accept.** A gate URL must now be absolute and either
479
+ https or an exact loopback host, whatever credentials the config holds. A relative or scheme-less
480
+ `apiUrl` throws at startup — it used to pass on the reasoning that the shipped transport rejects
481
+ it, which says nothing about a custom `fetchImpl`.
482
+
483
+ **Decision ids are shape-checked.** Letters, digits, dot, dash and underscore, starting with a
484
+ letter or digit, at most 128 characters, never trimmed. The server enforces the same grammar (it
485
+ had none before 0.3.0, which is why the SDK was the stricter of the two). Ids Decidio issues are
486
+ well inside it.
487
+
488
+ **`/agent/confirm` can answer 409.** `TERMINALLY_DENIED` means a human refused the decision and
489
+ your action appears to have run anyway; `NOT_AUTHORIZED` means there is no approved, sealed
490
+ decision for evidence to attach to. Neither is a report failure and neither is retryable — the SDK
491
+ raises `DecidioHttpError` with advice that says so, rather than the "only its report failed" note
492
+ that fits every other confirm error.
493
+
423
494
  ## Engine adapters (BETA) — and Python too
424
495
 
425
496
  - **Engine adapters are BETA.** They share the core's gate semantics — the verdict allow-list,
@@ -430,6 +501,17 @@ for engines you don't. Each adapter's shipped `.d.ts` header documents its wirin
430
501
  runtimes. Use the core path for production until that changes. Each adapter is bound through its
431
502
  own `@decidio/sdk/<engine>` subpath (they need the engine handle at call time); no engine at all?
432
503
  The worker/webhook resume is the universal path and is not beta.
504
+ - **One result shape on every door (0.3.0, breaking).** `decidioGate` (LangGraph, Temporal) and
505
+ `decidioGateStep` (Inngest) now return `{ value, decisionId, confirmation }` — the same
506
+ `ProtectedExecution` the core path returns. They used to hand back the bare value and discard what
507
+ Decidio recorded, so "it ran and was recorded" and "it ran and Decidio does not know" were the
508
+ same return value to a caller. Inngest keeps its `status`, because its durable path can end in
509
+ `rejected` or `blocked` without ever calling your action; its value field is `value`, not
510
+ `result`.
511
+ The OpenAI door is the exception and now says so: it was renamed `gateInterruptions` →
512
+ `authorizeInterruptions` and returns `confirmationSupported: false`. The OpenAI runtime executes
513
+ the tool itself, so this SDK never sees the return value and has nothing to attest — and an
514
+ ABSENT confirmation must not look like a failed one. The old name remains as a deprecated alias.
433
515
  - **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.
434
516
 
435
517
  ## CLI
@@ -495,3 +577,12 @@ your queue.
495
577
  - **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.
496
578
  - **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.
497
579
  - **API surface:** the agent-gate REST contract this SDK speaks is described in [`openapi.yaml`](./openapi.yaml), shipped in the package.
580
+
581
+ ## Using this for real?
582
+
583
+ The hosted sandbox is synthetic and shared. Production workspaces are per-tenant, with your own
584
+ issuer key and receipts that verify offline against it.
585
+
586
+ [decidioai.com](https://decidioai.com) · [Developer docs](https://decidioai.com/developers/) · [Request access](https://decidioai.com/#early-access)
587
+
588
+ Built by OmniTwin Technologies.
@@ -1,5 +1,5 @@
1
1
  import { Command } from "@langchain/langgraph";
2
- import { type GuardConfig, type ApprovalContext } from "../index.js";
2
+ import { type GuardConfig, type ApprovalContext, type ProtectedExecution } from "../index.js";
3
3
  /**
4
4
  * @decidio/sdk/langgraph — durable suspend via LangGraph's native `interrupt()`.
5
5
  * `interrupt` finds the running graph itself (no handle needed), so the gate drops into
@@ -13,7 +13,7 @@ import { type GuardConfig, type ApprovalContext } from "../index.js";
13
13
  /** Gate `run()` behind Decidio inside a LangGraph node. */
14
14
  export declare function decidioGate<R>(config: GuardConfig, ctx: ApprovalContext, run: () => Promise<R>, opts?: {
15
15
  evidence?: (r: R) => unknown;
16
- }): Promise<R>;
16
+ }): Promise<ProtectedExecution<R>>;
17
17
  /** Translate Decidio's signed resume webhook into the LangGraph resume Command. The host
18
18
  * maps decisionId → thread_id and passes this to graph.invoke/stream. */
19
19
  export declare function decidioResumeCommand(body: {
@@ -10,8 +10,8 @@ export async function decidioGate(config, ctx, run, opts) {
10
10
  assertVerdict(verdictFrom(resumed), auth.decisionId, resumed?.reason);
11
11
  }
12
12
  const result = await run();
13
- await gateConfirm(config, auth.decisionId, result, opts?.evidence);
14
- return result;
13
+ const confirmation = await gateConfirm(config, auth.decisionId, result, opts?.evidence);
14
+ return { value: result, decisionId: auth.decisionId, confirmation };
15
15
  }
16
16
  export function decidioResumeCommand(body) {
17
17
  return new Command({ resume: { verdict: body.verdict, reason: body.reason } });
@@ -34,13 +34,23 @@ export interface Pending {
34
34
  item: ToolApprovalItem;
35
35
  decisionId: string;
36
36
  }
37
- /** Ask Decidio for each pending tool-approval on a RunState.
38
- * proceed → state.approve(item); block → state.reject(item); route → pending (a human
39
- * decides via Decidio; the host parks state.toString() + resumes via applyResume). */
40
- export declare function gateInterruptions(config: GuardConfig, state: RunStateLike, describe: (item: ToolApprovalItem) => ApprovalContext): Promise<{
37
+ /** THIS DOOR AUTHORIZES; IT DOES NOT CONFIRM. Named `gateInterruptions` it looked like the other
38
+ * `decidioGate*` functions, which all run the action and report what happened. This one cannot:
39
+ * the OpenAI Agents runtime executes the tool itself, so the SDK never sees the return value and
40
+ * has nothing to attest. Every other adapter now returns a `confirmation`; this one deliberately
41
+ * returns `confirmationSupported: false` instead of an empty one, because an absent confirmation
42
+ * and a failed confirmation must not look alike.
43
+ *
44
+ * proceed → state.approve(item); block → state.reject(item); route → pending (a human decides via
45
+ * Decidio; the host parks state.toString() + resumes via applyResume). */
46
+ export declare function authorizeInterruptions(config: GuardConfig, state: RunStateLike, describe: (item: ToolApprovalItem) => ApprovalContext): Promise<{
41
47
  resolved: Resolved[];
42
48
  pending: Pending[];
49
+ confirmationSupported: false;
43
50
  }>;
51
+ /** @deprecated Renamed to `authorizeInterruptions` in 0.3.0 — the old name read like the other
52
+ * `decidioGate*` doors, which execute and confirm. This one only ever authorized. */
53
+ export declare const gateInterruptions: typeof authorizeInterruptions;
44
54
  /** Apply a Decidio verdict to a resumed RunState (after RunState.fromString).
45
55
  *
46
56
  * VALIDATES BEFORE MUTATING. `else state.reject(item)` treated every unrecognised string as a
@@ -1,6 +1,6 @@
1
1
  import { gateAuthorize, checkEngineVersion } from "./_gate.js";
2
2
  import { DecidioBlockedError } from "../index.js";
3
- export async function gateInterruptions(config, state, describe) {
3
+ export async function authorizeInterruptions(config, state, describe) {
4
4
  const resolved = [];
5
5
  const pending = [];
6
6
  for (const item of state.getInterruptions()) {
@@ -18,8 +18,9 @@ export async function gateInterruptions(config, state, describe) {
18
18
  else
19
19
  throw new DecidioBlockedError(`unhandled authorization verdict ${JSON.stringify(auth.decision)}`, auth.decisionId);
20
20
  }
21
- return { resolved, pending };
21
+ return { resolved, pending, confirmationSupported: false };
22
22
  }
23
+ export const gateInterruptions = authorizeInterruptions;
23
24
  export function applyResume(state, item, verdict) {
24
25
  if (verdict === "approved" || verdict === "auto_approved") {
25
26
  state.approve(item);
@@ -1,4 +1,4 @@
1
- import { type GuardConfig, type ApprovalContext } from "../index.js";
1
+ import { type GuardConfig, type ApprovalContext, type ProtectedExecution } from "../index.js";
2
2
  import { type GateAuth } from "./_gate.js";
3
3
  /**
4
4
  * @decidio/sdk/temporal — durable suspend via Temporal's native `condition()` + a
@@ -32,4 +32,4 @@ export interface DecidioGateOpts<R = unknown> {
32
32
  export declare function decidioGate<R>(config: GuardConfig, ctx: ApprovalContext, run: () => Promise<R>, verdicts: Map<string, {
33
33
  verdict: string;
34
34
  reason?: string;
35
- }>, opts?: DecidioGateOpts<R>): Promise<R>;
35
+ }>, opts?: DecidioGateOpts<R>): Promise<ProtectedExecution<R>>;
@@ -21,18 +21,23 @@ export async function decidioGate(config, ctx, run, verdicts, opts = {}) {
21
21
  throw new DecidioBlockedError(`unhandled authorization verdict ${JSON.stringify(auth.decision)} — refusing to execute`, auth.decisionId);
22
22
  }
23
23
  const result = await run();
24
+ let confirmation;
24
25
  if (opts.confirm) {
25
26
  if (opts.evidence)
26
27
  console.warn("[decidio] temporal: `evidence` is ignored when you supply `confirm` — your callback owns the report, so map the result inside it. Remove one of the two to make the intent clear.");
27
28
  await opts.confirm(auth.decisionId, result);
29
+ confirmation = {
30
+ reportRecorded: false, executionConfirmed: false, confirmationRetryable: false, reportJournalled: false,
31
+ diagnostic: "your own `confirm` callback reported this execution, so this SDK cannot read back what Decidio recorded. Check the record, or return the gate's response body from your activity and validate it with readConfirmResponse.",
32
+ };
28
33
  if (!confirmWarned) {
29
34
  confirmWarned = true;
30
35
  console.warn(`[decidio] temporal: a custom \`confirm\` reports through your own activity, so this SDK cannot read back the evidence tier Decidio recorded. Check the record (or return the gate's response body from your activity and validate it with readConfirmResponse) if you need to know whether the execution was CONFIRMED, not merely reported.`);
31
36
  }
32
37
  }
33
38
  else {
34
- await gateConfirm(config, auth.decisionId, result, opts?.evidence);
39
+ confirmation = await gateConfirm(config, auth.decisionId, result, opts?.evidence);
35
40
  }
36
- return result;
41
+ return { value: result, decisionId: auth.decisionId, confirmation };
37
42
  }
38
43
  checkEngineVersion("temporal");
package/dist/cli.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync } from "node:fs";
3
3
  import { randomBytes } from "node:crypto";
4
- import { userInfo } from "node:os";
5
4
  import { createServer } from "node:http";
6
5
  import { execFileSync } from "node:child_process";
7
6
  import { createInterface } from "node:readline";
8
7
  import { createRequire } from "node:module";
9
8
  import { generateKeypair, assertSafeTransport } from "./signing.js";
9
+ import { hardenFile } from "./resume.js";
10
10
  import { withApproval, DecidioRateLimitError, didFromPrivateKey } from "./index.js";
11
11
  import { verifyReceipt, runVerify } from "@decidio/verify";
12
12
  const VERSION = (() => {
@@ -39,6 +39,14 @@ function readEnvFile() {
39
39
  return out;
40
40
  }
41
41
  const OFFLINE_CMD = ["verify", "--version", "-v", "-V", "version", "--help", "-h", "help", undefined].includes(process.argv[2]);
42
+ if (!OFFLINE_CMD) {
43
+ readEnvFile();
44
+ if (envUnreadable) {
45
+ console.error(`✘ .env exists here but could not be read, so this command cannot know what is already configured in this directory.`);
46
+ console.error(` Treating it as empty would risk overwriting an agent key that cannot be recovered. Fix the file's permissions (or move it aside) and run the command again. Nothing was changed.`);
47
+ process.exit(1);
48
+ }
49
+ }
42
50
  if (!OFFLINE_CMD)
43
51
  for (const [k, v] of Object.entries(readEnvFile())) {
44
52
  if (k === "DECIDIO_SESSION_TOKEN") {
@@ -106,21 +114,9 @@ function writeSecretFile(path, body) {
106
114
  chmodSync(path, 0o600);
107
115
  }
108
116
  catch { }
109
- if (process.platform === "win32") {
110
- try {
111
- execFileSync("icacls", [path, "/inheritance:r", "/grant:r", `${effectiveUser()}:F`], { stdio: "ignore" });
112
- }
113
- catch {
114
- console.warn(`⚠ could not restrict ${path} to owner-only on this Windows volume — it keeps the directory's inherited permissions. For production, move these values to a secret manager.`);
115
- }
116
- }
117
- }
118
- function effectiveUser() {
119
- try {
120
- return userInfo().username;
121
- }
122
- catch {
123
- return process.env.USERNAME ?? process.env.USER ?? "";
117
+ const why = hardenFile(path);
118
+ if (why) {
119
+ console.warn(`⚠ could not restrict ${path} to owner-only on this Windows volume (${why}) — it keeps the directory's inherited permissions. For production, move these values to a secret manager.`);
124
120
  }
125
121
  }
126
122
  function upsertEnv(updates) {
@@ -720,8 +716,11 @@ async function cmdQuickstart(opts = {}) {
720
716
  const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(prevJti)}`, { method: "DELETE" }, session);
721
717
  if (rv.status >= 200 && rv.status < 300)
722
718
  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}`);
719
+ else {
720
+ const carried = [...(prev.match(/^DECIDIO_ORPHANED_TOKEN_JTI=(.+)$/m)?.[1] ?? "").split(",").filter(Boolean), prevJti];
721
+ envOut.DECIDIO_ORPHANED_TOKEN_JTI = [...new Set(carried)].join(",");
722
+ log(` ⚠ could not revoke the previous tour's token (jti ${prevJti}, ${rv.status}) — it is still LIVE. Its id is kept in ${envFile} as DECIDIO_ORPHANED_TOKEN_JTI, and \`quickstart cleanup\` will retry it; or revoke it by hand: npx @decidio/sdk tokens revoke ${prevJti}`);
723
+ }
725
724
  }
726
725
  else {
727
726
  log(` ⚠ the previous tour file carried no revocation id, so its token (if any) must be revoked from your Decidio app.`);
@@ -859,6 +858,7 @@ async function cmdQuickstart(opts = {}) {
859
858
  log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
860
859
  log("");
861
860
  log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")} · Next: the README's 7-step quickstart (durable, workers, adapters).`);
861
+ log(`Built by OmniTwin — decidioai.com · Putting this in front of real approvals? decidioai.com/#early-access`);
862
862
  let doCleanup = false;
863
863
  if (interactive) {
864
864
  const answer = await ask(`\nKeep this agent for development? [Enter = keep · c + Enter = clean up now]: `);
@@ -884,6 +884,23 @@ async function quickstartCleanup(session, envFile) {
884
884
  }
885
885
  return out;
886
886
  })();
887
+ const orphans = (env.DECIDIO_ORPHANED_TOKEN_JTI ?? "").split(",").map((s) => s.trim()).filter(Boolean);
888
+ const stillOrphaned = [];
889
+ for (const jti of orphans) {
890
+ const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, session);
891
+ if (rv.status >= 200 && rv.status < 300)
892
+ log(` ✔ orphaned token revoked (jti ${jti}) — it had survived an earlier replace`);
893
+ else {
894
+ stillOrphaned.push(jti);
895
+ err(` ⚠ orphaned token ${jti} could NOT be revoked (${rv.status}) — it is still live.`);
896
+ }
897
+ }
898
+ if (stillOrphaned.length) {
899
+ err(`✘ ${stillOrphaned.length} orphaned token(s) are STILL LIVE and could not be revoked. Nothing was deleted; ${envFile} keeps their ids so you can retry:`);
900
+ for (const jti of stillOrphaned)
901
+ err(` npx @decidio/sdk tokens revoke ${jti}`);
902
+ return 1;
903
+ }
887
904
  if (env.DECIDIO_TOKEN_JTI) {
888
905
  const rev = await api(`/api/workspace/agent-token/${encodeURIComponent(env.DECIDIO_TOKEN_JTI)}`, { method: "DELETE" }, session);
889
906
  if (rev.status >= 200 && rev.status < 300 && rev.body?.changed === false)
@@ -906,7 +923,7 @@ async function quickstartCleanup(session, envFile) {
906
923
  return 1;
907
924
  }
908
925
  if (existsSync(envFile)) {
909
- const TOUR_KEYS = new Set(["DECIDIO_API_URL", "DECIDIO_AGENT_ID", "DECIDIO_AGENT_DID", "DECIDIO_AGENT_KEY", "DECIDIO_API_TOKEN", "DECIDIO_WORKSPACE_ID", "DECIDIO_TOKEN_JTI"]);
926
+ const TOUR_KEYS = new Set(["DECIDIO_API_URL", "DECIDIO_AGENT_ID", "DECIDIO_AGENT_DID", "DECIDIO_AGENT_KEY", "DECIDIO_API_TOKEN", "DECIDIO_WORKSPACE_ID", "DECIDIO_TOKEN_JTI", "DECIDIO_ORPHANED_TOKEN_JTI"]);
910
927
  const kept = readFileSync(envFile, "utf8").split(/\r?\n/).filter((line) => {
911
928
  const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)=/);
912
929
  return m ? !TOUR_KEYS.has(m[1]) : line.trim() !== "";
package/dist/errors.js CHANGED
@@ -1,6 +1,12 @@
1
1
  const RETRYABLE_STATUS = (status) => status === 408 || status === 429 || (status >= 500 && status !== 501);
2
- function sideEffectNote(endpoint) {
2
+ function sideEffectNote(endpoint, code) {
3
3
  if (endpoint.startsWith("/agent/confirm")) {
4
+ if (code === "TERMINALLY_DENIED") {
5
+ return " A human DENIED this decision, and your action appears to have run anyway. Decidio recorded the refusal and will NOT attach execution evidence to a denied decision. Do NOT re-run it — investigate why the action executed without an approval.";
6
+ }
7
+ if (code === "NOT_AUTHORIZED") {
8
+ return " This decision is not an approved, sealed decision, so no execution evidence can attach to it yet. If your action already ran, it ran WITHOUT authority — do NOT re-run it; investigate. If you are confirming early, wait for the approval and confirm then.";
9
+ }
4
10
  return " Your action ALREADY RAN — only its report failed. Do NOT re-run it.";
5
11
  }
6
12
  if (endpoint.startsWith("/agent/status")) {
@@ -22,7 +28,7 @@ export class DecidioHttpError extends Error {
22
28
  : retryable
23
29
  ? ` — this looks transient; retrying is safe (a retry creates a NEW request and can never double-execute).`
24
30
  : ` — the server refused the request itself, so retrying it unchanged will fail the same way. Fix the request (or the decision it names) first.`) +
25
- sideEffectNote(endpoint));
31
+ sideEffectNote(endpoint, typeof body?.code === "string" ? body.code : undefined));
26
32
  this.status = status;
27
33
  this.endpoint = endpoint;
28
34
  this.body = body;
@@ -44,7 +50,9 @@ export function decisionIdProblem(raw) {
44
50
  if (cp < 0x20 || cp === 0x7f)
45
51
  return "it contains control characters";
46
52
  }
47
- if (raw.length > 256)
48
- return `it is ${raw.length} characters long (limit 256)`;
53
+ if (raw.length > 128)
54
+ return `it is ${raw.length} characters long (limit 128)`;
55
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(raw))
56
+ return "it is not letters, digits, dot, dash or underscore starting with a letter or digit (an opaque id needs nothing else, and every other character is a way to make two different strings look like the same decision)";
49
57
  return null;
50
58
  }
package/dist/evidence.js CHANGED
@@ -32,20 +32,22 @@ export function readConfirmResponse(body, opts) {
32
32
  if (!EVIDENCE_TIERS.includes(tier)) {
33
33
  return { reportRecorded: false, executionConfirmed: false, confirmationRetryable: false, reportJournalled: journalled, diagnostic: `the gate reported an evidence tier this SDK does not recognise (${JSON.stringify(tier)}) — refusing to treat it as evidence. Upgrade @decidio/sdk; your action DID run, and ${retry}` };
34
34
  }
35
- const executionConfirmed = CONFIRMING.includes(tier);
35
+ const rpt = body?.executionReport;
36
+ const hasVerdict = !!rpt && typeof rpt === "object" && typeof rpt.verified === "boolean";
37
+ const proofExplicitlyFailed = hasVerdict && rpt.verified === false;
38
+ const executionConfirmed = CONFIRMING.includes(tier) && !proofExplicitlyFailed;
36
39
  const outcome = { reportRecorded: true, evidenceTier: tier, executionConfirmed, reportJournalled: journalled };
37
- if (!executionConfirmed && opts.signed)
40
+ if (!executionConfirmed && !proofExplicitlyFailed && opts.signed)
38
41
  outcome.diagnostic = describeTierGap(opts.capturedResponse);
39
- else if (!executionConfirmed)
42
+ else if (!executionConfirmed && !proofExplicitlyFailed)
40
43
  outcome.diagnostic = "no signing key is configured, so the gate capped this report at an unproven agent assertion. Run `npx @decidio/sdk init` to generate the agent's key and earn a confirming tier.";
41
- const rpt = body?.executionReport;
42
- if (rpt && typeof rpt === "object" && typeof rpt.verified === "boolean") {
44
+ if (hasVerdict) {
43
45
  outcome.executionReportVerified = rpt.verified;
44
46
  if (typeof rpt.reason === "string" && rpt.reason)
45
47
  outcome.executionReportReason = rpt.reason;
46
- if (!rpt.verified) {
48
+ if (proofExplicitlyFailed) {
47
49
  const why = typeof rpt.reason === "string" && rpt.reason ? ` (${rpt.reason})` : "";
48
- outcome.diagnostic = `${outcome.diagnostic ? outcome.diagnostic + " " : ""}The signed execution report did NOT verify${why} — the report was recorded without a proven attestation. Check the agent's key/DID/registration (npx @decidio/sdk doctor).`;
50
+ outcome.diagnostic = `the signed execution report did NOT verify${why}, so this is NOT a confirmed execution — the gate reported tier "${tier}", and this SDK refuses to read an unproven attestation as confirmation. Your action DID run; check the agent's key/DID/registration (npx @decidio/sdk doctor), then ${retry}`;
49
51
  }
50
52
  }
51
53
  return outcome;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ResumeController } from "./resume.js";
2
2
  import { type EvidenceOutcome } from "./evidence.js";
3
- export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
3
+ export { DecidioSuspendedError, DecidioDeniedError, DecidioRecoveryUnavailableError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
4
4
  export type { PendingAction, PendingStore, ResumeController, ResumeOutcome } from "./resume.js";
5
5
  export { guard, Guard } from "./guard.js";
6
6
  export type { GuardFacadeConfig, ProtectOptions } from "./guard.js";
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import { DecidioSuspendedError } from "./resume.js";
2
2
  import { signBoundProof, signConfirm, assertSafeTransport, carriesCredential } from "./signing.js";
3
3
  import { canonicalize, canonicalizeArgs } from "./canonical.js";
4
4
  import { readConfirmResponse, buildConfirmBody } from "./evidence.js";
5
- export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
5
+ export { DecidioSuspendedError, DecidioDeniedError, DecidioRecoveryUnavailableError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
6
6
  export { guard, Guard } from "./guard.js";
7
7
  export { DecidioHttpError, decisionIdProblem } from "./errors.js";
8
8
  import { DecidioHttpError, decisionIdProblem } from "./errors.js";
package/dist/inngest.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { RequestListener } from "node:http";
2
2
  import { verifyResumeSignature } from "./resume.js";
3
- import type { GuardConfig, ApprovalContext } from "./index.js";
3
+ import type { GuardConfig, ApprovalContext, EvidenceOutcome } from "./index.js";
4
4
  /** The Inngest event Decidio's verdict is translated into. The waiting function
5
5
  * correlates on `data.decisionId`. */
6
6
  export declare const DECIDIO_RESUME_EVENT = "decidio/decision.resolved";
@@ -24,10 +24,18 @@ export interface InngestClientLike {
24
24
  data: Record<string, any>;
25
25
  }): Promise<unknown>;
26
26
  }
27
+ /** ONE RESULT SHAPE ACROSS EVERY DOOR (F-008), with the one field this door genuinely needs on
28
+ * top of it. `status` stays because Inngest's durable path can legitimately end in `rejected` or
29
+ * `blocked` without ever calling your action — the other adapters signal that by throwing. What
30
+ * changed: the value is `value` (not `result`) so it reads the same as every other door, and
31
+ * `confirmation` is CARRIED rather than discarded. It used to be thrown away inside the confirm
32
+ * step, which made "it ran and Decidio recorded it" and "it ran and Decidio does not know"
33
+ * indistinguishable to the caller. */
27
34
  export type GateStepOutcome<R> = {
28
35
  status: "executed" | "rejected" | "blocked";
29
36
  decisionId: string;
30
- result?: R;
37
+ value?: R;
38
+ confirmation?: EvidenceOutcome;
31
39
  };
32
40
  /**
33
41
  * Gate an action INSIDE an Inngest function, with durable suspend on routing.
package/dist/inngest.js CHANGED
@@ -22,8 +22,8 @@ export async function decidioGateStep(args) {
22
22
  return { status: "blocked", decisionId: auth.decisionId };
23
23
  }
24
24
  const result = await step.run("agent-action", args.run);
25
- await step.run("decidio-confirm", async () => { await gateConfirm(config, auth.decisionId, result, args.evidence); return true; });
26
- return { status: "executed", decisionId: auth.decisionId, result };
25
+ const confirmation = await step.run("decidio-confirm", async () => gateConfirm(config, auth.decisionId, result, args.evidence));
26
+ return { status: "executed", decisionId: auth.decisionId, value: result, confirmation };
27
27
  }
28
28
  export function createInngestResumeBridge(opts) {
29
29
  const secret = opts.webhookSecret ?? "";