@decidio/sdk 0.2.2 → 0.3.3

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
 
@@ -144,8 +148,12 @@ evidence is yours, not ours. To also prove *who* issued it, pin your workspace's
144
148
  record verify panel — or simply read it off the `receipt` command's output, which prints your
145
149
  workspace's issuer DID from the authenticated response so you never have to hunt for it).
146
150
 
147
- **What VALID means — and what it does not.** A pinned `VALID` proves the receipt's bytes were
148
- sealed by your workspace's issuer and are unaltered since. It does **not** prove the approved
151
+ **What VALID means — and what it does not.** A pinned `VALID` proves the receipt's **claims** were
152
+ sealed by your workspace's issuer and that none of them has changed since. Not its *bytes*: the
153
+ signature covers the record's canonical form (JCS), so reformatting the file — reindenting it,
154
+ minifying it, reordering keys — still verifies, and should, because none of that alters a claim.
155
+ If you need byte-for-byte custody, hash the file and keep that hash yourself; this proves the
156
+ content, which is what an auditor is asking about. It does **not** prove the approved
149
157
  action ran. Execution is confirmed *after* the seal, and a sealed record is immutable, so the
150
158
  receipt's own `authority.result` and `confirmation.status` are a snapshot taken at seal time — a
151
159
  receipt for an approved-but-never-executed action verifies `VALID`, correctly. The verifier now
@@ -382,10 +390,30 @@ for (const item of await guard.resume.recover()) {
382
390
  console.log(item.state, item.decisionId, item.guidance);
383
391
  }
384
392
  // "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
393
+ // "indeterminate" → no outcome was recorded; check downstream, then reconcile. Never blind-retry
386
394
  // "parked" → still waiting on a human; nothing has executed
387
395
  ```
388
396
 
397
+ Two things `reconcile()` will refuse, because both would undo an authority decision rather than
398
+ record one. **It refuses by returning, not by throwing** — check `reportRecorded`, and read
399
+ `diagnostic` to see why:
400
+
401
+ ```js
402
+ // A denial is final. There is no outcome to record — nothing ran — so neither outcome is accepted.
403
+ const r = await guard.resume.reconcile(deniedId, { outcome: "not_committed" });
404
+ r.reportRecorded; // false — r.diagnostic says the decision was DENIED
405
+
406
+ // A decision that is still `invoking` may be running the action at this moment. Releasing it would
407
+ // let a second worker run the same approved action, so it takes an explicit assertion — from you,
408
+ // after you have checked. Nothing here can see another machine's process table, so this is your
409
+ // judgement, and it is stamped into the ledger as such.
410
+ await guard.resume.reconcile(id, { outcome: "not_committed", reason: "…" }); // refused while invoking
411
+ await guard.resume.reconcile(id, { outcome: "not_committed", reason: "…", workerStopped: true }); // released
412
+ ```
413
+
414
+ `indeterminate` needs no such flag: the handler has already returned or thrown, so nothing is in
415
+ flight — only the downstream result is unknown.
416
+
389
417
  The report a worker sends is written to the pending store's execution ledger *before* the first
390
418
  send attempt, so a lost confirmation is recoverable; and because that record holds no handler
391
419
  binding, replaying it can only re-send evidence — never re-run your function. The ledger lives in
@@ -420,6 +448,135 @@ imports none of them — install the engine you use alongside the SDK (`npm i in
420
448
  for engines you don't. Each adapter's shipped `.d.ts` header documents its wiring; `npx
421
449
  @decidio/sdk quickstart` is the fastest runnable end-to-end.
422
450
 
