@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/dist/index.d.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import type { ResumeController } from "./resume.js";
2
+ import { type EvidenceOutcome } from "./evidence.js";
2
3
  export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
3
4
  export type { PendingAction, PendingStore, ResumeController, ResumeOutcome } from "./resume.js";
4
5
  export { guard, Guard } from "./guard.js";
5
6
  export type { GuardFacadeConfig, ProtectOptions } from "./guard.js";
6
- export { DecidioHttpError } from "./errors.js";
7
- export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport } from "./signing.js";
7
+ export { DecidioHttpError, decisionIdProblem } from "./errors.js";
8
+ export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport, carriesCredential } from "./signing.js";
8
9
  export { canonicalize, canonicalizeArgs, canonicalJson, DecidioUnsafeArgumentsError } from "./canonical.js";
9
10
  export type { JsonValue, JsonPrimitive } from "./canonical.js";
10
11
  export { readConfirmResponse, buildConfirmBody, describeTierGap, EVIDENCE_TIERS } from "./evidence.js";
@@ -107,10 +108,34 @@ export declare class DecidioNetworkError extends Error {
107
108
  /** `<appUrl>/d/<decisionId>` — the deep link that opens this decision directly, on the web app or
108
109
  * the phone build. Null when DECIDIO_APP_URL is not configured, never a half-built URL. */
109
110
  export declare function decisionUrl(appUrl: string | undefined, decisionId: string): string | null;
111
+ /** What a protected call returns in 0.2.0: your function's value, the decision it was authorized
112
+ * under, and what Decidio actually recorded about the execution.
113
+ *
114
+ * Through 0.1.x this was just `R`. The SDK computed the full confirmation outcome — tier, whether
115
+ * the report was recorded, whether the signed execution report verified, whether the evidence
116
+ * mapper failed — and then dropped it on the floor, surfacing it only as a console warning. For a
117
+ * product whose value IS the record, the one thing a caller could not do was find out what got
118
+ * recorded. Returning it makes "my action ran but was logged as an unproven assertion" something
119
+ * code can branch on instead of something a human might notice in a log.
120
+ *
121
+ * `protectBestEffort` keeps the old value-only shape for callers that genuinely do not care. */
122
+ export interface ProtectedExecution<R> {
123
+ /** Exactly what your function returned — unchanged, uncanonicalized, your object. */
124
+ value: R;
125
+ /** The decision this execution was authorized under. Use it as your downstream idempotency key. */
126
+ decisionId: string;
127
+ /** What Decidio recorded. `reportRecorded` false means the execution happened and Decidio does
128
+ * NOT know; `executionConfirmed` false means it knows but could not attest a commit. */
129
+ confirmation: EvidenceOutcome;
130
+ }
110
131
  export declare function withApproval<A extends any[], R>(fn: (...args: A) => Promise<R>, describe: (...args: A) => ApprovalContext, config: GuardConfig, opts?: {
111
132
  /** Map YOUR function's natural return value to the evidence Decidio records. The gate awards
112
133
  * its strongest wrapper tier (application_confirmed) only for a committed record id plus a
113
134
  * success indication — this lets a real API keep its own shape instead of being forced into
114
135
  * magic field names: `evidence: (r) => ({ recordId: r.transactionId, success: r.status === "committed" })`. */
115
136
  evidence?: (result: R) => unknown;
116
- }): (...args: A) => Promise<R>;
137
+ /** Hand the action an `ExecutionContext` as its FIRST argument: `{ decisionId, attemptId,
138
+ * idempotencyKey }`. Opt-in, never inferred from the function's arity — guessing wrong means
139
+ * passing a context object as someone's first business argument. */
140
+ withContext?: boolean;
141
+ }): (...args: A) => Promise<ProtectedExecution<R>>;
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { DecidioSuspendedError } from "./resume.js";
2
- import { signBoundProof, signConfirm, assertSafeTransport } from "./signing.js";
2
+ import { signBoundProof, signConfirm, assertSafeTransport, carriesCredential } from "./signing.js";
3
3
  import { canonicalize, canonicalizeArgs } from "./canonical.js";
4
4
  import { readConfirmResponse, buildConfirmBody } from "./evidence.js";
5
5
  export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
