@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 +119 -13
- package/dist/adapters/_gate.js +3 -2
- package/dist/adapters/openai.d.ts +1 -1
- package/dist/cli.js +292 -29
- package/dist/errors.js +5 -1
- package/dist/evidence.d.ts +9 -0
- package/dist/evidence.js +5 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +10 -7
- package/dist/inngest.js +2 -0
- package/dist/resume.d.ts +58 -27
- package/dist/resume.js +273 -161
- package/dist/signing.d.ts +4 -0
- package/dist/signing.js +23 -2
- package/package.json +3 -3
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
|
|
152
|
-
sealed by your workspace's issuer and
|
|
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.
|
|
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
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
|
459
|
-
|
|
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
|
|
499
|
-
battery against every door. What is *not* yet
|
|
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
|
|
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.
|
package/dist/adapters/_gate.js
CHANGED
|
@@ -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
|
|
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);
|