@decidio/sdk 0.3.0 → 0.3.5

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
@@ -57,6 +57,9 @@ npm install @decidio/sdk
57
57
  npx @decidio/sdk init --unique my-agent # --unique appends a random suffix: collision-proof
58
58
  ```
59
59
 
60
+ > Just ran `npx @decidio/sdk quickstart` in this directory and kept its agent? Its `.env` already
61
+ > works — skip this step and continue at step 3.
62
+
60
63
  Every CLI command talks to the hosted sandbox by default and announces its target host on
61
64
  stderr each run — set `DECIDIO_API_URL` (env or `.env`) to point at another Decidio instead.
62
65
 
@@ -148,8 +151,12 @@ evidence is yours, not ours. To also prove *who* issued it, pin your workspace's
148
151
  record verify panel — or simply read it off the `receipt` command's output, which prints your
149
152
  workspace's issuer DID from the authenticated response so you never have to hunt for it).
150
153
 
151
- **What VALID means — and what it does not.** A pinned `VALID` proves the receipt's bytes were
152
- sealed by your workspace's issuer and are unaltered since. It does **not** prove the approved
154
+ **What VALID means — and what it does not.** A pinned `VALID` proves the receipt's **claims** were
155
+ sealed by your workspace's issuer and that none of them has changed since. Not its *bytes*: the
156
+ signature covers the record's canonical form (JCS), so reformatting the file — reindenting it,
157
+ minifying it, reordering keys — still verifies, and should, because none of that alters a claim.
158
+ If you need byte-for-byte custody, hash the file and keep that hash yourself; this proves the
159
+ content, which is what an auditor is asking about. It does **not** prove the approved
153
160
  action ran. Execution is confirmed *after* the seal, and a sealed record is immutable, so the
154
161
  receipt's own `authority.result` and `confirmation.status` are a snapshot taken at seal time — a
155
162
  receipt for an approved-but-never-executed action verifies `VALID`, correctly. The verifier now
@@ -226,7 +233,11 @@ Real approvals take minutes to days, and nobody keeps a terminal open for them.
226
233
  `mode: "blocking"` (durable is the default) and a routed action **suspends** instead of
227
234
  waiting: it parks its call arguments agent-side (`.decidio-pending/` by default — Decidio
228
235
  stores none of your downstream payload) and throws `DecidioSuspendedError`. The process may
229
- exit. Two ways the parked action resumes when a human decides:
236
+ exit. **The parked payload is plaintext on the agent's disk:** the full call arguments and their
237
+ signature sit in `.decidio-pending/` until the decision resolves — owner-only permissions where the
238
+ OS supports it, gitignored by `init`, but not encrypted. Treat that directory as you treat the `.env`
239
+ beside it; a custom `PendingStore` can encrypt at rest. Two ways the parked action resumes when a
240
+ human decides:
230
241
 
231
242
  **The worker (self-serve — start here).** No inbound URL, no shared secrets, and it resumes work
232
243
  the current process never started. Here is a complete `agent.mjs` you can run:
@@ -417,11 +428,12 @@ the store you configured, so two workers sharing one Redis or Postgres store can
417
428
  other's work. (Through 0.1.x this was a separate local-filesystem journal, which meant two
418
429
  workers kept two disjoint journals and neither could. `reportDir` is accepted and ignored now.)
419
430
 
420
- **Parked arguments must be JSON-serializable.** A suspended action is signed and then written to
421
- disk as JSON, so values that don't survive `JSON.stringify` round-tripping — `Date`, `Map`,
422
- `undefined`, `NaN`, class instances with custom `toJSON` — come back changed and fail the
423
- integrity check on resume (a refusal, never a wrong execution). Pass plain data; rehydrate
424
- inside your function.
431
+ **Parked arguments must be JSON-serializable — and that is checked BEFORE routing.** Values that
432
+ do not survive JSON with their meaning intact — `Date`, `Map`, `undefined`, `NaN`, class instances,
433
+ functions — are refused with `DecidioUnsafeArgumentsError` before any decision exists and before any
434
+ network call (since 0.1.12): nothing is parked, nothing is routed. The parked payload is also
435
+ signed, so a file edited on disk fails the integrity check on resume (a refusal, never a wrong
436
+ execution). Pass plain data; rehydrate inside your function.
425
437
 
426
438
  **Payload integrity.** When a routed action is parked, the SDK signs its arguments (bound to the
427
439
  decision id) with your agent key; on resume it **refuses to execute** if the parked payload was
@@ -445,6 +457,97 @@ for engines you don't. Each adapter's shipped `.d.ts` header documents its wirin
445
457
  @decidio/sdk quickstart` is the fastest runnable end-to-end.
446
458
 
447
459
 
