@decidio/sdk 0.1.9 → 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/README.md +134 -29
- package/dist/adapters/_gate.d.ts +25 -1
- package/dist/adapters/_gate.js +69 -26
- package/dist/adapters/langgraph.js +2 -15
- package/dist/adapters/openai.js +2 -5
- package/dist/adapters/temporal.js +18 -20
- package/dist/canonical.d.ts +18 -0
- package/dist/canonical.js +104 -0
- package/dist/cli.js +146 -272
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +18 -0
- package/dist/evidence.d.ts +41 -0
- package/dist/evidence.js +57 -0
- package/dist/guard.d.ts +29 -8
- package/dist/guard.js +28 -35
- package/dist/index.d.ts +19 -10
- package/dist/index.js +49 -113
- package/dist/inngest.js +6 -56
- package/dist/resume.d.ts +83 -26
- package/dist/resume.js +213 -150
- package/dist/signing.d.ts +72 -6
- package/dist/signing.js +107 -41
- package/openapi.yaml +47 -1
- package/package.json +3 -9
package/dist/errors.d.ts
ADDED
|
@@ -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>;
|
package/dist/evidence.js
ADDED
|
@@ -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
|
@@ -20,9 +20,14 @@ export interface GuardFacadeConfig {
|
|
|
20
20
|
fetchImpl?: typeof fetch;
|
|
21
21
|
}
|
|
22
22
|
export interface ProtectOptions {
|
|
23
|
-
/** null/"generic" → the core gate; engine adapters (langgraph/openai/temporal/inngest
|
|
24
|
-
* live in @decidio/sdk
|
|
25
|
-
|
|
23
|
+
/** null/"generic" → the core gate; engine adapters (langgraph/openai/temporal/inngest)
|
|
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. */
|
|
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";
|
|
28
33
|
/** The action name this wrapper produces, declared UP FRONT so the resume registry is populated
|
|
@@ -31,18 +36,31 @@ export interface ProtectOptions {
|
|
|
31
36
|
* `describe` (which names the action) cannot run without the original call arguments. Declare
|
|
32
37
|
* it and a restarted process resumes work it never originated. */
|
|
33
38
|
action?: string;
|
|
34
|
-
/** Blocking-mode poll ceiling in ms (default 600000 = 10 min).
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* fast — and the Python twin rejected the same call loudly, a twin divergence). */
|
|
39
|
+
/** Blocking-mode poll ceiling in ms (default 600000 = 10 min). Set it here so a CI pipeline
|
|
40
|
+
* fails fast instead of blocking the full 10-minute default while a routed action waits.
|
|
41
|
+
* Accepted identically by the Python SDK (poll_timeout, seconds). */
|
|
38
42
|
pollTimeoutMs?: number;
|
|
39
43
|
/** Blocking-mode poll interval in ms (default 3000). */
|
|
40
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;
|
|
41
50
|
}
|
|
42
51
|
export declare class Guard {
|
|
43
52
|
config: GuardFacadeConfig;
|
|
44
|
-
|
|
53
|
+
private _resume?;
|
|
45
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);
|
|
46
64
|
static fromEnv(): Guard;
|
|
47
65
|
/** Register a raw fn under its action name so a COLD process (one that never made the original
|
|
48
66
|
* call) can resume it. Call this at startup beside `guard.worker()` — or pass
|
|
@@ -63,6 +81,9 @@ export declare class Guard {
|
|
|
63
81
|
stop: () => void;
|
|
64
82
|
};
|
|
65
83
|
private ensureResume;
|
|
84
|
+
/** Throws when the facade has no gate URL — at USE time, not import time, so `import { guard }`
|
|
85
|
+
* stays safe in envless contexts. */
|
|
86
|
+
private assertConfigured;
|
|
66
87
|
protect<A extends any[], R>(fn: (...a: A) => Promise<R>, describe: (...a: A) => ApprovalContext, opts?: ProtectOptions): (...a: A) => Promise<R>;
|
|
67
88
|
}
|
|
68
89
|
/** Preconfigured singleton — `import { guard } from "@decidio/sdk"`. */
|
package/dist/guard.js
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
|
-
// The Guard facade — `guard.protect(fn, describe)`, the one agnostic line (TS mirror
|
|
2
|
-
// of decidio-guard Python). A thin layer over the verified primitives: durable mode
|
|
3
|
-
// builds a resume controller and suspends; blocking mode polls (opt-in, loud). The
|
|
4
|
-
// agent executes its own action; Decidio gates + records + signals — never executes.
|
|
5
1
|
import { withApproval } from "./index.js";
|
|
6
2
|
import { createDecidioResume, FilePendingStore } from "./resume.js";
|
|
7
3
|
export class Guard {
|
|
8
4
|
config;
|
|
9
|
-
|
|
5
|
+
_resume;
|
|
10
6
|
constructor(config, resume) {
|
|
11
7
|
this.config = config;
|
|
12
|
-
this.
|
|
8
|
+
this._resume = resume;
|
|
13
9
|
}
|
|
10
|
+
get resume() { return this.ensureResume(); }
|
|
11
|
+
set resume(r) { this._resume = r; }
|
|
14
12
|
static fromEnv() {
|
|
15
13
|
return new Guard({
|
|
16
|
-
apiUrl: process.env.DECIDIO_API_URL ?? "
|
|
14
|
+
apiUrl: process.env.DECIDIO_API_URL ?? "",
|
|
17
15
|
agentId: process.env.DECIDIO_AGENT_ID ?? "agent",
|
|
18
16
|
apiToken: process.env.DECIDIO_API_TOKEN,
|
|
19
17
|
sourceSystem: process.env.DECIDIO_SOURCE_SYSTEM,
|
|
@@ -26,39 +24,37 @@ export class Guard {
|
|
|
26
24
|
appUrl: process.env.DECIDIO_APP_URL,
|
|
27
25
|
});
|
|
28
26
|
}
|
|
29
|
-
/** Register a raw fn under its action name so a COLD process (one that never made the original
|
|
30
|
-
* call) can resume it. Call this at startup beside `guard.worker()` — or pass
|
|
31
|
-
* `{ action }` to `protect(...)`, which does it for you at wrap time. */
|
|
32
27
|
register(action, fn) { this.ensureResume().register(action, fn); }
|
|
33
|
-
/** Mount on a node/Express/Fastify route: `app.post("/decidio/resume", guard.resumeHandler())`. */
|
|
34
28
|
resumeHandler() { return this.ensureResume().webhookHandler(); }
|
|
35
|
-
/** Mount on a Next.js/Hono/CF/Bun route: `export const POST = (req) => guard.resumeFetchHandler()(req)`. */
|
|
36
29
|
resumeFetchHandler() { return this.ensureResume().fetchHandler(); }
|
|
37
|
-
/** Durable resume with NOTHING to host: a background watcher polls Decidio outward and finishes
|
|
38
|
-
* any parked approval — so a suspended action survives a process restart. One line at startup.
|
|
39
|
-
* Production may swap this for the webhook (resumeUrl); the suspend/resume shape is identical. */
|
|
40
30
|
worker(opts) {
|
|
41
31
|
return this.ensureResume().worker(opts);
|
|
42
32
|
}
|
|
43
33
|
ensureResume() {
|
|
44
|
-
|
|
34
|
+
this.assertConfigured();
|
|
35
|
+
if (!this._resume) {
|
|
45
36
|
const c = this.config;
|
|
46
|
-
this.
|
|
37
|
+
this._resume = createDecidioResume({
|
|
47
38
|
apiUrl: c.apiUrl, apiToken: c.apiToken, webhookSecret: c.webhookSecret,
|
|
48
39
|
resumeUrl: c.resumeUrl, store: new FilePendingStore(c.pendingDir ?? ".decidio-pending"),
|
|
49
|
-
// Carry the signing identity so the durable/cold confirm on resume is signed too (incl. the
|
|
50
|
-
// workspace binding — D-B3.6 — so a cold-resume confirm proof is tenant-scoped as well).
|
|
51
40
|
agentId: c.agentId, agentKey: c.agentKey, agentDid: c.agentDid, workspaceId: c.workspaceId,
|
|
52
41
|
fetchImpl: c.fetchImpl,
|
|
53
42
|
});
|
|
54
43
|
}
|
|
55
|
-
return this.
|
|
44
|
+
return this._resume;
|
|
45
|
+
}
|
|
46
|
+
assertConfigured() {
|
|
47
|
+
if (!this.config.apiUrl)
|
|
48
|
+
throw new Error("[decidio] no gate URL configured — set DECIDIO_API_URL (the hosted sandbox is https://decidio-api.onrender.com) or run `npx @decidio/sdk init`, then start your process with `node --env-file=.env`. Nothing was routed or executed.");
|
|
56
49
|
}
|
|
57
50
|
protect(fn, describe, opts) {
|
|
51
|
+
this.assertConfigured();
|
|
58
52
|
if (opts?.adapter && opts.adapter !== "generic") {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
53
|
+
const REAL = ["langgraph", "openai", "temporal", "inngest"];
|
|
54
|
+
if (REAL.includes(opts.adapter)) {
|
|
55
|
+
throw new Error(`[decidio] adapter "${opts.adapter}" must be bound via the @decidio/sdk/${opts.adapter} subpath (it needs the engine handle). See that adapter's exports.`);
|
|
56
|
+
}
|
|
57
|
+
throw new Error(`[decidio] unknown adapter "${opts.adapter}" — the available engine adapters are ${REAL.map((a) => `"${a}"`).join(", ")} (each via its @decidio/sdk/<name> subpath), or omit the option for the core gate.`);
|
|
62
58
|
}
|
|
63
59
|
const c = this.config;
|
|
64
60
|
const base = {
|
|
@@ -67,22 +63,19 @@ export class Guard {
|
|
|
67
63
|
pollTimeoutMs: opts?.pollTimeoutMs, pollIntervalMs: opts?.pollIntervalMs,
|
|
68
64
|
};
|
|
69
65
|
if ((opts?.mode ?? "durable") === "blocking") {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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);
|
|
75
72
|
}
|
|
76
|
-
// durable: refuse-don't-downgrade — always wire a resume transport, never silently block.
|
|
77
73
|
const resume = this.ensureResume();
|
|
78
|
-
// WRAP-TIME registration when the action is declared: the call-time registration inside
|
|
79
|
-
// withApproval only helps the process that made the original call, so a restarted worker
|
|
80
|
-
// found an empty registry and answered no_handler — the documented restart-survival pattern
|
|
81
|
-
// could not actually work without this (or an explicit guard.register at startup).
|
|
82
74
|
if (opts?.action)
|
|
83
75
|
resume.register(opts.action, fn);
|
|
84
|
-
|
|
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);
|
|
85
79
|
}
|
|
86
80
|
}
|
|
87
|
-
/** Preconfigured singleton — `import { guard } from "@decidio/sdk"`. */
|
|
88
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 {
|
|
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". */
|
|
@@ -75,15 +80,13 @@ export declare class DecidioTimeoutError extends Error {
|
|
|
75
80
|
decisionId: string;
|
|
76
81
|
constructor(decisionId: string);
|
|
77
82
|
}
|
|
78
|
-
/** A transport failure with GUIDANCE instead of a raw
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* whether anything could have executed. */
|
|
83
|
+
/** A transport failure with GUIDANCE instead of a raw fetch stack. Carries the endpoint, the
|
|
84
|
+
* operation, the underlying code, and the safety state a caller cares about most: whether
|
|
85
|
+
* anything could have executed. */
|
|
82
86
|
/** The workspace's governed-action allowance is exhausted (HTTP 429, code AGENT_RATE). The
|
|
83
|
-
* server says exactly how much and until when —
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* recorded, or executed; retry after `resetAt`. */
|
|
87
|
+
* server says exactly how much and until when — `limit`, `remaining` and `resetAt` relay its
|
|
88
|
+
* answer verbatim so automation never has to parse a message. Nothing was routed, recorded,
|
|
89
|
+
* or executed; retry after `resetAt`. */
|
|
87
90
|
export declare class DecidioRateLimitError extends Error {
|
|
88
91
|
limit: number | undefined;
|
|
89
92
|
remaining: number | undefined;
|
|
@@ -104,4 +107,10 @@ export declare class DecidioNetworkError extends Error {
|
|
|
104
107
|
/** `<appUrl>/d/<decisionId>` — the deep link that opens this decision directly, on the web app or
|
|
105
108
|
* the phone build. Null when DECIDIO_APP_URL is not configured, never a half-built URL. */
|
|
106
109
|
export declare function decisionUrl(appUrl: string | undefined, decisionId: string): string | null;
|
|
107
|
-
export declare function withApproval<A extends any[], R>(fn: (...args: A) => Promise<R>, describe: (...args: A) => ApprovalContext, config: GuardConfig
|
|
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>;
|
package/dist/index.js
CHANGED
|
@@ -1,37 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
// action: "createOpportunity", amount: a.amount, scope: "Opportunity",
|
|
6
|
-
// }));
|
|
7
|
-
//
|
|
8
|
-
// NOTE: `guard` is the preconfigured SINGLETON (a Guard instance, see guard.ts). It is NOT a
|
|
9
|
-
// GuardConfig, so it cannot be passed as withApproval's third argument — this comment used to
|
|
10
|
-
// show exactly that, and the error propagated into the product's own copy-paste snippets.
|
|
11
|
-
// `withApproval(fn, describe, config)` is the lower-level export and takes a config OBJECT.
|
|
12
|
-
//
|
|
13
|
-
// On call, the wrapper asks Decidio's gate to authorize the action. Decidio's
|
|
14
|
-
// policy engine (Cedar / computeChain) returns proceed | route | block:
|
|
15
|
-
// • proceed → the wrapped fn runs immediately (auto-approved under policy)
|
|
16
|
-
// • route → the wrapper WAITS for a human to approve in Decidio's queue, then
|
|
17
|
-
// runs the fn (or throws if rejected)
|
|
18
|
-
// • block → the wrapper throws; the fn never runs
|
|
19
|
-
// The wrapper is a LISTENER: it captures the real source-system response the wrapped
|
|
20
|
-
// fn returns and reports it to Decidio, which seals an `application_confirmed`
|
|
21
|
-
// Authority Receipt (Decidio minimizes + tokenizes before sealing — the immutable
|
|
22
|
-
// seal never stores raw payloads). The agent executes its own action; Decidio
|
|
23
|
-
// authorizes, records, and (optionally) independently verifies it. Decidio holds NO
|
|
24
|
-
// write credentials for your system — only the authority decision and the record.
|
|
25
|
-
//
|
|
26
|
-
// Framework-agnostic by design: it wraps at the tool-call boundary, so it works with
|
|
27
|
-
// any agent runtime (LangGraph, OpenAI Agents SDK, CrewAI, a raw tool loop) and any
|
|
28
|
-
// action (Salesforce, M365, SAP, a DB write) — the wrapper doesn't care what the fn
|
|
29
|
-
// does, only that it returns the source response.
|
|
30
|
-
import { DecidioSuspendedError, assertConfirmRecorded } from "./resume.js";
|
|
31
|
-
import { signBoundProof, signConfirm, assertJsonSafe } from "./signing.js";
|
|
1
|
+
import { DecidioSuspendedError } from "./resume.js";
|
|
2
|
+
import { signBoundProof, signConfirm, assertSafeTransport } from "./signing.js";
|
|
3
|
+
import { canonicalize, canonicalizeArgs } from "./canonical.js";
|
|
4
|
+
import { readConfirmResponse, buildConfirmBody } from "./evidence.js";
|
|
32
5
|
export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
|
|
33
6
|
export { guard, Guard } from "./guard.js";
|
|
34
|
-
export {
|
|
7
|
+
export { DecidioHttpError } from "./errors.js";
|
|
8
|
+
import { DecidioHttpError } from "./errors.js";
|
|
9
|
+
export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport } from "./signing.js";
|
|
10
|
+
export { canonicalize, canonicalizeArgs, canonicalJson, DecidioUnsafeArgumentsError } from "./canonical.js";
|
|
11
|
+
export { readConfirmResponse, buildConfirmBody, describeTierGap, EVIDENCE_TIERS } from "./evidence.js";
|
|
35
12
|
export class DecidioBlockedError extends Error {
|
|
36
13
|
reason;
|
|
37
14
|
decisionId;
|
|
@@ -61,15 +38,6 @@ export class DecidioTimeoutError extends Error {
|
|
|
61
38
|
}
|
|
62
39
|
}
|
|
63
40
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
64
|
-
/** A transport failure with GUIDANCE instead of a raw undici stack (round-4 Codex catch: the CLI
|
|
65
|
-
* wrapped network errors kindly but the runtime leaked `TypeError: fetch failed`). Carries the
|
|
66
|
-
* endpoint, the operation, the underlying code, and the safety state a caller cares about most:
|
|
67
|
-
* whether anything could have executed. */
|
|
68
|
-
/** The workspace's governed-action allowance is exhausted (HTTP 429, code AGENT_RATE). The
|
|
69
|
-
* server says exactly how much and until when — relayed here instead of discarded (round-2
|
|
70
|
-
* blind catch: this surfaced as a bare `Error` with no decisionId, the tour misdiagnosed it as
|
|
71
|
-
* a connectivity failure, and doctor then corroborated the wrong suspect). Nothing was routed,
|
|
72
|
-
* recorded, or executed; retry after `resetAt`. */
|
|
73
41
|
export class DecidioRateLimitError extends Error {
|
|
74
42
|
limit;
|
|
75
43
|
remaining;
|
|
@@ -88,9 +56,6 @@ export class DecidioNetworkError extends Error {
|
|
|
88
56
|
op;
|
|
89
57
|
code;
|
|
90
58
|
constructor(endpoint, op, code, cause) {
|
|
91
|
-
// A refused localhost connection almost always means MISSING CONFIG, not a broken network
|
|
92
|
-
// (blind-review catch: forgetting `--env-file .env` produced advice about network debugging).
|
|
93
|
-
// Name the likely cause before the generic one.
|
|
94
59
|
super(`[decidio] could not reach ${endpoint} during ${op}${code ? ` (${code})` : ""}. ` +
|
|
95
60
|
(/localhost|127\.0\.0\.1/.test(endpoint) && code === "ECONNREFUSED"
|
|
96
61
|
? `A refused localhost connection usually means the SDK never saw your config: if your settings live in .env, run node with --env-file=.env (or export DECIDIO_API_URL — the hosted sandbox is https://decidio-api.onrender.com). `
|
|
@@ -115,6 +80,7 @@ async function reach(f, url, op, init) {
|
|
|
115
80
|
}
|
|
116
81
|
async function post(cfg, path, body) {
|
|
117
82
|
const f = cfg.fetchImpl ?? fetch;
|
|
83
|
+
assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
|
|
118
84
|
const res = await reach(f, cfg.apiUrl.replace(/\/$/, "") + path, `POST ${path}`, {
|
|
119
85
|
method: "POST",
|
|
120
86
|
headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
|
@@ -124,47 +90,58 @@ async function post(cfg, path, body) {
|
|
|
124
90
|
const b = await res.json().catch(() => null);
|
|
125
91
|
throw new DecidioRateLimitError(b?.limit, b?.remaining, b?.resetAt, b?.message);
|
|
126
92
|
}
|
|
127
|
-
if (!res.ok)
|
|
128
|
-
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
const raw = await res.text().catch(() => "");
|
|
95
|
+
let parsed = raw;
|
|
96
|
+
try {
|
|
97
|
+
parsed = raw ? JSON.parse(raw) : undefined;
|
|
98
|
+
}
|
|
99
|
+
catch { }
|
|
100
|
+
throw new DecidioHttpError(res.status, path, parsed);
|
|
101
|
+
}
|
|
129
102
|
return res.json();
|
|
130
103
|
}
|
|
131
104
|
async function get(cfg, path) {
|
|
132
105
|
const f = cfg.fetchImpl ?? fetch;
|
|
106
|
+
assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
|
|
133
107
|
const res = await reach(f, cfg.apiUrl.replace(/\/$/, "") + path, `GET ${path}`, {
|
|
134
108
|
headers: { ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
|
135
109
|
});
|
|
136
110
|
if (!res.ok)
|
|
137
|
-
throw new
|
|
111
|
+
throw new DecidioHttpError(res.status, path);
|
|
138
112
|
return res.json();
|
|
139
113
|
}
|
|
140
|
-
/**
|
|
141
|
-
* Wrap an async action behind the Decidio gate. `describe` extracts the approval
|
|
142
|
-
* context (action, amount, scope) from the call arguments. Returns a function with
|
|
143
|
-
* the SAME signature as `fn` — drop-in.
|
|
144
|
-
*/
|
|
145
|
-
/** `<appUrl>/d/<decisionId>` — the deep link that opens this decision directly, on the web app or
|
|
146
|
-
* the phone build. Null when DECIDIO_APP_URL is not configured, never a half-built URL. */
|
|
147
114
|
export function decisionUrl(appUrl, decisionId) {
|
|
148
115
|
if (!appUrl)
|
|
149
116
|
return null;
|
|
150
117
|
return `${appUrl.replace(/\/$/, "")}/d/${encodeURIComponent(decisionId)}`;
|
|
151
118
|
}
|
|
152
|
-
export function withApproval(fn, describe, config) {
|
|
119
|
+
export function withApproval(fn, describe, config, opts) {
|
|
153
120
|
const pollInterval = config.pollIntervalMs ?? 2_000;
|
|
154
121
|
const pollTimeout = config.pollTimeoutMs ?? 600_000;
|
|
155
122
|
const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
|
|
156
|
-
return async (...
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
123
|
+
return async (...rawArgs) => {
|
|
124
|
+
const args = canonicalizeArgs(rawArgs);
|
|
125
|
+
let ctx;
|
|
126
|
+
try {
|
|
127
|
+
ctx = describe(...args);
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
if (e instanceof TypeError && /read only|not extensible|object is not extensible|frozen/i.test(e.message ?? "")) {
|
|
131
|
+
throw new TypeError(`[decidio] your describe() callback tried to MODIFY the call arguments (${e.message}). ` +
|
|
132
|
+
`Those arguments are the approved value: they are what a human sees, what the signature covers, ` +
|
|
133
|
+
`what is parked for a durable resume, and what your function ultimately runs on — so changing them ` +
|
|
134
|
+
`here would mean the action that executes is not the action that was approved. Read from the ` +
|
|
135
|
+
`arguments and RETURN the context instead: (inv) => ({ action: "pay", amount: inv.amount }). ` +
|
|
136
|
+
`If you need a modified value at execution time, compute it inside your function. ` +
|
|
137
|
+
`Nothing was routed or executed.`);
|
|
138
|
+
}
|
|
139
|
+
throw e;
|
|
140
|
+
}
|
|
162
141
|
const requesterIdentity = (config.agentKey && config.agentDid)
|
|
163
142
|
? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
|
|
164
143
|
: undefined;
|
|
165
|
-
|
|
166
|
-
// human as null — they would approve something other than what the caller described.
|
|
167
|
-
assertJsonSafe(ctx, "describe(...)");
|
|
144
|
+
canonicalize(ctx, "describe(...)");
|
|
168
145
|
const auth = await post(config, "/agent/authorize", {
|
|
169
146
|
requester: config.agentId, action: ctx.action, amount: ctx.amount,
|
|
170
147
|
scope: ctx.scope, sourceSystem: config.sourceSystem,
|
|
@@ -172,52 +149,28 @@ export function withApproval(fn, describe, config) {
|
|
|
172
149
|
...(requesterIdentity ? { requesterIdentity } : {}),
|
|
173
150
|
...(resumeUrl ? { resumeUrl } : {}),
|
|
174
151
|
});
|
|
175
|
-
// Allow-list, not deny-list: an unrecognised verdict means this SDK is older than the
|
|
176
|
-
// server, and the safe reading of an answer you do not understand is NO. Without this, the
|
|
177
|
-
// day a fourth verdict ships every deployed SDK falls through and executes it.
|
|
178
152
|
if (auth.decision !== "proceed" && auth.decision !== "route" && auth.decision !== "block")
|
|
179
153
|
throw new DecidioBlockedError(`unrecognized gate verdict: ${String(auth.decision)}`, auth.decisionId);
|
|
180
154
|
if (auth.decision === "block")
|
|
181
155
|
throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
|
|
182
|
-
// 2. routed → either SUSPEND for async resume (durable) or BLOCK-poll (interactive)
|
|
183
156
|
if (auth.decision === "route") {
|
|
184
|
-
// ALWAYS hand back the decision id — an agent that suspends and says only "waiting" leaves
|
|
185
|
-
// the operator hunting through a queue, and an agent that says NOTHING (the pre-0.1.2 bug:
|
|
186
|
-
// the print was gated on appUrl, which init never writes) leaves them staring at a silent
|
|
187
|
-
// terminal. The Codex acceptance run failed exactly this claim; the id line is unconditional
|
|
188
|
-
// now, with the deep link when appUrl is configured and the CLI command when not.
|
|
189
157
|
const approvalUrl = decisionUrl(config.appUrl, auth.decisionId);
|
|
190
158
|
if (config.onPending)
|
|
191
159
|
config.onPending({ decisionId: auth.decisionId, receiptId: auth.receiptId, url: approvalUrl });
|
|
192
|
-
// stderr, not stdout: this is SDK diagnostics inside a USER'S process — their stdout
|
|
193
|
-
// belongs to their application output (round-3 blind catch: piping the agent's stdout
|
|
194
|
-
// captured this status line interleaved with app output, while the warning went to stderr).
|
|
195
160
|
else
|
|
196
161
|
console.error(`[decidio] ${ctx.action} is awaiting approval (decision ${auth.decisionId}) — approve it ${approvalUrl ? `at ${approvalUrl}` : `in your Decidio queue, or: npx @decidio/sdk approvals approve ${auth.decisionId}`}`);
|
|
197
|
-
// Durable transport: park the args agent-side, register the raw fn so a (cold)
|
|
198
|
-
// resume can replay it, and SUSPEND. The process may exit now; the controller's
|
|
199
|
-
// webhook handler or poll worker re-executes THIS fn when a human approves.
|
|
200
|
-
// Decidio never executes — it only signals.
|
|
201
162
|
if (config.resume) {
|
|
202
|
-
// Propagate the agent's signing identity to the resume controller so the eventual
|
|
203
|
-
// (possibly cold) confirm on resume is SIGNED too — earning application_confirmed on
|
|
204
|
-
// the durable path, not just inline. (No-op if the controller already has its own.)
|
|
205
163
|
config.resume.agentId ??= config.agentId;
|
|
206
164
|
config.resume.agentKey ??= config.agentKey;
|
|
207
165
|
config.resume.agentDid ??= config.agentDid;
|
|
208
166
|
config.resume.register(ctx.action, fn);
|
|
209
|
-
// park() (not store.put) so the payload is SIGNED — resolve() refuses a pending file
|
|
210
|
-
// whose args no longer match what was routed (round-6 acceptance catch).
|
|
211
167
|
await config.resume.park({
|
|
212
168
|
decisionId: auth.decisionId, receiptId: auth.receiptId,
|
|
213
169
|
action: ctx.action, amount: ctx.amount, scope: ctx.scope, args,
|
|
214
170
|
});
|
|
215
171
|
throw new DecidioSuspendedError(auth.decisionId, ctx.action);
|
|
216
172
|
}
|
|
217
|
-
// Interactive transport (no resume configured): block until decided. Only
|
|
218
|
-
// appropriate for short, watched approvals (demos, synchronous tools).
|
|
219
173
|
const deadline = Date.now() + pollTimeout;
|
|
220
|
-
// eslint-disable-next-line no-constant-condition
|
|
221
174
|
while (true) {
|
|
222
175
|
const st = await get(config, `/agent/status/${encodeURIComponent(auth.decisionId)}`);
|
|
223
176
|
if (st.state === "approved" || st.state === "auto_approved")
|
|
@@ -227,42 +180,25 @@ export function withApproval(fn, describe, config) {
|
|
|
227
180
|
if (st.state === "blocked")
|
|
228
181
|
throw new DecidioBlockedError(st.reason ?? "blocked", auth.decisionId);
|
|
229
182
|
if (Date.now() > deadline) {
|
|
230
|
-
// The founder-visible failure of blocking: the wait has a ceiling, and past it the
|
|
231
|
-
// work is LOST — the decision stays open in Decidio but nothing is parked to resume.
|
|
232
|
-
// Say so, say the limit, and say the durable way out.
|
|
233
183
|
console.warn(`[decidio] blocking wait hit its ${Math.round(pollTimeout / 1000)}s limit — decision ${auth.decisionId} is still open in Decidio, but this process stopped waiting and nothing was executed. Re-run to request again, or use durable resume (guard.worker()) so an approval is never lost.`);
|
|
234
184
|
throw new DecidioTimeoutError(auth.decisionId);
|
|
235
185
|
}
|
|
236
186
|
await sleep(pollInterval);
|
|
237
187
|
}
|
|
238
188
|
}
|
|
239
|
-
// 3. authorized inline (proceed, or interactive route just approved) → run the
|
|
240
|
-
// REAL action and capture the source response (listener)
|
|
241
189
|
const result = await fn(...args);
|
|
242
|
-
// 4. report the captured outcome → Decidio seals application_confirmed
|
|
243
|
-
// (server minimizes + tokenizes the response before sealing). Best-effort:
|
|
244
|
-
// a confirmation failure must not undo a successful business action.
|
|
245
190
|
try {
|
|
246
|
-
// Sign the confirmation too (bound to {action:"confirm", scope:decisionId}) so the gate
|
|
247
|
-
// can PROVE this is the receipt's original requester before granting the wrapper tier
|
|
248
|
-
// (application_confirmed). Without a key the report is capped at agent_asserted server-side.
|
|
249
191
|
const confirmIdentity = signConfirm(auth.decisionId, config);
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
assertConfirmRecorded(confirmBody, auth.decisionId, !!confirmIdentity);
|
|
259
|
-
// An UNSIGNED confirm is server-capped at the weaker agent_asserted tier. Say so at the
|
|
260
|
-
// moment it happens — a silent evidence downgrade was round 3's acceptance FAIL.
|
|
261
|
-
if (!confirmIdentity)
|
|
262
|
-
console.warn(`[decidio] execution evidence for ${auth.decisionId} recorded as agent_asserted — no signing key configured, so the report can't be proven to come from this agent. Set DECIDIO_AGENT_KEY/DID (npx @decidio/sdk init writes them) to earn application_confirmed.`);
|
|
192
|
+
const evidenceOf = (opts?.evidence ?? ((r) => r));
|
|
193
|
+
const reported = canonicalize(evidenceOf(result) ?? null, "evidence(result)");
|
|
194
|
+
const confirmBody = await post(config, "/agent/confirm", buildConfirmBody(config, auth.decisionId, reported, confirmIdentity));
|
|
195
|
+
const ev = readConfirmResponse(confirmBody, { signed: !!confirmIdentity, capturedResponse: reported });
|
|
196
|
+
if (!ev.reportRecorded)
|
|
197
|
+
console.warn(`[decidio] ${auth.decisionId}: ${ev.diagnostic}`);
|
|
198
|
+
else if (!ev.executionConfirmed)
|
|
199
|
+
console.warn(`[decidio] execution evidence for ${auth.decisionId} recorded as ${ev.evidenceTier} — ${ev.diagnostic}`);
|
|
263
200
|
}
|
|
264
201
|
catch (e) {
|
|
265
|
-
// surface but don't throw — the action already happened
|
|
266
202
|
console.warn(`[decidio] confirmation post failed (action succeeded): ${e.message}`);
|
|
267
203
|
}
|
|
268
204
|
return result;
|