@decidio/sdk 0.1.11 → 0.2.0

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
@@ -90,8 +90,9 @@ const payInvoice = guard.protect(
90
90
  { mode: "blocking" }, // blocking = watch the whole loop live in one terminal sitting.
91
91
  ); // Production agents use durable mode instead — see below.
92
92
 
93
- const result = await payInvoice({ id: "INV-2026-001", amount: 86_000 });
94
- console.log("executed after approval:", result);
93
+ const { value, confirmation } = await payInvoice({ id: "INV-2026-001", amount: 86_000 });
94
+ console.log("executed after approval:", value);
95
+ console.log("Decidio recorded it as:", confirmation.evidenceTier); // e.g. application_confirmed
95
96
  ```
96
97
 
97
98
  **4. Trigger it:**
@@ -200,7 +201,7 @@ That's the whole contract: gate → human decision → your execution → owned,
200
201
  - **proceed** — your function runs immediately (auto-approved under a named, versioned rule).
201
202
  - **route** — the action suspends (durable) or waits (blocking) for a human decision in Decidio's queue, then runs your function (or throws `DecidioRejectedError`).
202
203
  - **block** — the wrapper throws `DecidioBlockedError`; your function never runs.
203
- 3. After your function runs, the wrapper reports the **real response it captured** back to Decidio, which records it as `application_confirmed` execution evidence (Decidio minimizes + tokenizes before storing — the immutable record never stores raw payloads).
204
+ 3. After your function runs, the wrapper reports the **real response it captured** back to Decidio (which minimizes + tokenizes before storing — the immutable record never stores raw payloads). The tier that report earns depends on what it can prove: `application_confirmed` when the agent has key material and signs the execution report, `agent_asserted` when it does not, and `execution_unconfirmed` when the reported value carries no usable evidence. The SDK tells you which it got; it never claims the top tier by default.
204
205
 
205
206
  Your agent executes its own action. **Decidio holds no write credentials for your system** — it authorizes the decision, records it, and (optionally) independently verifies it.
206
207
 
@@ -313,13 +314,42 @@ canonicalization preserves **value** semantics, not JavaScript object identity.
313
314
  pointing at the same object arrive as two equal objects, and mutating an argument inside your
314
315
  function does not affect the caller's copy.
315
316
 
316
- **What the SDK can and cannot promise about duplicates.** It prevents *concurrent* execution:
317
- an entry is claimed atomically before it runs, so two workers cannot both execute it and a
318
- duplicate signal finds nothing to replay. It cannot make an external side effect exactly-once —
319
- no client can. If your action succeeds and the process dies before the result is recorded,
320
- whether it ran is not knowable from this machine. So the contract is **at-least-once with
321
- honest recovery**, and your downstream action should be idempotent (the decision id makes a
322
- natural idempotency key).
317
+ **What the SDK can and cannot promise about duplicates.** The guarantee, stated exactly:
318
+
319
+ > Once invocation may have begun, the SDK never automatically invokes it again unless the
320
+ > downstream system provides an idempotency guarantee or an operator explicitly reconciles it.
321
+
322
+ It holds across processes and restarts, not just within one worker. Before your function is
323
+ called the SDK writes a durable `invoking` marker to the pending store; from that moment no
324
+ poll, no duplicate webhook, no fresh process and no worker cycle will call it again. Deletion of
325
+ the parked row is cleanup, not the guarantee — a store whose `delete()` silently fails cannot
326
+ cause a second execution.
327
+
328
+ **Closing the last window yourself.** Pass `withContext: true` and your action receives an
329
+ `ExecutionContext` as its first argument:
330
+
331
+ ```js
332
+ const payInvoice = guard.protect(
333
+ async (ctx, invoice) => stripe.paymentIntents.create(
334
+ { amount: invoice.amount, currency: "usd" },
335
+ { idempotencyKey: ctx.idempotencyKey }, // ← stable across every attempt at this decision
336
+ ),
337
+ (invoice) => ({ action: "payInvoice", amount: invoice.amount }),
338
+ { withContext: true },
339
+ );
340
+ ```
341
+
342
+ `ctx` is `{ decisionId, attemptId, idempotencyKey }`. It is opt-in rather than inferred from your
343
+ function's arity, because guessing wrong would hand a payment call a context object where it
344
+ expected an invoice. `describe` still receives your arguments only — it describes the *request*,
345
+ while the context describes the *execution*.
346
+
347
+ It still cannot make an external side effect exactly-once — no client can. If your action
348
+ succeeds and the process dies before the result is recorded, whether the downstream write
349
+ committed is not knowable from this machine. The difference is what the SDK does about it: that
350
+ decision becomes **`indeterminate`** and stops, rather than being retried into a duplicate
351
+ payment. You settle it with `reconcile()` once you have looked downstream. Pass the decision id
352
+ as your vendor's idempotency key and that window closes on their side too.
323
353
 
324
354
  Recovery is explicit rather than magical:
325
355
 
@@ -332,9 +362,12 @@ for (const item of await guard.resume.recover()) {
332
362
  // "parked" → still waiting on a human; nothing has executed
333
363
  ```