460
+
461
+
462
+ ## The durable store is a revision chain (since 0.3.2)
463
+
464
+ `FilePendingStore` keeps each decision's execution record as an **append-only revision chain** —
465
+ `<id>.rev.1`, `<id>.rev.2`, … — and the head is the highest number. Every state change reads the
466
+ head, validates against exactly that record, writes the successor to a temp file, and publishes it
467
+ with `link()` to the next revision number. `link()` fails if that name already exists, so **exactly
468
+ one writer wins each revision**, with no lock, no timeout and no daemon — on any volume that
469
+ supports hard links (NTFS, ext4, APFS, XFS; not FAT/exFAT, and not every network or FUSE mount —
470
+ on those every write fails closed rather than silently degrading). A loser re-reads the new head
471
+ and validates again — against what actually happened, not a snapshot. **No revision file is ever
472
+ deleted:** a name that could be reused is a race that could be lost, so the chain is genuinely
473
+ append-only and a decision's whole history stays readable in place.
474
+
475
+ This replaced the 0.2/0.3 one-marker-per-state layout after an external retest showed why it had to:
476
+ that layout made a transition in three separate steps (read the markers, write the new one, sweep
477
+ the weaker ones), so a process holding `claimed_preinvoke` could validate its snapshot, lose to a
478
+ second process that durably wrote *and returned* a denial, then write `invoking` anyway — and its
479
+ sweep deleted the tombstone. The SDK reported a denial as final and ran the denied action. Every
480
+ *sequential* schedule was safe; the window was inside one transition. The chain has no such window:
481
+ nothing ever deletes the head, and the rule that refuses a denied decision now runs against the
482
+ denial that just landed.
483
+
484
+ **Upgrading.** A directory in the older layout is migrated once, at the first construction of a
485
+ 0.3.2 store: each decision's markers become the head of a fresh chain and the markers are removed.
486
+ Corrupt markers are left exactly as found and the decision reads as unreadable until a person
487
+ inspects it. **Stop every older worker that shares the directory BEFORE the first 0.3.2 process
488
+ constructs its store, then upgrade them together.** The one-time migration reads the markers it
489
+ finds and removes exactly those; an older SDK still writing leaves a marker beside a chain, and
490
+ that decision is then *unreadable* by design — neither reading can be trusted over the other, so
491
+ the store refuses to pick one.
492
+
493
+ **Custom stores.** The `PendingStore` contract has said since 0.2.0 that every method must be atomic
494
+ against concurrent callers. The shipped file store did not honor that until now; a custom store must.
495
+ The deterministic two-process schedules that proved the defect live in the Decidio source
496
+ repository's conformance suite (not part of this package): a denial landing while an invoker is
497
+ paused between validating and publishing; two conflicting settlements from one head; and a denier
498
+ paused while the approver publishes four revisions past it. A store that cannot pass those schedules
499
+ is not atomic, whatever its methods say.
500
+
501
+ **Sizing envelope.** Nothing deletes a revision, so each decision leaves a handful of small files (one
502
+ per transition, typically four to six) and a head lookup lists the directory. That is comfortable for
503
+ tens of thousands of decisions on a local disk; beyond that, or for any deployment that shares a store
504
+ across hosts, use a transactional custom `PendingStore`. The tested envelope is a **single host on a local
505
+ filesystem with hard links** — network, FUSE and multi-host volumes are outside it, and their link
506
+ semantics have not been exercised. Never compact the directory while a worker runs; if compaction is
507
+ ever wanted it is an offline job with every writer stopped.
508
+
509
+ ## Reading a receipt
510
+
511
+ The cryptography is only half the evidence. These are the fields that an outside reader has
512
+ actually misread on a first run, and what each one means.
513
+
514
+ **`ratified: yes` next to `deciders: 0` is not a contradiction.** `deciders` is the list of people
515
+ nominated **in advance** to decide. When an agent's request is *routed* rather than pre-assigned,
516
+ that list is empty — nobody was nominated, because the request had not happened yet. The person who
517
+ actually decided is the **ratifier** (`ratifiedBy`). So a receipt for a human-approved action reads
518
+ `deciders: 0, ratified: yes`, and the human is recorded, in the field named for the role they
519
+ played. The verifier prints this explanation whenever the pair appears.
520
+
521
+ **`appliedLimits` is what the request was MEASURED AGAINST, not a rule that fired.** It holds up to
522
+ three entries, and only those that have a value: `requested_amount` (the amount actually asked for),
523
+ `auto_approve_threshold` and `hard_cap` (the ceilings on the agent's own token). So a decision routed
524
+ to a human because *no rule matched* can still list an `auto_approve_threshold` — that is the bar the
525
+ request was compared to and did not clear, not a limit that was applied to it. Read
526
+ `requested_amount` as the ask, the other two as ceilings.
527
+
528
+ **`firedRules` is what the policy engine actually matched — for any verdict.** It is not an
529
+ auto-approve indicator: a request **blocked by the universal backstop** (no active policy governs
530
+ agents at all) carries that backstop rule here, so non-empty does not mean "approved" and empty does
531
+ not mean "blocked". The field that states the
532
+ disposition is `authority.result`; `firedRules` tells you which named, versioned rules produced it.
533
+
534
+ **`payloadSchemaUri` is a `urn:`, not a URL.** It is content-addressed —
535
+ `urn:decidio:sealed-payload:1:sha256:<hash>` — so it *identifies* the exact schema the record was
536
+ sealed under and pins it against substitution. It does not locate a document, and nothing will
537
+ resolve it. (Shipping the schema alongside the verifier so you can check the hash yourself is on
538
+ the roadmap; today the URN is an identifier only, and this note exists so it does not look broken.)
539
+
540
+ **`prevHash` proves a pointer, not an ancestry.** A non-null `prevHash` shows this record was
541
+ sealed after a specific predecessor, but a single-file verification cannot check that predecessor
542
+ exists or is what it claims. Verify a chain (`verify a.json b.json c.json`) when the sequence
543
+ matters.
544
+
545
+ **Pinning is trust-on-first-use.** The first time you fetch a receipt, the record *and* the issuer
546
+ DID arrive in the same authenticated response — so that first check trusts the connection, not the
547
+ key. What pinning buys is everything afterwards: record the DID once, out of band if you can, and
548
+ every later verification is checked against a key you chose rather than one you were handed. The
549
+ verifier says so on every unpinned run.
550
+
448
551
  ## Upgrading from 0.2.x to 0.3.0
449
552
 
450
553
  0.3.0 is a breaking release. Every item below changes behaviour a 0.2.x caller may depend on, so
@@ -455,8 +558,10 @@ verdict, reason?)` is now REQUIRED — it writes the tombstone that makes a deni
455
558
  permanently unclaimable, and a store that cannot record a denial cannot uphold the guarantee, so
456
559
  this is refused at construction rather than deep inside a resolve. `delete` also takes an optional
457
560
  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.
561
+ payload, and `settle` takes `workerStopped`. The shipped stores show the shape; the ownership
562
+ corpus that pins these rules (claim fencing, terminal denial, advance-only transitions, settle
563
+ preconditions) lives in the Decidio source repository and is not part of this package — the rules
564
+ themselves are the ones stated above.
460
565
 
461
566
  **`recover()` now throws instead of returning `[]`** when the ledger cannot be read
462
567
  (`DecidioRecoveryUnavailableError`). `[]` and "I cannot tell you what you owe" are the same value
@@ -495,8 +600,9 @@ that fits every other confirm error.
495
600
 
496
601
  - **Engine adapters are BETA.** They share the core's gate semantics — the verdict allow-list,
497
602
  the canonical argument model, the transport rule and evidence handling are ONE implementation,
498
- and a conformance matrix runs the same malformed-verdict, unsafe-argument and plaintext-transport
499
- battery against every door. What is *not* yet verified is the engine integration itself: those
603
+ and a conformance matrix runs the same malformed-verdict, unsafe-argument, plaintext-transport and
604
+ malformed-description (blank action, negative amount) battery against every door. What is *not* yet
605
+ verified is the engine integration itself: those
500
606
  paths are exercised against structural mocks, not live LangGraph/Temporal/Inngest/OpenAI
501
607
  runtimes. Use the core path for production until that changes. Each adapter is bound through its
502
608
  own `@decidio/sdk/<engine>` subpath (they need the engine handle at call time); no engine at all?
@@ -545,7 +651,7 @@ manager for production.
545
651
 
546
652
  ## Errors
547
653
 
548
- - `DecidioBlockedError` — policy blocked the action (incl. the universal deny-by-default backstop).
654
+ - `DecidioBlockedError` — policy blocked the action: a named block rule, or the universal backstop that fires when NO active policy governs agents at all (fail closed, no human involved). Distinct from routing-by-default — with an active policy and no matching auto-approve rule, a request is not blocked, it is ROUTED to a person.
549
655
  - `DecidioRejectedError` — a human rejected the routed action.
550
656
  - `DecidioSuspendedError` — (durable mode) the action was routed and is parked for async approval; the process may exit and resume later. Not a failure.
551
657
  - `DecidioTimeoutError` — (blocking mode only) no human decided within `pollTimeoutMs` (default 10 min); the decision stays open in Decidio, nothing executed locally.
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, carriesCredential, canonicalize, readConfirmResponse, buildConfirmBody, decisionIdProblem } from "../index.js";
2
+ import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, carriesCredential, canonicalize, assertDescribed, readConfirmResponse, buildConfirmBody, decisionIdProblem } from "../index.js";
3
3
  export const AUTHORIZE_VERDICTS = ["proceed", "route", "block"];
4
4
  export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
5
5
  const a = auth;
@@ -14,7 +14,8 @@ 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
+ assertDescribed(ctx);
18
19
  assertSafeTransport(config.apiUrl, carriesCredential(config));
19
20
  const f = config.fetchImpl ?? fetch;
20
21
  const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
@@ -15,7 +15,7 @@ interface RunStateLike {
15
15
  * park the serialized state agent-side on `route`, resume on Decidio's approval.
16
16
  *
17
17
  * const state = result.state; // or RunState from the run
18
- * const { resolved, pending } = await gateInterruptions(config, state, describe);
18
+ * const { resolved, pending } = await authorizeInterruptions(config, state, describe);
19
19
  * if (pending.length) { await store.put(decisionId, state.toString()); return; } // suspend
20
20
  * result = await run(agent, state); // all resolved → resume
21
21
  * // on Decidio approval webhook: state = await RunState.fromString(agent, parked);