6
6
  export { guard, Guard } from "./guard.js";
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";
7
+ export { DecidioHttpError, decisionIdProblem } from "./errors.js";
8
+ import { DecidioHttpError, decisionIdProblem } from "./errors.js";
9
+ export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport, carriesCredential } from "./signing.js";
10
10
  export { canonicalize, canonicalizeArgs, canonicalJson, DecidioUnsafeArgumentsError } from "./canonical.js";
11
11
  export { readConfirmResponse, buildConfirmBody, describeTierGap, EVIDENCE_TIERS } from "./evidence.js";
12
12
  export class DecidioBlockedError extends Error {
@@ -80,7 +80,7 @@ async function reach(f, url, op, init) {
80
80
  }
81
81
  async function post(cfg, path, body) {
82
82
  const f = cfg.fetchImpl ?? fetch;
83
- assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
83
+ assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
84
84
  const res = await reach(f, cfg.apiUrl.replace(/\/$/, "") + path, `POST ${path}`, {
85
85
  method: "POST",
86
86
  headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
@@ -103,7 +103,7 @@ async function post(cfg, path, body) {
103
103
  }
104
104
  async function get(cfg, path) {
105
105
  const f = cfg.fetchImpl ?? fetch;
106
- assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
106
+ assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
107
107
  const res = await reach(f, cfg.apiUrl.replace(/\/$/, "") + path, `GET ${path}`, {
108
108
  headers: { ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
109
109
  });
@@ -142,6 +142,12 @@ export function withApproval(fn, describe, config, opts) {
142
142
  ? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
143
143
  : undefined;
144
144
  canonicalize(ctx, "describe(...)");
145
+ if (typeof ctx?.action !== "string" || ctx.action.trim().length === 0) {
146
+ throw new Error(`[decidio] describe(...) must return an \`action\` string naming what is being asked (got ${JSON.stringify(ctx?.action ?? null)}). Nothing was routed or executed.`);
147
+ }
148
+ if (typeof ctx.amount !== "number" || !Number.isFinite(ctx.amount) || ctx.amount < 0) {
149
+ throw new Error(`[decidio] describe(...) must return a finite, non-negative \`amount\` — the policy decides on it, so a request without one is not a request Decidio can rule on (got ${JSON.stringify(ctx.amount ?? null)}). Use 0 for an action with no monetary value. Nothing was routed or executed.`);
150
+ }
145
151
  const auth = await post(config, "/agent/authorize", {
146
152
  requester: config.agentId, action: ctx.action, amount: ctx.amount,
147
153
  scope: ctx.scope, sourceSystem: config.sourceSystem,
@@ -151,6 +157,9 @@ export function withApproval(fn, describe, config, opts) {
151
157
  });
152
158
  if (auth.decision !== "proceed" && auth.decision !== "route" && auth.decision !== "block")
153
159
  throw new DecidioBlockedError(`unrecognized gate verdict: ${String(auth.decision)}`, auth.decisionId);
160
+ const idProblem = decisionIdProblem(auth.decisionId);
161
+ if (idProblem)
162
+ throw new DecidioBlockedError(`the gate answered "${auth.decision}" with no usable decisionId (${JSON.stringify(auth.decisionId ?? null)} — ${idProblem}) — refusing to execute. Every authorized action must be addressable: without an id it cannot be polled, resumed, confirmed or audited.`, typeof auth.decisionId === "string" ? auth.decisionId : String(auth.decisionId ?? ""));
154
163
  if (auth.decision === "block")
155
164
  throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
156
165
  if (auth.decision === "route") {
@@ -163,7 +172,7 @@ export function withApproval(fn, describe, config, opts) {
163
172
  config.resume.agentId ??= config.agentId;
164
173
  config.resume.agentKey ??= config.agentKey;
165
174
  config.resume.agentDid ??= config.agentDid;
166
- config.resume.register(ctx.action, fn);
175
+ config.resume.register(ctx.action, fn, opts?.evidence);
167
176
  await config.resume.park({
168
177
  decisionId: auth.decisionId, receiptId: auth.receiptId,
169
178
  action: ctx.action, amount: ctx.amount, scope: ctx.scope, args,
@@ -186,21 +195,44 @@ export function withApproval(fn, describe, config, opts) {
186
195
  await sleep(pollInterval);
187
196
  }
188
197
  }
189
- const result = await fn(...args);
198
+ const result = opts?.withContext
199
+ ? await fn({ decisionId: auth.decisionId, attemptId: null, idempotencyKey: auth.decisionId }, ...args)
200
+ : await fn(...args);
201
+ let reported = null;
202
+ let evidenceProblem;
190
203
  try {
191
- const confirmIdentity = signConfirm(auth.decisionId, config);
192
204
  const evidenceOf = (opts?.evidence ?? ((r) => r));
193
- const reported = canonicalize(evidenceOf(result) ?? null, "evidence(result)");
205
+ reported = canonicalize(evidenceOf(result) ?? null, "evidence(result)");
206
+ }
207
+ catch (e) {
208
+ evidenceProblem = String(e?.message ?? e);
209
+ console.warn(`[decidio] ${auth.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 (e.g. evidence: (r) => ({ recordId: String(r.id), success: true })) to earn a confirming tier. The action was NOT re-run.`);
210
+ }
211
+ let confirmation;
212
+ try {
213
+ const confirmIdentity = signConfirm(auth.decisionId, config);
194
214
  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}`);
215
+ confirmation = readConfirmResponse(confirmBody, { signed: !!confirmIdentity, capturedResponse: reported, journalled: false });
216
+ if (!confirmation.reportRecorded)
217
+ console.warn(`[decidio] ${auth.decisionId}: ${confirmation.diagnostic}`);
218
+ else if (!confirmation.executionConfirmed || confirmation.executionReportVerified === false)
219
+ console.warn(`[decidio] execution evidence for ${auth.decisionId} recorded as ${confirmation.evidenceTier} — ${confirmation.diagnostic}`);
220
+ if (evidenceProblem)
221
+ console.warn(`[decidio] ${auth.decisionId}: the execution IS recorded, but with an empty captured response — see the evidence note above.`);
200
222
  }
201
223
  catch (e) {
202
- console.warn(`[decidio] confirmation post failed (action succeeded): ${e.message}`);
224
+ const isHttp = e instanceof DecidioHttpError;
225
+ console.warn(`[decidio] confirmation post failed (action succeeded): ${e?.message ?? e}`);
226
+ confirmation = {
227
+ reportRecorded: false,
228
+ executionConfirmed: false,
229
+ confirmationRetryable: isHttp ? e.retryable : true,
230
+ reportJournalled: false,
231
+ diagnostic: `your action RAN and Decidio does NOT have its execution report (${e?.message ?? e}). This path journals nothing, so there is no local copy to re-send: record the outcome from your own system. Durable mode DOES journal reports, and its recovery path can re-send them — that is the mode to use if this matters to you. Do NOT re-run the action.`,
232
+ };
203
233
  }
204
- return result;
234
+ if (evidenceProblem)
235
+ confirmation.evidenceError = evidenceProblem;
236
+ return { value: result, decisionId: auth.decisionId, confirmation };
205
237
  };
206
238
  }
package/dist/inngest.d.ts CHANGED
@@ -45,6 +45,10 @@ export declare function decidioGateStep<R>(args: {
45
45
  ctx: ApprovalContext;
46
46
  run: () => Promise<R>;
47
47
  timeout?: string;
48
+ /** Map your action's return value to the evidence Decidio records — the same option the core
49
+ * path takes. This door's own confirm chokepoint tells callers to use it; until 0.2.0 no
50
+ * adapter door accepted one, so the advice was unfollowable everywhere it was printed. */
51
+ evidence?: (result: R) => unknown;
48
52
  }): Promise<GateStepOutcome<R>>;
49
53
  /**
50
54
  * Decidio → Inngest bridge. Mount as an HTTP endpoint and register it as the agent's
package/dist/inngest.js CHANGED
@@ -22,7 +22,7 @@ export async function decidioGateStep(args) {
22
22
  return { status: "blocked", decisionId: auth.decisionId };
23
23
  }
24
24
  const result = await step.run("agent-action", args.run);
25
- await step.run("decidio-confirm", async () => { await gateConfirm(config, auth.decisionId, result); return true; });
25
+ await step.run("decidio-confirm", async () => { await gateConfirm(config, auth.decisionId, result, args.evidence); return true; });
26
26
  return { status: "executed", decisionId: auth.decisionId, result };
27
27
  }
28
28
  export function createInngestResumeBridge(opts) {