@decidio/sdk 0.1.10 → 0.1.11

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/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { execFileSync } from "node:child_process";
7
7
  import { createInterface } from "node:readline";
8
8
  import { createRequire } from "node:module";
9
9
  import { generateKeypair } from "./signing.js";
10
- import { withApproval, DecidioRateLimitError } from "./index.js";
10
+ import { withApproval, DecidioRateLimitError, didFromPrivateKey } from "./index.js";
11
11
  import { verifyReceipt, runVerify } from "@decidio/verify";
12
12
  const VERSION = (() => {
13
13
  try {
@@ -18,6 +18,7 @@ const VERSION = (() => {
18
18
  }
19
19
  })();
20
20
  let envWarned = false;
21
+ let envUnreadable = false;
21
22
  function readEnvFile() {
22
23
  const out = {};
23
24
  try {
@@ -29,9 +30,10 @@ function readEnvFile() {
29
30
  }
30
31
  }
31
32
  catch (e) {
33
+ envUnreadable = true;
32
34
  if (!envWarned) {
33
35
  envWarned = true;
34
- console.error(`⚠ could not read .env (${e?.code ?? e?.message ?? e}) — continuing without it. Fix its permissions if you meant it to load.`);
36
+ console.error(`⚠ could not read .env (${e?.code ?? e?.message ?? e}).`);
35
37
  }
36
38
  }
37
39
  return out;
@@ -58,6 +60,11 @@ async function api(path, init = {}, bearer) {
58
60
  err(`· decidio → ${API}${process.env.DECIDIO_API_URL ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
59
61
  }
60
62
  const auth = bearer ?? TOKEN();
63
+ if (auth && envUnreadable && !process.env.DECIDIO_API_URL) {
64
+ err(`✘ .env exists here but could not be read, so the target Decidio is unknown — refusing to send credentials to a guessed host (${API}).`);
65
+ err(` Fix the file's permissions, or set DECIDIO_API_URL explicitly for this command. Nothing was sent.`);
66
+ process.exit(1);
67
+ }
61
68
  if (auth && /^http:[/][/]/i.test(API) && !/^http:[/][/](localhost|127.0.0.1|.::1.)([:/]|$)/i.test(API)) {
62
69
  err(`✘ refusing to send credentials over plaintext http to ${API} — use https, or localhost for local dev. Nothing was sent.`);
63
70
  process.exit(1);
@@ -137,6 +144,28 @@ function upsertEnv(updates) {
137
144
  lines.push(`${k}=${v}`);
138
145
  writeSecretFile(ENV_PATH, lines.join("\n") + "\n");
139
146
  }
147
+ function isTrackedByGit(path) {
148
+ try {
149
+ execFileSync("git", ["ls-files", "--error-unmatch", path], { stdio: "ignore" });
150
+ return true;
151
+ }
152
+ catch {
153
+ return false;
154
+ }
155
+ }
156
+ function assertNotTracked(path) {
157
+ if (!isTrackedByGit(path))
158
+ return true;
159
+ err(`✘ ${path} is ALREADY TRACKED by git — refusing to write the agent's private key into it.`);
160
+ err(` A .gitignore entry does not untrack a file: the next \`git add -A\` would commit`);
161
+ err(` DECIDIO_AGENT_KEY and DECIDIO_API_TOKEN into permanent history. Nothing was written.`);
162
+ err(` Fix it, then re-run:`);
163
+ err(` git rm --cached ${path} # stop tracking it (keeps your local copy)`);
164
+ err(` git commit -m "stop tracking ${path}"`);
165
+ err(` If it has ALREADY been committed with secrets in it, rotate them — history rewriting`);
166
+ err(` alone does not un-leak a key that was pushed.`);
167
+ return false;
168
+ }
140
169
  function ensureGitignore() {
141
170
  const entries = [".env", ".env.quickstart", ".env.*.bak", ".decidio-pending/"];
142
171
  const existing = existsSync(".gitignore") ? readFileSync(".gitignore", "utf8") : "";
@@ -144,7 +173,7 @@ function ensureGitignore() {
144
173
  if (!missing.length)
145
174
  return;
146
175
  appendFileSync(".gitignore", (existing && !existing.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n");
147
- log(`✔ added ${missing.join(" + ")} to .gitignore (your agent key and parked actions stay out of git)`);
176
+ log(`✔ added ${missing.join(" + ")} to .gitignore (new secret files here will not be committed)`);
148
177
  }
149
178
  function ask(question, mute = false) {
150
179
  return new Promise((resolve) => {
@@ -204,6 +233,8 @@ async function acquireSession(purpose) {
204
233
  return null;
205
234
  }
206
235
  async function cmdInit(agentId, opts = {}) {
236
+ if (!assertNotTracked(ENV_PATH))
237
+ return 1;
207
238
  err(`Registering agent "${agentId}" with Decidio at ${API}…`);
208
239
  const existingEnv = readEnvFile();
209
240
  const existingKey = existingEnv.DECIDIO_AGENT_KEY;
@@ -342,8 +373,20 @@ async function cmdDoctor() {
342
373
  log(secret ? "✔ webhook secret: set — operator-only transport (inactive against the hosted sandbox; the poll worker guard.worker() is the self-serve resume path)"
343
374
  : "• webhook secret: not set — only needed for the operator resume-webhook transport; the poll worker guard.worker() is the self-serve resume path (init generates one)");
344
375
  const signing = (process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY) && (process.env.DECIDIO_AGENT_DID || env.DECIDIO_AGENT_DID);
345
- if (signing)
346
- log("✔ signing key: set — the agent can prove its identity, making it ELIGIBLE for policy auto-approve. Whether a rule matches is your workspace policy's decision; with no matching rule every request routes to a human (deny-by-default).");
376
+ if (signing) {
377
+ const storedDid = (process.env.DECIDIO_AGENT_DID || env.DECIDIO_AGENT_DID);
378
+ let derived;
379
+ try {
380
+ derived = didFromPrivateKey((process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY));
381
+ }
382
+ catch { }
383
+ if (!derived)
384
+ line(false, "signing key: PRESENT BUT UNREADABLE — DECIDIO_AGENT_KEY is not a valid Ed25519 private key. Re-run `npx @decidio/sdk init` (its public half is what Decidio registered).");
385
+ else if (derived !== storedDid)
386
+ line(false, `signing key: DOES NOT MATCH this agent's DID — the key here derives ${derived}, but .env registers ${storedDid}. Signed requests will not be recognised as this agent (auto-approve and application_confirmed evidence will both fail). Restore the original key, or re-run \`npx @decidio/sdk init\` to register a fresh one.`);
387
+ else
388
+ log("✔ signing key: set and MATCHES the registered DID — the agent can prove its identity, making it ELIGIBLE for policy auto-approve. Whether a rule matches is your workspace policy's decision; with no matching rule every request routes to a human (deny-by-default).");
389
+ }
347
390
  else
348
391
  warn("signing key: MISSING (DECIDIO_AGENT_KEY/DID) — the agent can't prove identity, so EVERY request routes to a human. Run `npx @decidio/sdk init` to generate one.");
349
392
  const h = await api("/api/health", {}, "");
@@ -366,7 +409,7 @@ async function cmdDoctor() {
366
409
  else if (days <= 14)
367
410
  warn(`agent token: expires in ${left} — mint a fresh one before it does: npx @decidio/sdk init ${env.DECIDIO_AGENT_ID ?? "<agent>"}`);
368
411
  else
369
- log(`✔ agent token: valid for another ${days} days`);
412
+ log(`✔ agent token: unexpired for another ${days} days (validity is decided by the server — see the token-scope check below)`);
370
413
  }
371
414
  }
372
415
  catch { }
@@ -396,6 +439,11 @@ async function cmdApprovals(sub, id, reason, flags = {}) {
396
439
  sub = undefined;
397
440
  if ((sub === "approve" || sub === "reject") && !id) {
398
441
  err(`usage: npx @decidio/sdk approvals ${sub} <decisionId> [reason]`);
442
+ err(` npx @decidio/sdk approvals ${sub} <decisionId> --reason "text with -dashes"`);
443
+ return 2;
444
+ }
445
+ if ((sub === "approve" || sub === "reject") && (flags.json || flags.mine)) {
446
+ err(`✘ ${flags.json ? "--json" : "--mine"} applies to \`approvals\` (the list), not to ${sub} — refusing to ignore a flag you meant to matter.`);
399
447
  return 2;
400
448
  }
401
449
  if (mineOnly && !process.env.DECIDIO_AGENT_ID) {
@@ -422,8 +470,18 @@ async function cmdApprovals(sub, id, reason, flags = {}) {
422
470
  return 1;
423
471
  }
424
472
  log(`✔ ${sub === "approve" ? "approved" : "rejected"} ${id}${r.body?.resumeSignal ? ` — agent signalled (${r.body.resumeSignal.ok ? "ok" : r.body.resumeSignal.error})` : ""}`);
425
- if (sub === "approve")
426
- log(` The agent's function runs next — a blocking call resumes now; a durable one when its worker or resume route picks this up.`);
473
+ if (reason) {
474
+ log(` reason sent verbatim: ${JSON.stringify(reason)}`);
475
+ log(` (it seals immutably; personal or sensitive values are tokenized at rest, so the`);
476
+ log(` stored text can differ — see exactly what was kept: npx @decidio/sdk receipt ${id})`);
477
+ }
478
+ if (sub === "approve") {
479
+ log(` What happens next depends on how the agent is waiting:`);
480
+ log(` • durable (the production mode): its worker or resume route picks this up and executes.`);
481
+ log(` • blocking, still running: it resumes now.`);
482
+ log(` • blocking, already timed out or exited: NOTHING executes — that process is gone, and this`);
483
+ log(` approval resolved the RECORD only. Re-run the action to request again.`);
484
+ }
427
485
  else
428
486
  log(` The agent's action will NOT run; the rejection seals as a verifiable record.`);
429
487
  log(` Download the receipt: npx @decidio/sdk receipt ${id}`);
@@ -517,11 +575,13 @@ async function cmdReceipt(id, jsonMode = false) {
517
575
  if (conf?.status)
518
576
  log(` Sealed credential: the authority decision (its confirmation field reads "pending" forever — immutable). Current record evidence tier: ${conf.status}${conf.confirmed ? " (confirmed)" : ""}.`);
519
577
  log(` Verify it offline — no Decidio account or network needed (the verifier is built in):`);
520
- log(` npx @decidio/sdk verify ${name}`);
521
578
  if (issuer) {
522
- log(` Your workspace's issuer DID (from the authenticated response — record it once, then pin it forever):`);
523
- log(` ${issuer}`);
524
579
  log(` npx @decidio/sdk verify --issuer ${issuer} ${name}`);
580
+ log(` That pins the check to your workspace's issuer DID (read from the authenticated`);
581
+ log(` response above — record it once, then pin it forever):`);
582
+ log(` ${issuer}`);
583
+ log(` Without --issuer the verifier proves the receipt is internally consistent, but NOT who`);
584
+ log(` issued it; it says so in its output.`);
525
585
  }
526
586
  else {
527
587
  log(` To also prove WHO issued it, pin your workspace's issuer DID (shown in your app's record verify panel):`);
@@ -536,8 +596,8 @@ function cmdVerify(argsIn) {
536
596
  const a = argsIn[i];
537
597
  if (a === "--issuer") {
538
598
  const v = argsIn[i + 1];
539
- if (v === undefined || v.startsWith("-")) {
540
- err("✘ --issuer needs a did:key value.");
599
+ if (v === undefined || v === "" || v.startsWith("-")) {
600
+ err("✘ --issuer needs a did:key value (a bare or EMPTY --issuer must not silently verify unpinned).");
541
601
  return 2;
542
602
  }
543
603
  expectedIssuer = v;
@@ -609,6 +669,8 @@ async function cmdQuickstart(opts = {}) {
609
669
  let envFile = ENV_PATH;
610
670
  if (readEnvFile().DECIDIO_AGENT_KEY) {
611
671
  envFile = ".env.quickstart";
672
+ if (!assertNotTracked(envFile))
673
+ return 1;
612
674
  ensureGitignore();
613
675
  if (existsSync(envFile)) {
614
676
  const prev = readFileSync(envFile, "utf8");
@@ -623,6 +685,8 @@ async function cmdQuickstart(opts = {}) {
623
685
  log(` ✔ this directory already has an agent in .env — tour credentials went to ${envFile} (your agent is untouched)`);
624
686
  }
625
687
  else {
688
+ if (!assertNotTracked(ENV_PATH))
689
+ return 1;
626
690
  ensureGitignore();
627
691
  upsertEnv(envOut);
628
692
  }
@@ -762,8 +826,12 @@ async function quickstartCleanup(session, envFile) {
762
826
  log(` • token was already revoked (jti ${env.DECIDIO_TOKEN_JTI}) — nothing to do.`);
763
827
  else if (rev.status >= 200 && rev.status < 300)
764
828
  log(` ✔ token revoked (jti ${env.DECIDIO_TOKEN_JTI})`);
765
- else
766
- log(` ⚠ token revoke returned ${rev.status} — it still expires on its own within 7 days. List tokens: npx @decidio/sdk tokens list`);
829
+ else {
830
+ err(`✘ token revoke returned ${rev.status} — the tour's token is STILL LIVE (it would expire on its own within 7 days).`);
831
+ err(` Nothing was deleted: ${envFile} still holds the jti so you can retry. Run \`npx @decidio/sdk quickstart cleanup\` again, or revoke it directly:`);
832
+ err(` npx @decidio/sdk tokens revoke ${env.DECIDIO_TOKEN_JTI}`);
833
+ return 1;
834
+ }
767
835
  }
768
836
  else {
769
837
  log(` ⚠ no DECIDIO_TOKEN_JTI in ${envFile} (older tour) — revoke by hand: npx @decidio/sdk tokens list, then tokens revoke <jti>. It expires on its own within 7 days.`);
@@ -916,6 +984,10 @@ const USAGE = `decidio — the Decidio SDK command line
916
984
  npx @decidio/sdk approvals [--json] [--mine] list pending agent approvals
917
985
  npx @decidio/sdk approvals approve <id> [reason]
918
986
  npx @decidio/sdk approvals reject <id> [reason]
987
+ the reason is FREE TEXT and is sealed verbatim. If it
988
+ starts with a dash, put it after -- or use --reason:
989
+ ... approve <id> -- "-50% cut, per the board"
990
+ ... approve <id> --reason "-50% cut, per the board"
919
991
  npx @decidio/sdk receipt <id> download a sealed decision's Authority Receipt (.json)
920
992
  npx @decidio/sdk verify [--issuer <did>] <receipt.json> [more...] verify receipts OFFLINE
921
993
  npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
@@ -949,7 +1021,7 @@ async function main(argv) {
949
1021
  quickstart: { flags: ["--auto-approve-demo", "--keep-agent"] },
950
1022
  init: { flags: ["--unique", "--force"], valued: ["--expires-days"] },
951
1023
  doctor: { flags: [] },
952
- approvals: { flags: ["--json", "--mine"] },
1024
+ approvals: { flags: ["--json", "--mine"], valued: ["--reason"] },
953
1025
  receipt: { flags: ["--json"] },
954
1026
  verify: { flags: [], valued: ["--issuer"] },
955
1027
  tokens: { flags: ["--mine"] },
@@ -961,6 +1033,10 @@ async function main(argv) {
961
1033
  const positionals = [];
962
1034
  for (let i = 0; i < argsIn.length; i++) {
963
1035
  const a = argsIn[i];
1036
+ if (a === "--") {
1037
+ positionals.push(...argsIn.slice(i + 1));
1038
+ break;
1039
+ }
964
1040
  if (!a.startsWith("--")) {
965
1041
  positionals.push(a);
966
1042
  continue;
@@ -1028,8 +1104,28 @@ async function main(argv) {
1028
1104
  }
1029
1105
  case "doctor": return cmdDoctor();
1030
1106
  case "approvals": {
1031
- const positional = argsIn.filter((x) => !x.startsWith("-"));
1032
- return cmdApprovals(positional[0], positional[1], positional.slice(2).join(" ") || undefined, { json: argsIn.includes("--json"), mine: argsIn.includes("--mine") });
1107
+ const dashdash = argsIn.indexOf("--");
1108
+ const head = dashdash >= 0 ? argsIn.slice(0, dashdash) : argsIn;
1109
+ const literal = dashdash >= 0 ? argsIn.slice(dashdash + 1) : [];
1110
+ const rIdx = head.indexOf("--reason");
1111
+ const flagless = head.filter((x, i) => !x.startsWith("-") && !(rIdx >= 0 && i === rIdx + 1));
1112
+ const sub = flagless[0];
1113
+ const id = flagless[1];
1114
+ let reason;
1115
+ if (rIdx >= 0)
1116
+ reason = head[rIdx + 1];
1117
+ else if (literal.length)
1118
+ reason = literal.join(" ");
1119
+ else if (sub && id) {
1120
+ const after = head.indexOf(id) + 1;
1121
+ const rest = head.slice(after).filter((x) => x !== "--json" && x !== "--mine");
1122
+ reason = rest.length ? rest.join(" ") : undefined;
1123
+ }
1124
+ if (rIdx >= 0 && (reason === undefined || reason.startsWith("-"))) {
1125
+ err(`✘ --reason needs the text to record (quote it if it contains spaces): npx @decidio/sdk approvals ${sub ?? "approve"} <id> --reason "over budget -50% cut required"`);
1126
+ return 2;
1127
+ }
1128
+ return cmdApprovals(sub, id, reason, { json: head.includes("--json"), mine: head.includes("--mine") });
1033
1129
  }
1034
1130
  case "receipt": return cmdReceipt(argsIn.find((x) => !x.startsWith("-")), argsIn.includes("--json"));
1035
1131
  case "verify": return cmdVerify(argsIn);
@@ -0,0 +1,12 @@
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
+ export declare class DecidioHttpError extends Error {
7
+ status: number;
8
+ endpoint: string;
9
+ body?: unknown | undefined;
10
+ readonly retryable: boolean;
11
+ constructor(status: number, endpoint: string, body?: unknown | undefined);
12
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,18 @@
1
+ export class DecidioHttpError extends Error {
2
+ status;
3
+ endpoint;
4
+ body;
5
+ retryable;
6
+ constructor(status, endpoint, body) {
7
+ const terminal = status === 401 || status === 403;
8
+ super(`[decidio] ${endpoint} refused this call (HTTP ${status})` +
9
+ (terminal
10
+ ? ` — the credential was rejected. The agent token may have expired or been revoked (re-running \`init\` for the same agent revokes the previous one). Mint a fresh one: npx @decidio/sdk init <agent-name>, then restart this process. Nothing was routed or executed.`
11
+ : ` — this looks transient; retrying is safe (a retry creates a NEW request and can never double-execute). Nothing was routed or executed.`));
12
+ this.status = status;
13
+ this.endpoint = endpoint;
14
+ this.body = body;
15
+ this.name = "DecidioHttpError";
16
+ this.retryable = !terminal;
17
+ }
18
+ }
@@ -0,0 +1,41 @@
1
+ /** Every tier the gate can record, weakest to strongest. Mirrors the server's enum exactly; an
2
+ * answer outside this list means this SDK version does not understand the record, and an answer
3
+ * we do not understand is never treated as evidence. */
4
+ export declare const EVIDENCE_TIERS: readonly ["pending", "execution_unconfirmed", "agent_asserted", "sensor_observed", "application_confirmed", "independently_reconciled"];
5
+ export type EvidenceTier = (typeof EVIDENCE_TIERS)[number];
6
+ export interface EvidenceOutcome {
7
+ /** Decidio accepted the report and named the tier it recorded. */
8
+ reportRecorded: boolean;
9
+ /** The tier Decidio recorded, verbatim. */
10
+ evidenceTier?: EvidenceTier;
11
+ /** The recorded tier attests a committed execution. */
12
+ executionConfirmed: boolean;
13
+ /** Present when the outcome is worth explaining (why a signed report did not confirm, or why
14
+ * a report was not recorded at all) — always actionable, never a shrug. */
15
+ diagnostic?: string;
16
+ }
17
+ /** The SERVER's rule for `application_confirmed`, restated here exactly as implemented so this
18
+ * explanation can never drift from it: the captured response must carry a RECORD ID
19
+ * (`id` / `recordId` / `Id`), and must not explicitly deny success (`success: false` /
20
+ * `ok: false`). An id with no success field is treated as a commit; a response with neither is
21
+ * honestly `execution_unconfirmed`. */
22
+ export declare function describeTierGap(capturedResponse: unknown): string;
23
+ /** Read the gate's confirm response and say precisely what happened. Fail-closed: an unknown or
24
+ * missing tier is NOT a recorded report. */
25
+ export declare function readConfirmResponse(body: unknown, opts: {
26
+ signed: boolean;
27
+ capturedResponse?: unknown;
28
+ journalled?: boolean;
29
+ }): EvidenceOutcome;
30
+ import { type RequesterIdentity } from "./signing.js";
31
+ /** Minimal identity a confirm body needs. */
32
+ export interface ReportIdentity {
33
+ agentId: string;
34
+ agentKey?: string;
35
+ agentDid?: string;
36
+ workspaceId?: string;
37
+ }
38
+ /** The ONE confirm request shape, shared by every door (inline, durable resume, adapters).
39
+ * Carries the identity proof (who is reporting) AND — when a key is configured — a signed,
40
+ * decision-bound ExecutionReport over the canonical response digest (what was reported). */
41
+ export declare function buildConfirmBody(cfg: ReportIdentity, decisionId: string, canonicalResult: unknown, requesterIdentity?: RequesterIdentity, source?: string): Record<string, unknown>;
@@ -0,0 +1,57 @@
1
+ export const EVIDENCE_TIERS = [
2
+ "pending",
3
+ "execution_unconfirmed",
4
+ "agent_asserted",
5
+ "sensor_observed",
6
+ "application_confirmed",
7
+ "independently_reconciled",
8
+ ];
9
+ const CONFIRMING = ["sensor_observed", "application_confirmed", "independently_reconciled"];
10
+ export function describeTierGap(capturedResponse) {
11
+ const r = (capturedResponse ?? {});
12
+ const id = r.id ?? r.recordId ?? r.Id ?? null;
13
+ const denied = r.success === false || r.ok === false;
14
+ if (denied) {
15
+ return "your protected function reported success: false (or ok: false), so Decidio recorded the execution as UNCONFIRMED — that is the response saying the downstream write did not commit. If it did commit, return a truthy success flag.";
16
+ }
17
+ if (id == null) {
18
+ return "your protected function's return value carried no record id (`id`, `recordId` or `Id`), so Decidio has no evidence that the downstream write committed and honestly recorded the execution as UNCONFIRMED. Return the source system's identifiers (e.g. `{ id, success }` or `{ recordId, ok }`), or map them with the `evidence` option, to earn application_confirmed.";
19
+ }
20
+ return "the report was accepted but the gate recorded a weaker tier — check that this agent's registration and signing key still match (`npx @decidio/sdk doctor`).";
21
+ }
22
+ export function readConfirmResponse(body, opts) {
23
+ const retry = opts.journalled
24
+ ? "re-send the report with `guard.resume.reconfirm(decisionId)` once connectivity or the service is healthy."
25
+ : "this path journalled no report, so there is nothing to re-send from here — record the outcome from your own system, or run the action through `guard.protect`'s durable mode, where reports ARE journalled and `reconfirm` can replay them.";
26
+ const b = body;
27
+ if (!b || b.ok !== true || typeof b.confirmation !== "string") {
28
+ return { reportRecorded: false, executionConfirmed: false, diagnostic: `the gate answered 2xx but named no evidence tier — the execution report is NOT recorded. Your action DID run; ${retry}` };
29
+ }
30
+ const tier = b.confirmation;
31
+ if (!EVIDENCE_TIERS.includes(tier)) {
32
+ return { reportRecorded: false, executionConfirmed: false, 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}` };
33
+ }
34
+ const executionConfirmed = CONFIRMING.includes(tier);
35
+ const outcome = { reportRecorded: true, evidenceTier: tier, executionConfirmed };
36
+ if (!executionConfirmed && opts.signed)
37
+ outcome.diagnostic = describeTierGap(opts.capturedResponse);
38
+ else if (!executionConfirmed)
39
+ 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.";
40
+ return outcome;
41
+ }
42
+ import { signExecutionReport, resultDigest } from "./signing.js";
43
+ export function buildConfirmBody(cfg, decisionId, canonicalResult, requesterIdentity, source = "wrapper") {
44
+ const executionReport = (cfg.agentKey && cfg.agentDid)
45
+ ? signExecutionReport(cfg.agentKey, {
46
+ decisionId, agentId: cfg.agentId, did: cfg.agentDid, workspaceId: cfg.workspaceId,
47
+ source, resultDigest: resultDigest(canonicalResult ?? null),
48
+ })
49
+ : undefined;
50
+ return {
51
+ decisionId,
52
+ capturedResponse: canonicalResult ?? null,
53
+ source,
54
+ ...(requesterIdentity ? { requesterIdentity } : {}),
55
+ ...(executionReport ? { executionReport } : {}),
56
+ };
57
+ }
package/dist/guard.d.ts CHANGED
@@ -21,7 +21,12 @@ export interface GuardFacadeConfig {
21
21
  }
22
22
  export interface ProtectOptions {
23
23
  /** null/"generic" → the core gate; engine adapters (langgraph/openai/temporal/inngest)
24
- * live in @decidio/sdk subpaths and take the engine handle. */
24
+ * live in @decidio/sdk subpaths and take the engine handle.
25
+ *
26
+ * BETA: the engine adapters share the core's gate semantics (verdict allow-list, canonical
27
+ * arguments, transport rule and evidence handling are one implementation, conformance-tested
28
+ * across every door), but their ENGINE integration is verified against structural mocks, not
29
+ * live engines. Use the core path for production until that changes. */
25
30
  adapter?: "generic" | "langgraph" | "openai" | "temporal" | "inngest";
26
31
  /** "durable" (default) suspends for async approval; "blocking" polls (short approvals only). */
27
32
  mode?: "durable" | "blocking";
@@ -37,11 +42,25 @@ export interface ProtectOptions {
37
42
  pollTimeoutMs?: number;
38
43
  /** Blocking-mode poll interval in ms (default 3000). */
39
44
  pollIntervalMs?: number;
45
+ /** Map YOUR function's natural return value to the evidence Decidio records. The gate awards
46
+ * its strongest wrapper tier (application_confirmed) only for a committed record id plus a
47
+ * success indication, so instead of forcing your API into magic field names:
48
+ * `evidence: (r) => ({ recordId: r.transactionId, success: r.status === "committed" })`. */
49
+ evidence?: (result: any) => unknown;
40
50
  }
41
51
  export declare class Guard {
42
52
  config: GuardFacadeConfig;
43
- resume?: ResumeController;
53
+ private _resume?;
44
54
  constructor(config: GuardFacadeConfig, resume?: ResumeController);
55
+ /** The recovery surface — `guard.resume.recover()` / `.reconfirm(id)`. BUILT ON FIRST ACCESS,
56
+ * because the process that needs to recover is usually a COLD one that never made the original
57
+ * call, so nothing has lazily constructed the controller yet. As a plain field this read was
58
+ * `undefined` there and the documented recovery loop crashed on the exact path it exists for.
59
+ * Unconfigured still fails loud (naming DECIDIO_API_URL), never silently. */
60
+ get resume(): ResumeController;
61
+ /** Kept writable: hosts that construct their own controller (custom store, injected fetch)
62
+ * assign it here, and `new Guard(cfg, controller)` must stay equivalent to that assignment. */
63
+ set resume(r: ResumeController);
45
64
  static fromEnv(): Guard;
46
65
  /** Register a raw fn under its action name so a COLD process (one that never made the original
47
66
  * call) can resume it. Call this at startup beside `guard.worker()` — or pass
package/dist/guard.js CHANGED
@@ -2,11 +2,13 @@ import { withApproval } from "./index.js";
2
2
  import { createDecidioResume, FilePendingStore } from "./resume.js";
3
3
  export class Guard {
4
4
  config;
5
- resume;
5
+ _resume;
6
6
  constructor(config, resume) {
7
7
  this.config = config;
8
- this.resume = resume;
8
+ this._resume = resume;
9
9
  }
10
+ get resume() { return this.ensureResume(); }
11
+ set resume(r) { this._resume = r; }
10
12
  static fromEnv() {
11
13
  return new Guard({
12
14
  apiUrl: process.env.DECIDIO_API_URL ?? "",
@@ -30,16 +32,16 @@ export class Guard {
30
32
  }
31
33
  ensureResume() {
32
34
  this.assertConfigured();
33
- if (!this.resume) {
35
+ if (!this._resume) {
34
36
  const c = this.config;
35
- this.resume = createDecidioResume({
37
+ this._resume = createDecidioResume({
36
38
  apiUrl: c.apiUrl, apiToken: c.apiToken, webhookSecret: c.webhookSecret,
37
39
  resumeUrl: c.resumeUrl, store: new FilePendingStore(c.pendingDir ?? ".decidio-pending"),
38
40
  agentId: c.agentId, agentKey: c.agentKey, agentDid: c.agentDid, workspaceId: c.workspaceId,
39
41
  fetchImpl: c.fetchImpl,
40
42
  });
41
43
  }
42
- return this.resume;
44
+ return this._resume;
43
45
  }
44
46
  assertConfigured() {
45
47
  if (!this.config.apiUrl)
@@ -61,14 +63,19 @@ export class Guard {
61
63
  pollTimeoutMs: opts?.pollTimeoutMs, pollIntervalMs: opts?.pollIntervalMs,
62
64
  };
63
65
  if ((opts?.mode ?? "durable") === "blocking") {
64
- console.warn("[decidio] WARNING: blocking wait — only safe for short, watched approvals. " +
65
- "Omit mode:\"blocking\" for durable async resume on real (minutes-to-days) approvals.");
66
- return withApproval(fn, describe, base);
66
+ const ceilingMs = opts?.pollTimeoutMs ?? base.pollTimeoutMs ?? 600_000;
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 ` +
70
+ `durable async resume on real (minutes-to-days) approvals.`);
71
+ return withApproval(fn, describe, base, opts?.evidence ? { evidence: opts.evidence } : undefined);
67
72
  }
68
73
  const resume = this.ensureResume();
69
74
  if (opts?.action)
70
75
  resume.register(opts.action, fn);
71
- return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume });
76
+ if (opts?.evidence)
77
+ resume.evidence = opts.evidence;
78
+ return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume }, opts?.evidence ? { evidence: opts.evidence } : undefined);
72
79
  }
73
80
  }
74
81
  export const guard = Guard.fromEnv();
package/dist/index.d.ts CHANGED
@@ -3,7 +3,12 @@ export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDeci
3
3
  export type { PendingAction, PendingStore, ResumeController, ResumeOutcome } from "./resume.js";
4
4
  export { guard, Guard } from "./guard.js";
5
5
  export type { GuardFacadeConfig, ProtectOptions } from "./guard.js";
6
- export { generateKeypair, signBoundProof, signConfirm, canonicalize, assertJsonSafe, assertSafeTransport, DecidioUnsafeArgumentsError } from "./signing.js";
6
+ export { DecidioHttpError } from "./errors.js";
7
+ export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport } from "./signing.js";
8
+ export { canonicalize, canonicalizeArgs, canonicalJson, DecidioUnsafeArgumentsError } from "./canonical.js";
9
+ export type { JsonValue, JsonPrimitive } from "./canonical.js";
10
+ export { readConfirmResponse, buildConfirmBody, describeTierGap, EVIDENCE_TIERS } from "./evidence.js";
11
+ export type { EvidenceOutcome, EvidenceTier } from "./evidence.js";
7
12
  export type { RequesterIdentity } from "./signing.js";
8
13
  export interface GuardConfig {
9
14
  /** Base URL of the Decidio gate, e.g. "https://api.decidio.io". */
@@ -102,4 +107,10 @@ export declare class DecidioNetworkError extends Error {
102
107
  /** `<appUrl>/d/<decisionId>` — the deep link that opens this decision directly, on the web app or
103
108
  * the phone build. Null when DECIDIO_APP_URL is not configured, never a half-built URL. */
104
109
  export declare function decisionUrl(appUrl: string | undefined, decisionId: string): string | null;
105
- export declare function withApproval<A extends any[], R>(fn: (...args: A) => Promise<R>, describe: (...args: A) => ApprovalContext, config: GuardConfig): (...args: A) => Promise<R>;
110
+ export declare function withApproval<A extends any[], R>(fn: (...args: A) => Promise<R>, describe: (...args: A) => ApprovalContext, config: GuardConfig, opts?: {
111
+ /** Map YOUR function's natural return value to the evidence Decidio records. The gate awards
112
+ * its strongest wrapper tier (application_confirmed) only for a committed record id plus a
113
+ * success indication — this lets a real API keep its own shape instead of being forced into
114
+ * magic field names: `evidence: (r) => ({ recordId: r.transactionId, success: r.status === "committed" })`. */
115
+ evidence?: (result: R) => unknown;
116
+ }): (...args: A) => Promise<R>;