451
+
452
+
453
+
454
+ ## The durable store is a revision chain (0.3.2)
455
+
456
+ `FilePendingStore` keeps each decision's execution record as an **append-only revision chain** —
457
+ `<id>.rev.1`, `<id>.rev.2`, … — and the head is the highest number. Every state change reads the
458
+ head, validates against exactly that record, writes the successor to a temp file, and publishes it
459
+ with `link()` to the next revision number. `link()` fails if that name already exists, so **exactly
460
+ one writer wins each revision**, with no lock, no timeout and no daemon — on any volume that
461
+ supports hard links (NTFS, ext4, APFS, XFS; not FAT/exFAT, and not every network or FUSE mount —
462
+ on those every write fails closed rather than silently degrading). A loser re-reads the new head
463
+ and validates again — against what actually happened, not a snapshot. **No revision file is ever
464
+ deleted:** a name that could be reused is a race that could be lost, so the chain is genuinely
465
+ append-only and a decision's whole history stays readable in place.
466
+
467
+ This replaced the 0.2/0.3 one-marker-per-state layout after an external retest showed why it had to:
468
+ that layout made a transition in three separate steps (read the markers, write the new one, sweep
469
+ the weaker ones), so a process holding `claimed_preinvoke` could validate its snapshot, lose to a
470
+ second process that durably wrote *and returned* a denial, then write `invoking` anyway — and its
471
+ sweep deleted the tombstone. The SDK reported a denial as final and ran the denied action. Every
472
+ *sequential* schedule was safe; the window was inside one transition. The chain has no such window:
473
+ nothing ever deletes the head, and the rule that refuses a denied decision now runs against the
474
+ denial that just landed.
475
+
476
+ **Upgrading.** A directory in the older layout is migrated once, at the first construction of a
477
+ 0.3.2 store: each decision's markers become the head of a fresh chain and the markers are removed.
478
+ Corrupt markers are left exactly as found and the decision reads as unreadable until a person
479
+ inspects it. **Stop every older worker that shares the directory BEFORE the first 0.3.2 process
480
+ constructs its store, then upgrade them together.** The one-time migration reads the markers it
481
+ finds and removes exactly those; an older SDK still writing leaves a marker beside a chain, and
482
+ that decision is then *unreadable* by design — neither reading can be trusted over the other, so
483
+ the store refuses to pick one.
484
+
485
+ **Custom stores.** The `PendingStore` contract has said since 0.2.0 that every method must be atomic
486
+ against concurrent callers. The shipped file store did not honor that until now; a custom store must.
487
+ The deterministic two-process schedules that proved the defect live in
488
+ `packages/conformance/acceptance.mjs` (criteria 7a, 7b and 7c): a denial landing while an invoker
489
+ is paused between validating and publishing; two conflicting settlements from one head; and a denier
490
+ paused while the approver publishes four revisions past it. A store that cannot pass them is not
491
+ atomic, whatever its methods say.
492
+
493
+ ## Reading a receipt
494
+
495
+ The cryptography is only half the evidence. These are the fields that an outside reader has
496
+ actually misread on a first run, and what each one means.
497
+
498
+ **`ratified: yes` next to `deciders: 0` is not a contradiction.** `deciders` is the list of people
499
+ nominated **in advance** to decide. When an agent's request is *routed* rather than pre-assigned,
500
+ that list is empty — nobody was nominated, because the request had not happened yet. The person who
501
+ actually decided is the **ratifier** (`ratifiedBy`). So a receipt for a human-approved action reads
502
+ `deciders: 0, ratified: yes`, and the human is recorded, in the field named for the role they
503
+ played. The verifier prints this explanation whenever the pair appears.
504
+
505
+ **`appliedLimits` is what the request was MEASURED AGAINST, not a rule that fired.** It holds up to
506
+ three entries, and only those that have a value: `requested_amount` (the amount actually asked for),
507
+ `auto_approve_threshold` and `hard_cap` (the ceilings on the agent's own token). So a decision routed
508
+ to a human because *no rule matched* can still list an `auto_approve_threshold` — that is the bar the
509
+ request was compared to and did not clear, not a limit that was applied to it. Read
510
+ `requested_amount` as the ask, the other two as ceilings.
511
+
512
+ **`firedRules` is what the policy engine actually matched — for any verdict.** It is not an
513
+ auto-approve indicator: a **blocked** request carries the deny-by-default backstop rule here, so
514
+ non-empty does not mean "approved" and empty does not mean "blocked". The field that states the
515
+ disposition is `authority.result`; `firedRules` tells you which named, versioned rules produced it.
516
+
517
+ **`payloadSchemaUri` is a `urn:`, not a URL.** It is content-addressed —
518
+ `urn:decidio:sealed-payload:1:sha256:<hash>` — so it *identifies* the exact schema the record was
519
+ sealed under and pins it against substitution. It does not locate a document, and nothing will
520
+ resolve it. (Shipping the schema alongside the verifier so you can check the hash yourself is on
521
+ the roadmap; today the URN is an identifier only, and this note exists so it does not look broken.)
522
+
523
+ **`prevHash` proves a pointer, not an ancestry.** A non-null `prevHash` shows this record was
524
+ sealed after a specific predecessor, but a single-file verification cannot check that predecessor
525
+ exists or is what it claims. Verify a chain (`verify a.json b.json c.json`) when the sequence
526
+ matters.
527
+
528
+ **Pinning is trust-on-first-use.** The first time you fetch a receipt, the record *and* the issuer
529
+ DID arrive in the same authenticated response — so that first check trusts the connection, not the
530
+ key. What pinning buys is everything afterwards: record the DID once, out of band if you can, and
531
+ every later verification is checked against a key you chose rather than one you were handed. The
532
+ verifier says so on every unpinned run.
533
+
534
+ ## Upgrading from 0.2.x to 0.3.0
535
+
536
+ 0.3.0 is a breaking release. Every item below changes behaviour a 0.2.x caller may depend on, so
537
+ none of it is left to be discovered at runtime.
538
+
539
+ **If you wrote a custom `PendingStore`, it will fail to construct.** `markDiscarded(decisionId,
540
+ verdict, reason?)` is now REQUIRED — it writes the tombstone that makes a denied decision
541
+ permanently unclaimable, and a store that cannot record a denial cannot uphold the guarantee, so
542
+ this is refused at construction rather than deep inside a resolve. `delete` also takes an optional
543
+ owning attempt (`delete(id, attemptId?)`) so a superseded attempt cannot clean up the live one's
544
+ payload, and `settle` takes `workerStopped`. The shipped stores show the shape, and
545
+ `packages/conformance/vectors/ownership.json` is the corpus your store should pass.
546
+
547
+ **`recover()` now throws instead of returning `[]`** when the ledger cannot be read
548
+ (`DecidioRecoveryUnavailableError`). `[]` and "I cannot tell you what you owe" are the same value
549
+ to a caller and mean opposite things, so a script reading `if (!(await recover()).length)` treated
550
+ a permissions change as proof that nothing was outstanding. Wrap the call if you poll it.
551
+
552
+ **`reconcile()` refuses two things it used to allow.** A DENIED decision cannot be reconciled on
553
+ either outcome, and releasing a decision that is still `invoking` takes an explicit
554
+ `workerStopped: true` — that is the one state where the handler may be running right now, and
555
+ releasing it hands one approved action to two live workers. It refuses by RETURNING, not throwing:
556
+ check `reportRecorded` and read `diagnostic`.
557
+
558
+ **The adapters return a different shape.** `decidioGate` (LangGraph, Temporal) returns
559
+ `ProtectedExecution` — `{ value, decisionId, confirmation }` — instead of the bare value.
560
+ `decidioGateStep` (Inngest) renames `result` to `value` and carries `confirmation`.
561
+ `gateInterruptions` is now `authorizeInterruptions` and returns `confirmationSupported: false`
562
+ (the old name remains as a deprecated alias). Read `.value` where you previously read the result.
563
+
564
+ **The transport rule refuses URLs it used to accept.** A gate URL must now be absolute and either
565
+ https or an exact loopback host, whatever credentials the config holds. A relative or scheme-less
566
+ `apiUrl` throws at startup — it used to pass on the reasoning that the shipped transport rejects
567
+ it, which says nothing about a custom `fetchImpl`.
568
+
569
+ **Decision ids are shape-checked.** Letters, digits, dot, dash and underscore, starting with a
570
+ letter or digit, at most 128 characters, never trimmed. The server enforces the same grammar (it
571
+ had none before 0.3.0, which is why the SDK was the stricter of the two). Ids Decidio issues are
572
+ well inside it.
573
+
574
+ **`/agent/confirm` can answer 409.** `TERMINALLY_DENIED` means a human refused the decision and
575
+ your action appears to have run anyway; `NOT_AUTHORIZED` means there is no approved, sealed
576
+ decision for evidence to attach to. Neither is a report failure and neither is retryable — the SDK
577
+ raises `DecidioHttpError` with advice that says so, rather than the "only its report failed" note
578
+ that fits every other confirm error.
579
+
423
580
  ## Engine adapters (BETA) — and Python too
424
581
 
425
582
  - **Engine adapters are BETA.** They share the core's gate semantics — the verdict allow-list,
@@ -430,6 +587,17 @@ for engines you don't. Each adapter's shipped `.d.ts` header documents its wirin
430
587
  runtimes. Use the core path for production until that changes. Each adapter is bound through its
431
588
  own `@decidio/sdk/<engine>` subpath (they need the engine handle at call time); no engine at all?
432
589
  The worker/webhook resume is the universal path and is not beta.
590
+ - **One result shape on every door (0.3.0, breaking).** `decidioGate` (LangGraph, Temporal) and
591
+ `decidioGateStep` (Inngest) now return `{ value, decisionId, confirmation }` — the same
592
+ `ProtectedExecution` the core path returns. They used to hand back the bare value and discard what
593
+ Decidio recorded, so "it ran and was recorded" and "it ran and Decidio does not know" were the
594
+ same return value to a caller. Inngest keeps its `status`, because its durable path can end in
595
+ `rejected` or `blocked` without ever calling your action; its value field is `value`, not
596
+ `result`.
597
+ The OpenAI door is the exception and now says so: it was renamed `gateInterruptions` →
598
+ `authorizeInterruptions` and returns `confirmationSupported: false`. The OpenAI runtime executes
599
+ the tool itself, so this SDK never sees the return value and has nothing to attest — and an
600
+ ABSENT confirmation must not look like a failed one. The old name remains as a deprecated alias.
433
601
  - **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
602
 
435
603
  ## CLI
@@ -495,3 +663,12 @@ your queue.
495
663
  - **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
664
  - **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
665
  - **API surface:** the agent-gate REST contract this SDK speaks is described in [`openapi.yaml`](./openapi.yaml), shipped in the package.
666
+
667
+ ## Using this for real?
668
+
669
+ The hosted sandbox is synthetic and shared. Production workspaces are per-tenant, with your own
670
+ issuer key and receipts that verify offline against it.
671
+
672
+ [decidioai.com](https://decidioai.com) · [Developer docs](https://decidioai.com/developers/) · [Request access](https://decidioai.com/#early-access)
673
+
674
+ Built by OmniTwin Technologies.
@@ -14,7 +14,7 @@ export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
14
14
  return a;
15
15
  }
16
16
  export async function gateAuthorize(config, ctx) {
17
- canonicalize(ctx, "describe(...)");
17
+ ctx = canonicalize(ctx, "describe(...)");
18
18
  assertSafeTransport(config.apiUrl, carriesCredential(config));
19
19
  const f = config.fetchImpl ?? fetch;
20
20
  const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
@@ -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,22 +114,22 @@ 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
- }
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.`);
116
120
  }
117
121
  }
118
- function effectiveUser() {
119
- try {
120
- return userInfo().username;
121
- }
122
- catch {
123
- return process.env.USERNAME ?? process.env.USER ?? "";
122
+ async function revokeFreshMint(jti, bearer, writeError, target) {
123
+ err(`✘ could not write ${target} (${writeError?.message ?? writeError}). The token minted a moment ago (jti ${jti}) is LIVE until revoked - revoking it now.`);
124
+ const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, bearer);
125
+ if (rv.status >= 200 && rv.status < 300) {
126
+ err(` ✔ the token minted a moment ago (jti ${jti}) was REVOKED again, so nothing live was left behind. Fix the cause above and re-run - it will mint a fresh one.`);
127
+ return true;
124
128
  }
129
+ err(` ⚠ the token minted a moment ago (jti ${jti}) is LIVE and could NOT be revoked (${rv.status}). Revoke it by hand, or re-run init - the server keeps one active token per agent, so a fresh mint supersedes it:`);
130
+ err(` npx @decidio/sdk tokens revoke ${jti}`);
131
+ err(` npx @decidio/sdk tokens list --mine`);
132
+ return false;
125
133
  }
126
134
  function upsertEnv(updates) {
127
135
  const remaining = { ...updates };
@@ -345,8 +353,19 @@ async function cmdInit(agentId, opts = {}) {
345
353
  envOut.DECIDIO_WORKSPACE_ID = workspaceId;
346
354
  if (tokenJti)
347
355
  envOut.DECIDIO_TOKEN_JTI = tokenJti;
348
- prepareEnvForWrite();
349
- upsertEnv(envOut);
356
+ try {
357
+ prepareEnvForWrite();
358
+ upsertEnv(envOut);
359
+ }
360
+ catch (e) {
361
+ if (agentToken && tokenJti)
362
+ await revokeFreshMint(tokenJti, mintBearer, e, ENV_PATH);
363
+ else if (agentToken)
364
+ err(`✘ could not write ${ENV_PATH} (${e?.message ?? e}). The token minted a moment ago is LIVE and the server returned no revocation id - find and revoke it: npx @decidio/sdk tokens list --mine`);
365
+ else
366
+ err(`✘ could not write ${ENV_PATH} (${e?.message ?? e}). No token was minted this run.`);
367
+ return 1;
368
+ }
350
369
  for (const [k, v] of Object.entries(envOut))
351
370
  process.env[k] = v;
352
371
  log(`✔ wrote ${ENV_PATH} (owner-only where the OS supports it) — every decidio command reads it from this directory (your script loads it with node --env-file=.env)`);
@@ -601,10 +620,13 @@ async function cmdReceipt(id, jsonMode = false) {
601
620
  }
602
621
  log(` Verify it offline — no Decidio account or network needed (the verifier is built in):`);
603
622
  if (issuer) {
623
+ log(` Record your workspace's issuer DID — copy this line, and pin it forever:`);
624
+ log(``);
625
+ log(`${issuer}`);
626
+ log(``);
627
+ log(` Then verify, pinned to it (the same DID, inline — copy from the line above, not this one,`);
628
+ log(` which your terminal may wrap):`);
604
629
  log(` npx @decidio/sdk verify --issuer ${issuer} ${name}`);
605
- log(` That pins the check to your workspace's issuer DID (read from the authenticated`);
606
- log(` response above — record it once, then pin it forever):`);
607
- log(` ${issuer}`);
608
630
  log(` Without --issuer the verifier proves the receipt is internally consistent, but NOT who`);
609
631
  log(` issued it; it says so in its output.`);
610
632
  }