334
364
 
335
- The report a worker sends is journalled *before* the first attempt, so a lost confirmation is
336
- recoverable; and because the journal holds no handler binding, replaying it can only re-send
337
- evidence — never re-run your function.
365
+ The report a worker sends is written to the pending store's execution ledger *before* the first
366
+ send attempt, so a lost confirmation is recoverable; and because that record holds no handler
367
+ binding, replaying it can only re-send evidence — never re-run your function. The ledger lives in
368
+ the store you configured, so two workers sharing one Redis or Postgres store can reconfirm each
369
+ other's work. (Through 0.1.x this was a separate local-filesystem journal, which meant two
370
+ workers kept two disjoint journals and neither could. `reportDir` is accepted and ignored now.)
338
371
 
339
372
  **Parked arguments must be JSON-serializable.** A suspended action is signed and then written to
340
373
  disk as JSON, so values that don't survive `JSON.stringify` round-tripping — `Date`, `Map`,
@@ -380,6 +413,10 @@ for engines you don't. Each adapter's shipped `.d.ts` header documents its wirin
380
413
  `npx @decidio/sdk <cmd>`:
381
414
 
382
415
  - `quickstart` — the guided two-minute tour (gate → approve → execute → proof)
416
+ - `--auto-approve-demo` approves the tour's own synthetic decision so it can run headless; it
417
+ then CLEANS UP the tour agent and token automatically. Pass `--keep-agent` to keep them.
418
+ - An interactive run keeps the agent by default and offers cleanup at the end;
419
+ `quickstart cleanup` works later too.
383
420
  - `init [agentId] [--unique] [--force] [--expires-days N]` — sign in, register, mint the agent token, write `.env` (+ `.gitignore` entries). Refuses to overwrite a *different* agent's key in this directory; `--force` backs the old `.env` up first.
384
421
  - `doctor` — config, connectivity, and what your current token can do
385
422
  - `approvals [--mine] [--json]` / `approvals approve|reject <id> [reason]` — the human side, from your terminal (the list is workspace-wide; `--mine` narrows it to this directory's agent)
@@ -392,7 +429,7 @@ for engines you don't. Each adapter's shipped `.d.ts` header documents its wirin
392
429
  The CLI's stream contract: **stdout carries results only** (safe to pipe — `--json` output is
393
430
  pure JSON); errors, usage shown on *error* paths, and the per-run target-host announcement go
394
431
  to **stderr**. Asked-for help (an explicit `--help`, or invoking the bare CLI) is itself a
395
- result and prints on stdout. Exit codes: 0 success · 1 failure or honest no-op · 2 usage error.
432
+ result and prints on stdout. Exit codes: 0 success · 1 failure or honest no-op · 2 usage error · 3 `quickstart` stopped at the human moment (headless: the decision routed, but nothing executed and no receipt was written, so the run is deliberately not reported as a completed tour).
396
433
 