@@ -625,6 +647,10 @@ function cmdVerify(argsIn) {
625
647
  err("✘ --issuer needs a did:key value (a bare or EMPTY --issuer must not silently verify unpinned).");
626
648
  return 2;
627
649
  }
650
+ if (expectedIssuer !== undefined && expectedIssuer !== v) {
651
+ err(`✘ --issuer given twice with different values (${expectedIssuer} then ${v}) — refusing to choose which one you meant to pin to.`);
652
+ return 2;
653
+ }
628
654
  expectedIssuer = v;
629
655
  i++;
630
656
  }
@@ -634,6 +660,10 @@ function cmdVerify(argsIn) {
634
660
  err("✘ --issuer needs a did:key value.");
635
661
  return 2;
636
662
  }
663
+ if (expectedIssuer !== undefined && expectedIssuer !== v) {
664
+ err(`✘ --issuer given twice with different values (${expectedIssuer} then ${v}) — refusing to choose which one you meant to pin to.`);
665
+ return 2;
666
+ }
637
667
  expectedIssuer = v;
638
668
  }
639
669
  else if (!a.startsWith("-")) {
@@ -715,24 +745,49 @@ async function cmdQuickstart(opts = {}) {
715
745
  const prev = readFileSync(envFile, "utf8");
716
746
  const prevId = prev.match(/^DECIDIO_AGENT_ID=(.+)$/m)?.[1];
717
747
  const prevJti = prev.match(/^DECIDIO_TOKEN_JTI=(.+)$/m)?.[1];
748
+ const carried = new Set((prev.match(/^DECIDIO_ORPHANED_TOKEN_JTI=(.+)$/m)?.[1] ?? "").split(",").map((s) => s.trim()).filter(Boolean));
718
749
  if (prevId && prevId !== agentId) {
719
750
  if (prevJti) {
720
751
  const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(prevJti)}`, { method: "DELETE" }, session);
721
752
  if (rv.status >= 200 && rv.status < 300)
722
753
  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}`);
754
+ else {
755
+ carried.add(prevJti);
756
+ 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}`);
757
+ }
725
758
  }
726
759
  else {
727
760
  log(` ⚠ the previous tour file carried no revocation id, so its token (if any) must be revoked from your Decidio app.`);
728
761
  }
729
762
  }
763
+ if (carried.size) {
764
+ envOut.DECIDIO_ORPHANED_TOKEN_JTI = [...carried].join(",");
765
+ log(` • ${carried.size} orphaned token id(s) from earlier replacements carried forward — \`quickstart cleanup\` will retry them.`);
766
+ }
767
+ }
768
+ try {
769
+ writeSecretFile(envFile, Object.entries(envOut).map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
770
+ }
771
+ catch (e) {
772
+ if (mint.body?.jti)
773
+ await revokeFreshMint(mint.body.jti, session, e, envFile);
774
+ else
775
+ err(`✘ could not write ${envFile} (${e?.message ?? e}). The tour token minted a moment ago is LIVE and the server returned no revocation id - find and revoke it: npx @decidio/sdk tokens list --mine`);
776
+ return 1;
730
777
  }
731
- writeSecretFile(envFile, Object.entries(envOut).map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
732
778
  log(` ✔ this directory already has an agent in .env — tour credentials went to ${envFile} (your agent is untouched)`);
733
779
  }
734
780
  else {
735
- upsertEnv(envOut);
781
+ try {
782
+ upsertEnv(envOut);
783
+ }
784
+ catch (e) {
785
+ if (mint.body?.jti)
786
+ await revokeFreshMint(mint.body.jti, session, e, envFile);
787
+ else
788
+ err(`✘ could not write ${envFile} (${e?.message ?? e}). The tour token minted a moment ago is LIVE and the server returned no revocation id - find and revoke it: npx @decidio/sdk tokens list --mine`);
789
+ return 1;
790
+ }
736
791
  }
737
792
  const failTour = async (code) => {
738
793
  log("");
@@ -859,6 +914,7 @@ async function cmdQuickstart(opts = {}) {
859
914
  log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
860
915
  log("");
861
916
  log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")} · Next: the README's 7-step quickstart (durable, workers, adapters).`);
917
+ log(`Built by OmniTwin — decidioai.com · Putting this in front of real approvals? decidioai.com/#early-access`);
862
918
  let doCleanup = false;
863
919
  if (interactive) {
864
920
  const answer = await ask(`\nKeep this agent for development? [Enter = keep · c + Enter = clean up now]: `);
@@ -884,6 +940,23 @@ async function quickstartCleanup(session, envFile) {
884
940
  }
885
941
  return out;
886
942
  })();
943
+ const orphans = (env.DECIDIO_ORPHANED_TOKEN_JTI ?? "").split(",").map((s) => s.trim()).filter(Boolean);
944
+ const stillOrphaned = [];
945
+ for (const jti of orphans) {
946
+ const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, session);
947
+ if (rv.status >= 200 && rv.status < 300)
948
+ log(` ✔ orphaned token revoked (jti ${jti}) — it had survived an earlier replace`);
949
+ else {
950
+ stillOrphaned.push(jti);
951
+ err(` ⚠ orphaned token ${jti} could NOT be revoked (${rv.status}) — it is still live.`);
952
+ }
953
+ }
954
+ if (stillOrphaned.length) {
955
+ 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:`);
956
+ for (const jti of stillOrphaned)
957
+ err(` npx @decidio/sdk tokens revoke ${jti}`);
958
+ return 1;
959
+ }
887
960
  if (env.DECIDIO_TOKEN_JTI) {
888
961
  const rev = await api(`/api/workspace/agent-token/${encodeURIComponent(env.DECIDIO_TOKEN_JTI)}`, { method: "DELETE" }, session);
889
962
  if (rev.status >= 200 && rev.status < 300 && rev.body?.changed === false)
@@ -906,7 +979,7 @@ async function quickstartCleanup(session, envFile) {
906
979
  return 1;
907
980
  }
908
981
  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"]);
982
+ 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
983
  const kept = readFileSync(envFile, "utf8").split(/\r?\n/).filter((line) => {
911
984
  const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)=/);
912
985
  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
  }