397
434
  Commands that administer your workspace (`init`, `approvals`, `receipt`) use a **session**
398
435
  sign-in held in memory for that run. What lands on disk in `.env`: the agent's floor-limited
@@ -413,6 +450,7 @@ manager for production.
413
450
  the fix. Anything else is transient, and retrying is safe because a retry creates a *new* request
414
451
  and can never double-execute.
415
452
  - `DecidioNetworkError` — the gate was unreachable. Nothing executed locally.
453
+ - `DecidioUnsafeArgumentsError` — your call arguments cannot survive JSON with their meaning intact, so nothing was routed and nothing ran. The message names the field and the fix. Most often: `NaN`/`Infinity`, `-0`, a `Date`, `Map` or class instance, a `BigInt`, or a sparse array — anything whose serialized form would differ from the value the approval and signature cover. Pass the already-serialized form.
416
454
 
417
455
  ```js
418
456
  try { await payInvoice(inv); }
@@ -430,6 +468,6 @@ your queue.
430
468
  ## Notes
431
469
 
432
470
  - **Framework-agnostic:** it wraps at the tool-call boundary, so it works with any agent runtime (LangGraph, OpenAI Agents SDK, CrewAI, a raw tool loop) and any action (Salesforce, M365, SAP, a DB write).
433
- - **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 (`source_confirmed`) where it holds a read connection.
471
+ - **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.
434
472
  - **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.
435
473
  - **API surface:** the agent-gate REST contract this SDK speaks is described in [`openapi.yaml`](./openapi.yaml), shipped in the package.
@@ -1,4 +1,4 @@
1
- import { type GuardConfig, type ApprovalContext } from "../index.js";
1
+ import { type GuardConfig, type ApprovalContext, type EvidenceOutcome } from "../index.js";
2
2
  /** The ONLY authorization verdicts this SDK understands. A gate answer outside this set means
3
3
  * the SDK does not understand the decision — and an answer you do not understand means NO. */
4
4
  export declare const AUTHORIZE_VERDICTS: readonly ["proceed", "route", "block"];
@@ -16,7 +16,13 @@ export interface GateAuth {
16
16
  * fourth verdict) is the realistic trigger, and it must never mean "run it". */
17
17
  export declare function assertAuthorizeVerdict(auth: unknown, fallbackDecisionId?: string): GateAuth;
18
18
  export declare function gateAuthorize(config: GuardConfig, ctx: ApprovalContext): Promise<GateAuth>;
19
- export declare function gateConfirm(config: GuardConfig, decisionId: string, result: unknown): Promise<void>;
19
+ /** Report an adapter-path execution and RETURN what Decidio recorded.
20
+ *
21
+ * Two 0.2.0 changes, both closing the same gap. It applies the caller's `evidence` mapper — the
22
+ * warning below tells you to use that option, and on every engine adapter it did nothing, because
23
+ * the mapper never reached this function. And it RETURNS the outcome instead of swallowing it, so
24
+ * an adapter can hand its caller the same confirmation the core path now does. */
25
+ export declare function gateConfirm(config: GuardConfig, decisionId: string, result: unknown, evidence?: (r: any) => unknown): Promise<EvidenceOutcome>;
20
26
  /** Normalize a resume payload ({verdict} | "approved" | ...) to a verdict string. */
21
27
  export declare function verdictFrom(payload: any): string;
22
28
  /** Throw the right error for a non-approving verdict; return for approved/auto_approved. */
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, canonicalize, readConfirmResponse, buildConfirmBody } from "../index.js";
2
+ import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, carriesCredential, canonicalize, 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;
@@ -7,11 +7,15 @@ export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
7
7
  if (!a || !AUTHORIZE_VERDICTS.includes(a.decision)) {
8
8
  throw new DecidioBlockedError(`unrecognised authorization verdict ${JSON.stringify(a?.decision ?? null)} — refusing to execute. The gate answers ${AUTHORIZE_VERDICTS.join(" | ")}; anything else means this SDK version does not understand the decision (upgrade @decidio/sdk), and an answer you do not understand is never permission.`, decisionId);
9
9
  }
10
+ const idProblem = decisionIdProblem(a.decisionId);
11
+ if (idProblem) {
12
+ throw new DecidioBlockedError(`the gate answered "${a.decision}" with no usable decisionId (${JSON.stringify(a.decisionId ?? null)} — ${idProblem}) — refusing to execute. Every authorized action must be addressable: without an id it cannot be polled, resumed, confirmed or audited.`, decisionId);
13
+ }
10
14
  return a;
11
15
  }
12
16
  export async function gateAuthorize(config, ctx) {
13
17
  canonicalize(ctx, "describe(...)");
14
- assertSafeTransport(config.apiUrl, !!config.apiToken);
18
+ assertSafeTransport(config.apiUrl, carriesCredential(config));
15
19
  const f = config.fetchImpl ?? fetch;
16
20
  const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
17
21
  const requesterIdentity = (config.agentKey && config.agentDid)
@@ -37,20 +41,32 @@ export async function gateAuthorize(config, ctx) {
37
41
  }
38
42
  return assertAuthorizeVerdict(await res.json(), "unknown");
39
43
  }
40
- export async function gateConfirm(config, decisionId, result) {
44
+ export async function gateConfirm(config, decisionId, result, evidence) {
41
45
  const f = config.fetchImpl ?? fetch;
46
+ const failed = (why, retryable = true) => ({
47
+ reportRecorded: false, executionConfirmed: false, confirmationRetryable: retryable, reportJournalled: false,
48
+ diagnostic: `your action RAN and Decidio does NOT have its execution report (${why}). Record the outcome from your own system; do NOT re-run the action.`,
49
+ });
42
50
  try {
43
- assertSafeTransport(config.apiUrl, !!config.apiToken);
51
+ assertSafeTransport(config.apiUrl, carriesCredential(config));
44
52
  }
45
53
  catch (e) {
46
54
  console.warn(`[decidio] confirm failed (action succeeded): ${e.message}`);
47
- return;
55
+ return failed(e.message, false);
56
+ }
57
+ let reported = null;
58
+ let evidenceProblem;
59
+ try {
60
+ reported = canonicalize((evidence ? evidence(result) : result) ?? null, evidence ? "evidence(result)" : "result");
61
+ }
62
+ catch (e) {
63
+ evidenceProblem = String(e?.message ?? e);
64
+ console.warn(`[decidio] ${decisionId}: the action SUCCEEDED but its result could not be reported as evidence (${evidenceProblem}). Reporting an empty captured response so the execution is still recorded — map the return value with the \`evidence\` option to earn a confirming tier. The action was NOT re-run.`);
48
65
  }
49
66
  try {
50
67
  const requesterIdentity = signConfirm(decisionId, config);
51
68
  if (!requesterIdentity)
52
69
  console.warn(`[decidio] execution evidence for ${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 to earn application_confirmed.`);
53
- const reported = canonicalize(result ?? null, "result");
54
70
  const res = await f(config.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
55
71
  method: "POST",
56
72
  headers: { "content-type": "application/json", ...(config.apiToken ? { authorization: `Bearer ${config.apiToken}` } : {}) },
@@ -58,16 +74,22 @@ export async function gateConfirm(config, decisionId, result) {
58
74
  });
59
75
  if (!res.ok) {
60
76
  console.warn(`[decidio] confirm returned ${res.status} (action succeeded)`);
61
- return;
77
+ return { ...failed(`HTTP ${res.status}`, res.status === 408 || res.status === 429 || res.status >= 500), evidenceError: evidenceProblem };
62
78
  }
63
79
  const ev = readConfirmResponse(await res.json().catch(() => null), { signed: !!requesterIdentity, capturedResponse: reported });
64
80
  if (!ev.reportRecorded)
65
81
  console.warn(`[decidio] ${decisionId}: ${ev.diagnostic}`);
66
- else if (!ev.executionConfirmed)
82
+ else if (!ev.executionConfirmed || ev.executionReportVerified === false)
67
83
  console.warn(`[decidio] execution evidence for ${decisionId} recorded as ${ev.evidenceTier} — ${ev.diagnostic}`);
84
+ if (evidenceProblem)
85
+ console.warn(`[decidio] ${decisionId}: the execution IS recorded, but with an empty captured response — see the evidence note above.`);
86
+ if (evidenceProblem)
87
+ ev.evidenceError = evidenceProblem;
88
+ return ev;
68
89
  }
69
90
  catch (e) {
70
91
  console.warn(`[decidio] confirm failed (action succeeded): ${e.message}`);
92
+ return { ...failed(e.message), evidenceError: evidenceProblem };
71
93
  }
72
94
  }
73
95
  export function verdictFrom(payload) {
@@ -11,7 +11,9 @@ import { type GuardConfig, type ApprovalContext } from "../index.js";
11
11
  * (This wiring doc ships in the .d.ts — the README points here.)
12
12
  */
13
13
  /** Gate `run()` behind Decidio inside a LangGraph node. */
14
- export declare function decidioGate<R>(config: GuardConfig, ctx: ApprovalContext, run: () => Promise<R>): Promise<R>;
14
+ export declare function decidioGate<R>(config: GuardConfig, ctx: ApprovalContext, run: () => Promise<R>, opts?: {
15
+ evidence?: (r: R) => unknown;
16
+ }): Promise<R>;
15
17
  /** Translate Decidio's signed resume webhook into the LangGraph resume Command. The host
16
18
  * maps decisionId → thread_id and passes this to graph.invoke/stream. */
17
19
  export declare function decidioResumeCommand(body: {
@@ -1,7 +1,7 @@
1
1
  import { interrupt, Command } from "@langchain/langgraph";
2
2
  import { DecidioBlockedError } from "../index.js";
3
3
  import { gateAuthorize, gateConfirm, verdictFrom, assertVerdict, checkEngineVersion } from "./_gate.js";
4
- export async function decidioGate(config, ctx, run) {
4
+ export async function decidioGate(config, ctx, run, opts) {
5
5
  const auth = await gateAuthorize(config, ctx);
6
6
  if (auth.decision === "block")
7
7
  throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
@@ -10,7 +10,7 @@ export async function decidioGate(config, ctx, run) {
10
10
  assertVerdict(verdictFrom(resumed), auth.decisionId, resumed?.reason);
11
11
  }
12
12
  const result = await run();
13
- await gateConfirm(config, auth.decisionId, result);
13
+ await gateConfirm(config, auth.decisionId, result, opts?.evidence);
14
14
  return result;
15
15
  }
16
16
  export function decidioResumeCommand(body) {
@@ -1,4 +1,4 @@
1
- import type { GuardConfig, ApprovalContext } from "../index.js";
1
+ import { type GuardConfig, type ApprovalContext } from "../index.js";
2
2
  type ToolApprovalItem = any;
3
3
  interface RunStateLike {
4
4
  getInterruptions(): ToolApprovalItem[];
@@ -41,6 +41,11 @@ export declare function gateInterruptions(config: GuardConfig, state: RunStateLi
41
41
  resolved: Resolved[];
42
42
  pending: Pending[];
43
43
  }>;
44
- /** Apply a Decidio verdict to a resumed RunState (after RunState.fromString). */
44
+ /** Apply a Decidio verdict to a resumed RunState (after RunState.fromString).
45
+ *
46
+ * VALIDATES BEFORE MUTATING. `else state.reject(item)` treated every unrecognised string as a
47
+ * rejection, so a garbled payload, a version-skew verdict or an empty string became a rejection
48
+ * that a caller could not tell apart from a human deciding no — and the RunState was already
49
+ * mutated by the time anyone could look. The recognised set is exactly the one `resume` uses. */
45
50
  export declare function applyResume(state: RunStateLike, item: ToolApprovalItem, verdict: string): void;
46
51
  export {};
@@ -1,4 +1,5 @@
1
1
  import { gateAuthorize, checkEngineVersion } from "./_gate.js";
2
+ import { DecidioBlockedError } from "../index.js";
2
3
  export async function gateInterruptions(config, state, describe) {
3
4
  const resolved = [];
4
5
  const pending = [];
@@ -12,15 +13,22 @@ export async function gateInterruptions(config, state, describe) {
12
13
  state.reject(item);
13
14
  resolved.push({ item, verdict: "blocked", decisionId: auth.decisionId });
14
15
  }
15
- else
16
+ else if (auth.decision === "route")
16
17
  pending.push({ item, decisionId: auth.decisionId });
18
+ else
19
+ throw new DecidioBlockedError(`unhandled authorization verdict ${JSON.stringify(auth.decision)}`, auth.decisionId);
17
20
  }
18
21
  return { resolved, pending };
19
22
  }
20
23
  export function applyResume(state, item, verdict) {
21
- if (verdict === "approved" || verdict === "auto_approved")
24
+ if (verdict === "approved" || verdict === "auto_approved") {
22
25
  state.approve(item);
23
- else
26
+ return;
27
+ }
28
+ if (verdict === "rejected" || verdict === "blocked") {
24
29
  state.reject(item);
30
+ return;
31
+ }
32
+ throw new DecidioBlockedError(`unrecognised resume verdict ${JSON.stringify(verdict)} — refusing to apply it. This SDK understands approved / auto_approved / rejected / blocked; anything else is a version skew, and guessing would turn "I do not understand" into a decision. The RunState was NOT modified.`, "");
25
33
  }
26
34
  checkEngineVersion("openai");
@@ -21,11 +21,15 @@ export declare function installDecidioSignal(): Map<string, {
21
21
  verdict: string;
22
22
  reason?: string;
23
23
  }>;
24
- export interface DecidioGateOpts {
24
+ export interface DecidioGateOpts<R = unknown> {
25
25
  authorize?: () => Promise<GateAuth>;
26
26
  confirm?: (decisionId: string, result: unknown) => Promise<void>;
27
+ /** Map your action's return value to the evidence Decidio records — the same option the core
28
+ * path takes. Every adapter's stderr note named it and no adapter accepted one, so on this
29
+ * path the advice could not be followed. */
30
+ evidence?: (result: R) => unknown;
27
31
  }
28
32
  export declare function decidioGate<R>(config: GuardConfig, ctx: ApprovalContext, run: () => Promise<R>, verdicts: Map<string, {
29
33
  verdict: string;
30
34
  reason?: string;
31
- }>, opts?: DecidioGateOpts): Promise<R>;
35
+ }>, opts?: DecidioGateOpts<R>): Promise<R>;
@@ -22,6 +22,8 @@ export async function decidioGate(config, ctx, run, verdicts, opts = {}) {
22
22
  }
23
23
  const result = await run();
24
24
  if (opts.confirm) {
25
+ if (opts.evidence)
26
+ 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.");
25
27
  await opts.confirm(auth.decisionId, result);
26
28
  if (!confirmWarned) {
27
29
  confirmWarned = true;
@@ -29,7 +31,7 @@ export async function decidioGate(config, ctx, run, verdicts, opts = {}) {
29
31
  }
30
32
  }
31
33
  else {
32
- await gateConfirm(config, auth.decisionId, result);
34
+ await gateConfirm(config, auth.decisionId, result, opts?.evidence);
33
35
  }
34
36
  return result;
35
37
  }
@@ -15,4 +15,4 @@ export declare function canonicalize<T = JsonValue>(value: unknown, path?: strin
15
15
  export declare function canonicalizeArgs(args: readonly unknown[]): JsonValue[];
16
16
  /** RFC 8785-shaped canonical JSON of an already-canonical value — the exact bytes hashed for an
17
17
  * execution report, so both languages produce the same digest. */
18
- export declare function canonicalJson(value: JsonValue): string;
18
+ export declare function canonicalJson(value: JsonValue, path?: string): string;
package/dist/canonical.js CHANGED
@@ -10,6 +10,22 @@ const NOT_ROUTED = "The decision was NOT routed; nothing reached Decidio.";
10
10
  function refuse(path, what, fix) {
11
11
  throw new DecidioUnsafeArgumentsError(`[decidio] ${path} ${what} — the approval payload must be plain JSON so that what a human approves is exactly what executes; ${fix} ${NOT_ROUTED}`);
12
12
  }
13
+ function assertNoLoneSurrogate(s, p) {
14
+ for (let i = 0; i < s.length; i++) {
15
+ const c = s.charCodeAt(i);
16
+ if (c >= 0xd800 && c <= 0xdbff) {
17
+ const n = s.charCodeAt(i + 1);
18
+ if (n >= 0xdc00 && n <= 0xdfff) {
19
+ i++;
20
+ continue;
21
+ }
22
+ refuse(p, `contains an unpaired surrogate (U+${c.toString(16).toUpperCase()})`, "it cannot be encoded as UTF-8, so no wire or seal can carry it — repair the string (it is usually a split emoji or truncated UTF-16).");
23
+ }
24
+ else if (c >= 0xdc00 && c <= 0xdfff) {
25
+ refuse(p, `contains an unpaired surrogate (U+${c.toString(16).toUpperCase()})`, "it cannot be encoded as UTF-8, so no wire or seal can carry it — repair the string (it is usually a split emoji or truncated UTF-16).");
26
+ }
27
+ }
28
+ }
13
29
  export function canonicalize(value, path = "args") {
14
30
  return walk(value, path, new Set());
15
31
  }
@@ -17,8 +33,12 @@ function walk(v, p, seen) {
17
33
  if (v === null)
18
34
  return null;
19
35
  const t = typeof v;
20
- if (t === "boolean" || t === "string")
36
+ if (t === "boolean")
37
+ return v;
38
+ if (t === "string") {
39
+ assertNoLoneSurrogate(v, p);
21
40
  return v;
41
+ }
22
42
  if (t === "number") {
23
43
  const n = v;
24
44
  if (!Number.isFinite(n))
@@ -72,7 +92,11 @@ function walk(v, p, seen) {
72
92
  refuse(`${p}.${key}`, "is a getter/setter", "an accessor is evaluated at serialization time, so the approved value could differ from the executed one — pass a plain data property.");
73
93
  if (!d.enumerable)
74
94
  refuse(`${p}.${key}`, "is a non-enumerable property", "JSON drops it, so the approver would never see it — make it enumerable or remove it.");
75
- out[key] = walk(d.value, `${p}.${key}`, seen);
95
+ assertNoLoneSurrogate(key, `${p}.${key}`);
96
+ Object.defineProperty(out, key, {
97
+ value: walk(d.value, `${p}.${key}`, seen),
98
+ writable: true, enumerable: true, configurable: true,
99
+ });
76
100
  }
77
101
  return out;
78
102
  }
@@ -90,15 +114,19 @@ function deepFreeze(v) {
90
114
  export function canonicalizeArgs(args) {
91
115
  return args.map((a, i) => deepFreeze(walk(a, `args[${i}]`, new Set())));
92
116
  }
93
- export function canonicalJson(value) {
117
+ export function canonicalJson(value, path = "canonicalJson(...)") {
94
118
  if (value === null)
95
119
  return "null";
96
120
  if (typeof value === "boolean" || typeof value === "number")
97
121
  return JSON.stringify(value);
98
- if (typeof value === "string")
122
+ if (typeof value === "string") {
123
+ assertNoLoneSurrogate(value, path);
99
124
  return JSON.stringify(value);
125
+ }
100
126
  if (Array.isArray(value))
101
- return `[${value.map(canonicalJson).join(",")}]`;
127
+ return `[${value.map((v, i) => canonicalJson(v, `${path}[${i}]`)).join(",")}]`;
102
128
  const keys = Object.keys(value).sort();
103
- return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`;
129
+ for (const k of keys)
130
+ assertNoLoneSurrogate(k, `${path} key`);
131
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k], `${path}.${k}`)}`).join(",")}}`;
104
132
  }