@decidio/sdk 0.10.0 → 0.12.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/demo-gate.js CHANGED
@@ -41,6 +41,7 @@ export async function startDemoGate(opts = {}) {
41
41
  let relayCode = null;
42
42
  let relayOk = null;
43
43
  let committed = null;
44
+ let claimedBy = null;
44
45
  const relayFetch = async (path, init = {}) => {
45
46
  if (!relayBase)
46
47
  return null;
@@ -112,6 +113,7 @@ export async function startDemoGate(opts = {}) {
112
113
  return send({ ok: true, changed: true });
113
114
  if (path === "/agent/authorize") {
114
115
  verdict = null;
116
+ claimedBy = null;
115
117
  committed = typeof body?.requestCommitment === "string" && /^[0-9a-f]{64}$/.test(body.requestCommitment) ? body.requestCommitment : null;
116
118
  return send({ decision: "route", decisionId: DECISION_ID, receiptId: "R-DEMO-1", requestCommitment: committed });
117
119
  }
@@ -128,6 +130,18 @@ export async function startDemoGate(opts = {}) {
128
130
  return send({ state: "rejected", reason: "quickstart: withdrawn — the tour stopped waiting", requestCommitment: committed });
129
131
  return send(verdict === "reject" ? { state: "rejected", reason: "quickstart: rejected by the approver", requestCommitment: committed } : { state: verdict === "approve" ? "approved" : "pending", requestCommitment: committed });
130
132
  }
133
+ if (path.startsWith("/agent/claim/")) {
134
+ const key = typeof body?.claimKey === "string" && /^[A-Za-z0-9._:-]{16,128}$/.test(body.claimKey) ? body.claimKey : null;
135
+ if (!key)
136
+ return send({ code: "VALIDATION", message: "claimKey must be 16-128 characters of letters, digits, dot, colon, underscore or dash" }, 400);
137
+ if (verdict !== "approve")
138
+ return send({ code: "NOT_CLAIMABLE", decisionId: DECISION_ID, state: verdict === "reject" ? "rejected" : "pending", message: "only a person's approval is collected here" }, 409);
139
+ if (claimedBy && claimedBy !== key)
140
+ return send({ code: "ALREADY_USED", decisionId: DECISION_ID, state: "approved", message: "another caller already collected this approval - ask again for a new one" }, 409);
141
+ const again = claimedBy === key;
142
+ claimedBy = key;
143
+ return send({ ok: true, claimed: true, decisionId: DECISION_ID, again });
144
+ }
131
145
  if (path.startsWith("/agent/withdraw/")) {
132
146
  if (verdict)
133
147
  return send({ code: "NOT_OPEN", state: verdict === "approve" ? "approved" : "rejected", decisionId: DECISION_ID }, 409);
package/dist/errors.js CHANGED
@@ -30,7 +30,9 @@ export class DecidioHttpError extends Error {
30
30
  ? ` — this looks transient. Your action ALREADY RAN; do NOT re-run it — only its report failed to land, and the door's own note says whether a report was kept to re-send.`
31
31
  : /\/agent\/status/.test(endpoint)
32
32
  ? ` — this looks transient; polling again is safe (a status read changes nothing).`
33
- : ` — this looks transient; retrying is safe (a retry creates a NEW request and can never double-execute).`)
33
+ : /\/agent\/authorize/.test(endpoint)
34
+ ? ` — this looks transient. This SDK already retried it with the same request key (a request still waiting is answered with the one already waiting); retrying is safe and can never double-execute.`
35
+ : ` — this looks transient; retrying is safe and can never double-execute.`)
34
36
  : ` — the server refused the request itself, so retrying it unchanged will fail the same way. Fix the request (or the decision it names) first.`) +
35
37
  sideEffectNote(endpoint, typeof body?.code === "string" ? body.code : undefined));
36
38
  this.status = status;
package/dist/guard.d.ts CHANGED
@@ -17,6 +17,8 @@ export interface GuardFacadeConfig {
17
17
  workspaceId?: string;
18
18
  /** Base URL of the Decidio app (DECIDIO_APP_URL) — used to deep-link a routed decision. */
19
19
  appUrl?: string;
20
+ /** The per-call id behind the default request key (0.12.0) - see GuardConfig.invocationId. Leave it unset. */
21
+ invocationId?: () => string;
20
22
  fetchImpl?: typeof fetch;
21
23
  }
22
24
  export interface ProtectOptions {
@@ -53,6 +55,9 @@ export interface ProtectOptions {
53
55
  * success indication, so instead of forcing your API into magic field names:
54
56
  * `evidence: (r) => ({ recordId: r.transactionId, success: r.status === "committed" })`. */
55
57
  evidence?: (result: any) => unknown;
58
+ /** YOUR OWN REQUEST KEY for a call, from its arguments (0.12.0) - e.g. `(inv) => inv.id` - so the same call asked again,
59
+ * even from a new process, is the same request. Without it each call sends a fresh random key. See withApproval. */
60
+ requestKey?: (...args: any[]) => string;
56
61
  }
57
62
  export declare class Guard {
58
63
  config: GuardFacadeConfig;
package/dist/guard.js CHANGED
@@ -60,6 +60,7 @@ export class Guard {
60
60
  const base = {
61
61
  apiUrl: c.apiUrl, agentId: c.agentId, apiToken: c.apiToken,
62
62
  sourceSystem: c.sourceSystem, agentKey: c.agentKey, agentDid: c.agentDid, workspaceId: c.workspaceId, appUrl: c.appUrl, fetchImpl: c.fetchImpl,
63
+ ...(c.invocationId ? { invocationId: c.invocationId } : {}),
63
64
  pollTimeoutMs: opts?.pollTimeoutMs, pollIntervalMs: opts?.pollIntervalMs,
64
65
  };
65
66
  if ((opts?.mode ?? "durable") === "blocking") {
@@ -68,12 +69,12 @@ export class Guard {
68
69
  `(pollTimeoutMs), and when it does, this process stops waiting and the request is withdrawn in Decidio: ` +
69
70
  `a later approval does nothing. Only safe for short, watched approvals — omit mode:"blocking" for ` +
70
71
  `durable async resume on real (minutes-to-days) approvals.`);
71
- return withApproval(fn, describe, base, { ...(opts?.evidence ? { evidence: opts.evidence } : {}), ...(opts?.withContext ? { withContext: true } : {}) });
72
+ return withApproval(fn, describe, base, { ...(opts?.evidence ? { evidence: opts.evidence } : {}), ...(opts?.withContext ? { withContext: true } : {}), ...(opts?.requestKey ? { requestKey: opts.requestKey } : {}) });
72
73
  }
73
74
  const resume = this.ensureResume();
74
75
  if (opts?.action)
75
76
  resume.register(opts.action, fn, opts?.evidence, opts?.withContext);
76
- return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume }, { ...(opts?.evidence ? { evidence: opts.evidence } : {}), ...(opts?.withContext ? { withContext: true } : {}) });
77
+ return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume }, { ...(opts?.evidence ? { evidence: opts.evidence } : {}), ...(opts?.withContext ? { withContext: true } : {}), ...(opts?.requestKey ? { requestKey: opts.requestKey } : {}) });
77
78
  }
78
79
  protectBestEffort(fn, describe, opts) {
79
80
  const wrapped = this.protect(fn, describe, opts);
package/dist/index.d.ts CHANGED
@@ -1,12 +1,12 @@
1
- import type { ResumeController, ApprovalCheck } from "./resume.js";
1
+ import type { ResumeController, ApprovalCheck, ClaimOutcome } from "./resume.js";
2
2
  import { type CommittedRequest } from "./signing.js";
3
3
  import { type EvidenceOutcome } from "./evidence.js";
4
- export { DecidioSuspendedError, DecidioDeniedError, DecidioRecoveryUnavailableError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded, checkApproval } from "./resume.js";
5
- export type { PendingAction, PendingStore, ResumeController, ResumeOutcome, ApprovalCheck, ApprovalCheckCode, DecisionStatus } from "./resume.js";
4
+ export { DecidioSuspendedError, DecidioDeniedError, DecidioRecoveryUnavailableError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded, checkApproval, claimApproval, newClaimKey } from "./resume.js";
5
+ export type { PendingAction, PendingStore, ResumeController, ResumeOutcome, ApprovalCheck, ApprovalCheckCode, DecisionStatus, ClaimOutcome, ClaimCode } from "./resume.js";
6
6
  export { guard, Guard } from "./guard.js";
7
7
  export type { GuardFacadeConfig, ProtectOptions } from "./guard.js";
8
8
  export { DecidioHttpError, decisionIdProblem } from "./errors.js";
9
- export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport, carriesCredential, bindingMaterial, boundScope, requestCommitment, idempotencyKey, committedRequestJson } from "./signing.js";
9
+ export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport, carriesCredential, bindingMaterial, boundScope, requestCommitment, idempotencyKey, invocationRequestKey, ownRequestKey, committedRequestJson } from "./signing.js";
10
10
  export type { BindingMaterial, CommittedRequest } from "./signing.js";
11
11
  export { canonicalize, canonicalizeArgs, canonicalJson, DecidioUnsafeArgumentsError } from "./canonical.js";
12
12
  export type { JsonValue, JsonPrimitive } from "./canonical.js";
@@ -61,6 +61,11 @@ export interface GuardConfig {
61
61
  * Sent on /agent/authorize so the decision carries it. Falls back to
62
62
  * resume.resumeUrl. Omit when using the poll worker. */
63
63
  resumeUrl?: string;
64
+ /** Where the per-call id behind the DEFAULT REQUEST KEY comes from (0.12.0, 11f). Every call that sends a request
65
+ * commitment also sends a request key derived from a fresh random id and that commitment, so this SDK's own retry of
66
+ * the authorize is the same request. Leave it unset: a fixed id makes every call the SAME request. It exists so a
67
+ * test or a conformance harness can pin the bytes it compares. */
68
+ invocationId?: () => string;
64
69
  fetchImpl?: typeof fetch;
65
70
  }
66
71
  export interface ApprovalContext {
@@ -115,11 +120,15 @@ export declare function coreRequest(ctx: {
115
120
  export declare class DecidioTimeoutError extends Error {
116
121
  decisionId: string;
117
122
  withdrawn: boolean;
123
+ shared: boolean;
118
124
  /** `withdrawn` (0.6.4+): true when the SDK told Decidio it gave up and the request was closed as
119
125
  * WITHDRAWN (the queue no longer carries it; a later approval does nothing); false when that
120
126
  * best-effort call failed, so the request may still be open in Decidio. Nothing was executed
121
- * locally either way. */
122
- constructor(decisionId: string, withdrawn?: boolean);
127
+ * locally either way.
128
+ * `shared` (0.12.0): true when the request was left open on purpose because it may be shared - the authorize
129
+ * answered an earlier request (`idempotentReplay`) or joined one another caller is waiting on (`openTwin`), or the
130
+ * withdraw was refused because another caller joined it (409 SHARED_REQUEST). A person still decides it. */
131
+ constructor(decisionId: string, withdrawn?: boolean, shared?: boolean);
123
132
  }
124
133
  /** A transport failure with GUIDANCE instead of a raw fetch stack. Carries the endpoint, the
125
134
  * operation, the underlying code, and the safety state a caller cares about most: whether
@@ -132,7 +141,10 @@ export declare class DecidioRateLimitError extends Error {
132
141
  limit: number | undefined;
133
142
  remaining: number | undefined;
134
143
  resetAt: string | undefined;
135
- constructor(limit: number | undefined, remaining: number | undefined, resetAt: string | undefined, serverMessage?: string);
144
+ code?: string | undefined;
145
+ /** `code` (0.12.0): the server's own code - `AGENT_RATE` for the workspace's hourly allowance, which is never retried;
146
+ * a 429 without it is a transport throttle the SDK retries before it gives up. */
147
+ constructor(limit: number | undefined, remaining: number | undefined, resetAt: string | undefined, serverMessage?: string, code?: string | undefined);
136
148
  }
137
149
  export declare class DecidioNetworkError extends Error {
138
150
  endpoint: string;
@@ -145,6 +157,18 @@ export declare class DecidioNetworkError extends Error {
145
157
  * every engine door reached the mock authorization path with a request the core would have refused.
146
158
  * Exported so `adapters/_gate.ts` calls the same function rather than a second copy that can drift. */
147
159
  export declare function assertDescribed(ctx: ApprovalContext): void;
160
+ /** THE ONE AUTHORIZE POST (0.12.0, 11f) - the core wrapper and every engine adapter send through here. A network failure
161
+ * or a transient answer is retried a bounded number of times with the SAME body: under a request key that is the same
162
+ * request (the gate answers the decision it already made, `idempotentReplay`), and a keyless request asked again while
163
+ * it waits joins the one waiting (`openTwin`). A body that carries a commitment WITHOUT a key is never retried - nothing
164
+ * would make the retry the same request. `rateLimit: "http"` keeps an adapter's 429 a DecidioHttpError, as it always was. */
165
+ export declare function sendAuthorize(config: GuardConfig, first: Record<string, unknown>, opts?: {
166
+ rateLimit?: "typed" | "http";
167
+ resign?: () => unknown;
168
+ }): Promise<any>;
169
+ /** The typed refusal for a claim that did not let this run go ahead (0.12.0) - DecidioBlockedError, so code that treats
170
+ * a block as "did not run" keeps doing so; the words say what happened and what to do ("ask again"). */
171
+ export declare function claimRefusal(claim: ClaimOutcome): DecidioBlockedError;
148
172
  /**
149
173
  * Wrap an async action behind the Decidio gate. `describe` extracts the approval
150
174
  * context (action, amount, scope) from the call arguments. Returns a function with
@@ -172,6 +196,9 @@ export interface ProtectedExecution<R> {
172
196
  /** What Decidio recorded. `reportRecorded` false means the execution happened and Decidio does
173
197
  * NOT know; `executionConfirmed` false means it knows but could not attest a commit. */
174
198
  confirmation: EvidenceOutcome;
199
+ /** The request key this call sent with its authorize (0.12.0, 11f) - absent when none was sent (no key material, so no
200
+ * commitment). It names the REQUEST to Decidio; your downstream idempotency key stays `decisionId`. */
201
+ requestKey?: string;
175
202
  }
176
203
  export declare function withApproval<A extends any[], R>(fn: (...args: A) => Promise<R>, describe: (...args: A) => ApprovalContext, config: GuardConfig, opts?: {
177
204
  /** Map YOUR function's natural return value to the evidence Decidio records. The gate awards
@@ -183,4 +210,9 @@ export declare function withApproval<A extends any[], R>(fn: (...args: A) => Pro
183
210
  * idempotencyKey }`. Opt-in, never inferred from the function's arity — guessing wrong means
184
211
  * passing a context object as someone's first business argument. */
185
212
  withContext?: boolean;
213
+ /** YOUR OWN REQUEST KEY for a call (0.12.0, 11f), from its arguments - an order or invoice id - so the same call asked
214
+ * again, even from a new process, is the same request. Keyed with this agent's material before it is sent (your id
215
+ * never reaches Decidio in the clear). The same key for a CHANGED request is refused by the gate (409
216
+ * IDEMPOTENCY_CONFLICT), never a silent second request. Without it each call sends a fresh random key. */
217
+ requestKey?: (...args: A) => string;
186
218
  }): (...args: A) => Promise<ProtectedExecution<R>>;
package/dist/index.js CHANGED
@@ -1,12 +1,13 @@
1
- import { DecidioSuspendedError, checkApproval } from "./resume.js";
2
- import { signBoundProof, signConfirm, assertSafeTransport, carriesCredential, bindingMaterial, requestCommitment } from "./signing.js";
1
+ import { randomUUID } from "node:crypto";
2
+ import { DecidioSuspendedError, checkApproval, claimApproval, newClaimKey, RETRY_DELAYS_MS } from "./resume.js";
3
+ import { signBoundProof, signConfirm, assertSafeTransport, carriesCredential, bindingMaterial, requestCommitment, invocationRequestKey, ownRequestKey } from "./signing.js";
3
4
  import { canonicalize, canonicalizeArgs } from "./canonical.js";
4
5
  import { readConfirmResponse, buildConfirmBody } from "./evidence.js";
5
- export { DecidioSuspendedError, DecidioDeniedError, DecidioRecoveryUnavailableError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded, checkApproval } from "./resume.js";
6
+ export { DecidioSuspendedError, DecidioDeniedError, DecidioRecoveryUnavailableError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded, checkApproval, claimApproval, newClaimKey } from "./resume.js";
6
7
  export { guard, Guard } from "./guard.js";
7
8
  export { DecidioHttpError, decisionIdProblem } from "./errors.js";
8
9
  import { DecidioHttpError, decisionIdProblem } from "./errors.js";
9
- export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport, carriesCredential, bindingMaterial, boundScope, requestCommitment, idempotencyKey, committedRequestJson } from "./signing.js";
10
+ export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport, carriesCredential, bindingMaterial, boundScope, requestCommitment, idempotencyKey, invocationRequestKey, ownRequestKey, committedRequestJson } from "./signing.js";
10
11
  export { canonicalize, canonicalizeArgs, canonicalJson, DecidioUnsafeArgumentsError } from "./canonical.js";
11
12
  export { readConfirmResponse, buildConfirmBody, describeTierGap, EVIDENCE_TIERS } from "./evidence.js";
12
13
  export class DecidioBlockedError extends Error {
@@ -66,10 +67,12 @@ export function coreRequest(ctx, args) {
66
67
  export class DecidioTimeoutError extends Error {
67
68
  decisionId;
68
69
  withdrawn;
69
- constructor(decisionId, withdrawn = false) {
70
+ shared;
71
+ constructor(decisionId, withdrawn = false, shared = false) {
70
72
  super(`Timed out waiting for human approval (${decisionId})`);
71
73
  this.decisionId = decisionId;
72
74
  this.withdrawn = withdrawn;
75
+ this.shared = shared;
73
76
  this.name = "DecidioTimeoutError";
74
77
  }
75
78
  }
@@ -78,12 +81,14 @@ export class DecidioRateLimitError extends Error {
78
81
  limit;
79
82
  remaining;
80
83
  resetAt;
81
- constructor(limit, remaining, resetAt, serverMessage) {
84
+ code;
85
+ constructor(limit, remaining, resetAt, serverMessage, code) {
82
86
  super(`[decidio] governed-action rate limit reached${limit != null ? ` (${limit}/hour on this workspace)` : ""}${resetAt ? ` — resets ${resetAt}` : ""}. ` +
83
87
  `${serverMessage ? serverMessage + " " : ""}Nothing was routed or executed; retry after the reset.`);
84
88
  this.limit = limit;
85
89
  this.remaining = remaining;
86
90
  this.resetAt = resetAt;
91
+ this.code = code;
87
92
  this.name = "DecidioRateLimitError";
88
93
  }
89
94
  }
@@ -97,8 +102,9 @@ export class DecidioNetworkError extends Error {
97
102
  ? `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). `
98
103
  : "") +
99
104
  `Nothing was executed locally and no verdict was received — if a request reached Decidio it is ` +
100
- `visible in your queue. Check the network/URL (DECIDIO_API_URL) and retry; retrying is safe ` +
101
- `(a retry creates a new request — it can never double-execute).`);
105
+ `visible in your queue. ` +
106
+ (/\/agent\/authorize/.test(op) ? `This SDK already retried it with the same request key. ` : "") +
107
+ `Check the network/URL (DECIDIO_API_URL) and retry; retrying is safe — it can never double-execute.`);
102
108
  this.endpoint = endpoint;
103
109
  this.op = op;
104
110
  this.code = code;
@@ -132,7 +138,7 @@ async function post(cfg, path, body) {
132
138
  });
133
139
  if (res.status === 429) {
134
140
  const b = await res.json().catch(() => null);
135
- throw new DecidioRateLimitError(b?.limit, b?.remaining, b?.resetAt, b?.message);
141
+ throw new DecidioRateLimitError(b?.limit, b?.remaining, b?.resetAt, b?.message, typeof b?.code === "string" ? b.code : undefined);
136
142
  }
137
143
  if (!res.ok) {
138
144
  const raw = await res.text().catch(() => "");
@@ -145,6 +151,56 @@ async function post(cfg, path, body) {
145
151
  }
146
152
  return res.json();
147
153
  }
154
+ function retriesSafely(e) {
155
+ if (e instanceof DecidioNetworkError)
156
+ return true;
157
+ if (e instanceof DecidioRateLimitError)
158
+ return e.code !== "AGENT_RATE";
159
+ if (e instanceof DecidioHttpError)
160
+ return (e.status >= 500 && e.status !== 501) || (e.status === 429 && e.body?.code !== "AGENT_RATE");
161
+ return false;
162
+ }
163
+ export async function sendAuthorize(config, first, opts = {}) {
164
+ const mayRetry = !!first.idempotencyKey || !first.requestCommitment;
165
+ let body = first;
166
+ const once = async () => {
167
+ if ((opts.rateLimit ?? "typed") === "typed")
168
+ return post(config, "/agent/authorize", body);
169
+ const f = config.fetchImpl ?? fetch;
170
+ assertSafeTransport(config.apiUrl, carriesCredential(config));
171
+ const res = await reach(f, config.apiUrl.replace(/\/$/, "") + "/agent/authorize", "POST /agent/authorize", {
172
+ method: "POST",
173
+ headers: { "content-type": "application/json", ...(config.apiToken ? { authorization: `Bearer ${config.apiToken}` } : {}) },
174
+ body: JSON.stringify(body),
175
+ });
176
+ if (!res.ok) {
177
+ const raw = await res.text().catch(() => "");
178
+ let parsed = raw;
179
+ try {
180
+ parsed = raw ? JSON.parse(raw) : undefined;
181
+ }
182
+ catch { }
183
+ throw new DecidioHttpError(res.status, "/agent/authorize", parsed);
184
+ }
185
+ return res.json();
186
+ };
187
+ for (let attempt = 0;; attempt++) {
188
+ try {
189
+ return await once();
190
+ }
191
+ catch (e) {
192
+ if (!mayRetry || attempt >= RETRY_DELAYS_MS.length || !retriesSafely(e))
193
+ throw e;
194
+ await sleep(RETRY_DELAYS_MS[attempt]);
195
+ const fresh = first.requesterIdentity && opts.resign ? opts.resign() : undefined;
196
+ if (fresh)
197
+ body = { ...first, requesterIdentity: fresh };
198
+ }
199
+ }
200
+ }
201
+ export function claimRefusal(claim) {
202
+ return new DecidioBlockedError(claim.ok ? "the approval was collected" : claim.reason, claim.decisionId);
203
+ }
148
204
  async function get(cfg, path) {
149
205
  const f = cfg.fetchImpl ?? fetch;
150
206
  assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
@@ -190,13 +246,30 @@ export function withApproval(fn, describe, config, opts) {
190
246
  const material = bindingMaterial(config);
191
247
  const committed = coreRequest(ctx, args);
192
248
  const sentCommitment = material ? requestCommitment(material, committed) : undefined;
193
- const auth = await post(config, "/agent/authorize", {
249
+ let requestKey;
250
+ if (opts?.requestKey) {
251
+ const own = opts.requestKey(...args);
252
+ if (typeof own !== "string" || own.length === 0)
253
+ throw new Error(`[decidio] requestKey(...) must return a non-empty string naming this call (got ${JSON.stringify(own ?? null)}). Nothing was routed or executed.`);
254
+ if (!material)
255
+ throw new Error("[decidio] requestKey needs this agent's key material (DECIDIO_AGENT_KEY + DECIDIO_AGENT_DID, or DECIDIO_API_TOKEN): a key rides only beside the request commitment that material makes, and Decidio refuses one alone. Nothing was routed or executed.");
256
+ requestKey = ownRequestKey(material, own);
257
+ }
258
+ else if (material && sentCommitment) {
259
+ requestKey = invocationRequestKey(material, config.invocationId ? String(config.invocationId()) : randomUUID(), sentCommitment);
260
+ }
261
+ const auth = await sendAuthorize(config, {
194
262
  requester: config.agentId, action: ctx.action, amount: ctx.amount,
195
263
  scope: ctx.scope, sourceSystem: config.sourceSystem,
196
264
  ...(ctx.context ? { context: ctx.context } : {}),
197
265
  ...(requesterIdentity ? { requesterIdentity } : {}),
198
266
  ...(resumeUrl ? { resumeUrl } : {}),
199
267
  ...(sentCommitment ? { requestCommitment: sentCommitment } : {}),
268
+ ...(requestKey ? { idempotencyKey: requestKey } : {}),
269
+ }, {
270
+ resign: () => (config.agentKey && config.agentDid)
271
+ ? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
272
+ : undefined,
200
273
  });
201
274
  if (auth.decision !== "proceed" && auth.decision !== "route" && auth.decision !== "block")
202
275
  throw new DecidioBlockedError(`unrecognized gate verdict: ${String(auth.decision)}`, auth.decisionId);
@@ -238,13 +311,18 @@ export function withApproval(fn, describe, config, opts) {
238
311
  if (Date.now() > deadline) {
239
312
  let withdrawn = false;
240
313
  let lateState = null;
241
- try {
242
- await post(config, `/agent/withdraw/${encodeURIComponent(auth.decisionId)}`, { reason: "timeout" });
243
- withdrawn = true;
244
- }
245
- catch (e) {
246
- if (e instanceof DecidioHttpError && e.status === 409 && typeof e.body?.state === "string")
247
- lateState = e.body.state;
314
+ let shared = auth.idempotentReplay === true || auth.openTwin === true;
315
+ if (!shared) {
316
+ try {
317
+ await post(config, `/agent/withdraw/${encodeURIComponent(auth.decisionId)}`, { reason: "timeout" });
318
+ withdrawn = true;
319
+ }
320
+ catch (e) {
321
+ if (e instanceof DecidioHttpError && e.status === 409 && typeof e.body?.state === "string")
322
+ lateState = e.body.state;
323
+ else if (e instanceof DecidioHttpError && e.status === 409 && e.body?.code === "SHARED_REQUEST")
324
+ shared = true;
325
+ }
248
326
  }
249
327
  if (lateState === "approved" || lateState === "auto_approved")
250
328
  break;
@@ -252,14 +330,19 @@ export function withApproval(fn, describe, config, opts) {
252
330
  throw new DecidioRejectedError("rejected by approver", auth.decisionId);
253
331
  if (lateState === "blocked")
254
332
  throw new DecidioBlockedError("blocked", auth.decisionId);
255
- console.warn(`[decidio] blocking wait hit its ${Math.round(pollTimeout / 1000)}s limit — decision ${auth.decisionId} ${withdrawn ? "was withdrawn in Decidio (the queue no longer shows it; a later approval does nothing)" : "is still open in Decidio (the withdraw call failed), 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.`);
256
- throw new DecidioTimeoutError(auth.decisionId, withdrawn);
333
+ console.warn(`[decidio] blocking wait hit its ${Math.round(pollTimeout / 1000)}s limit — decision ${auth.decisionId} ${withdrawn ? "was withdrawn in Decidio (the queue no longer shows it; a later approval does nothing)" : shared ? "is shared with another caller (or an earlier try of this one), so it was left open for a person to decide - this process stopped waiting" : "is still open in Decidio (the withdraw call failed), 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.`);
334
+ throw new DecidioTimeoutError(auth.decisionId, withdrawn, shared);
257
335
  }
258
336
  await sleep(pollInterval);
259
337
  }
260
338
  const check = await checkApproval(config, auth.decisionId, committed, { sent: !!sentCommitment, ...(approvedStatus ? { status: approvedStatus } : {}) });
261
339
  if (!check.ok)
262
340
  throw approvalRefusal(check);
341
+ if (check.state === "approved") {
342
+ const claimed = await claimApproval(config, auth.decisionId, newClaimKey());
343
+ if (!claimed.ok)
344
+ throw claimRefusal(claimed);
345
+ }
263
346
  }
264
347
  const result = opts?.withContext
265
348
  ? await fn({ decisionId: auth.decisionId, attemptId: null, idempotencyKey: auth.decisionId }, ...args)
@@ -299,6 +382,6 @@ export function withApproval(fn, describe, config, opts) {
299
382
  }
300
383
  if (evidenceProblem)
301
384
  confirmation.evidenceError = evidenceProblem;
302
- return { value: result, decisionId: auth.decisionId, confirmation };
385
+ return { value: result, decisionId: auth.decisionId, confirmation, ...(requestKey ? { requestKey } : {}) };
303
386
  };
304
387
  }
package/dist/inngest.d.ts CHANGED
@@ -39,6 +39,7 @@ export type GateStepOutcome<R> =
39
39
  decisionId: string;
40
40
  value: R;
41
41
  confirmation: EvidenceOutcome;
42
+ requestKey?: string;
42
43
  }
43
44
  /** The action never ran — a human said no, or a rule blocked it — so there is nothing to confirm. */
44
45
  | {
@@ -70,6 +71,10 @@ export declare function decidioGateStep<R>(args: {
70
71
  * path takes. This door's own confirm chokepoint tells callers to use it; until 0.2.0 no
71
72
  * adapter door accepted one, so the advice was unfollowable everywhere it was printed. */
72
73
  evidence?: (result: R) => unknown;
74
+ /** Inngest's id for THIS function run - the handler's `runId` (0.12.0, 11f). The request key is keyed on it, so Inngest
75
+ * retrying the authorize step is the same request while it waits. Without it the key is
76
+ * over a fresh id for the call (a retried step may then ask again). */
77
+ runId?: string;
73
78
  }): Promise<GateStepOutcome<R>>;
74
79
  /**
75
80
  * Decidio → Inngest bridge. Mount as an HTTP endpoint and register it as the agent's
package/dist/inngest.js CHANGED
@@ -4,7 +4,7 @@ import { approvalRefusal } from "./index.js";
4
4
  export const DECIDIO_RESUME_EVENT = "decidio/decision.resolved";
5
5
  export async function decidioGateStep(args) {
6
6
  const { step, config, ctx } = args;
7
- const auth = await step.run("decidio-authorize", async () => gateAuthorize(config, ctx));
7
+ const auth = await step.run("decidio-authorize", async () => gateAuthorize(config, ctx, typeof args.runId === "string" && args.runId ? { naturalId: { engine: "inngest", runId: args.runId, step: "decidio-authorize" } } : {}));
8
8
  assertAuthorizeVerdict(auth, auth?.decisionId);
9
9
  if (auth.decision === "block")
10
10
  return { status: "blocked", decisionId: auth.decisionId };
@@ -31,7 +31,7 @@ export async function decidioGateStep(args) {
31
31
  }
32
32
  const result = await step.run("agent-action", args.run);
33
33
  const confirmation = await step.run("decidio-confirm", async () => gateConfirm(config, auth.decisionId, result, args.evidence));
34
- return { status: "executed", decisionId: auth.decisionId, value: result, confirmation };
34
+ return { status: "executed", decisionId: auth.decisionId, value: result, confirmation, ...(typeof auth.requestKey === "string" ? { requestKey: auth.requestKey } : {}) };
35
35
  }
36
36
  export function createInngestResumeBridge(opts) {
37
37
  const secret = opts.webhookSecret ?? "";
package/dist/resume.d.ts CHANGED
@@ -38,6 +38,13 @@ export interface PendingAction {
38
38
  * compared with the commitment Decidio returns. Absent on entries parked before 0.7.0 (and by
39
39
  * an agent with no key material), which are checked on Decidio's state alone. */
40
40
  requestCommitment?: string;
41
+ /** THE RUN'S CLAIM KEY (0.12.0, founder call 24): made at random when the action is parked, and the key this parked
42
+ * action collects a person's approval with (POST /agent/claim) right before it runs. It lives HERE, with the parked
43
+ * action, so every resume of it - a worker that restarts, a signal after a lost answer, a claim an operator released -
44
+ * is the same run to Decidio (`again: true`), while another process that parked its own copy of the request holds its
45
+ * own key and is told the approval was used. Covered by the payload signature. Absent on entries parked before 0.12.0:
46
+ * those claim with a fresh key each time (a lost answer then costs a new approval, never a second run). */
47
+ claimKey?: string;
41
48
  }
42
49
  /** Where suspended actions live between request and approval. The default is a
43
50
  * file store; swap in Redis/Postgres/your queue for production. AGENT-SIDE only —
@@ -636,7 +643,11 @@ export interface ResumeController extends ResumeConfig {
636
643
  * /agent/status + the request commitment — `checkApproval`). Decidio still pending → `not_approved`;
637
644
  * Decidio says rejected/blocked → recorded as that denial; an approval that cannot be tied to these
638
645
  * arguments → `request_unverified`. A transport failure during that check throws (nothing ran). A
639
- * `rejected`/`blocked` verdict is applied as given — it can only ever prevent execution. */
646
+ * `rejected`/`blocked` verdict is applied as given — it can only ever prevent execution.
647
+ *
648
+ * A PERSON'S approval is then COLLECTED ONCE (0.12.0, `claimApproval`) with the claim key parked with the action:
649
+ * another run already used it, or it expired → `blocked` with the reason (recorded like a block; nothing ran); a 404
650
+ * or a claim that could not be sent → throws (nothing ran, the entry stays parked for the next signal). */
640
651
  resolve(input: {
641
652
  decisionId: string;
642
653
  verdict: "approved" | "rejected" | "blocked";
@@ -704,7 +715,9 @@ export declare function assertConfirmRecorded(body: any, decisionId: string, sig
704
715
  * authorized with, or null for a decision whose request was never bound. `spent` and `grantExpiresAt` (0.8.0,
705
716
  * mini-approvals): the grant is ONE-TIME - `spent` once an execution was recorded on it, `grantExpiresAt` when
706
717
  * it lapses (the seal's time plus the gate's TTL) for a granted decision, null otherwise. A gate that predates
707
- * them answers neither. */
718
+ * them answers neither. `handedBackAt` (when a replay handed the grant to a retry) and `claimed` (0.12.0: whether a
719
+ * run has collected a person's approval - never by whom) are informational: reading the status claims NOTHING, and
720
+ * this SDK decides a claim only by claiming (claimApproval). */
708
721
  export interface DecisionStatus {
709
722
  state: string;
710
723
  decisionId?: string;
@@ -713,6 +726,8 @@ export interface DecisionStatus {
713
726
  requestCommitment?: string | null;
714
727
  spent?: boolean;
715
728
  grantExpiresAt?: string | null;
729
+ handedBackAt?: string | null;
730
+ claimed?: boolean;
716
731
  }
717
732
  type StatusConfig = {
718
733
  apiUrl: string;
@@ -764,6 +779,41 @@ export declare function checkApproval(cfg: StatusConfig, decisionId: string, req
764
779
  sent?: boolean;
765
780
  status?: DecisionStatus;
766
781
  }): Promise<ApprovalCheck>;
782
+ /** How long the SDK waits before each retry of a call it may safely repeat (11f): the authorize POST (with the same
783
+ * body, so the same request key) and the claim (with the same claim key). Bounded: two retries, then the failure is the
784
+ * caller's to see. Python twin: RETRY_DELAYS. */
785
+ export declare const RETRY_DELAYS_MS: readonly number[];
786
+ /** The grammar the gate takes for a request key and a claim key alike. */
787
+ export declare const REQUEST_KEY_RE: RegExp;
788
+ /** A claim key: RANDOM PER RUN (two workers with one key would be one run to Decidio, and both would run). The durable
789
+ * path makes one when it parks an action and keeps it there; the blocking wait and the OpenAI resume make one per call. */
790
+ export declare function newClaimKey(): string;
791
+ /** Why a claim did not let this run go ahead. already_used / expired / not_claimable are Decidio's FINAL answers (409):
792
+ * this approval will never run here. not_found (404: no such decision for this credential, or a gate that predates
793
+ * claims), refused (any other 4xx), unreachable (a network failure or a 5xx after the retries) and unrecognized (an
794
+ * answer this SDK does not understand) may change - nothing ran, and a later try may succeed. */
795
+ export type ClaimCode = "already_used" | "expired" | "not_claimable" | "not_found" | "refused" | "unreachable" | "unrecognized";
796
+ /** The outcome of one claim. Plain JSON on purpose, like ApprovalCheck: an engine that must run HTTP in an activity
797
+ * (Temporal) returns it across its serialization boundary. */
798
+ export type ClaimOutcome = {
799
+ ok: true;
800
+ decisionId: string;
801
+ again: boolean;
802
+ } | {
803
+ ok: false;
804
+ decisionId: string;
805
+ code: ClaimCode;
806
+ final: boolean;
807
+ reason: string;
808
+ status?: number;
809
+ };
810
+ /** COLLECT A PERSON'S APPROVAL FOR THIS RUN, ONCE (POST /agent/claim/:decisionId) - the step every door that RUNS on a
811
+ * person's approval takes right before it runs: the blocking wait, the durable resume, Temporal and the OpenAI resume. The
812
+ * first claim wins; the same key claiming again is the same run retrying (`again: true`); any other key is told the
813
+ * approval was already used. Reading the status claims nothing, so nothing that only watches ever calls this. An
814
+ * auto-approval is never claimed (it is never shared). Retried with the SAME key on a network failure or a 5xx; every
815
+ * answer other than a 200 that says `claimed: true` means DO NOT RUN. Never throws for an answer - a refusal is a value. */
816
+ export declare function claimApproval(cfg: StatusConfig, decisionId: string, claimKey: string): Promise<ClaimOutcome>;
767
817
  /** The webhook handlers' refusal when no webhook secret is configured - with the remedy. */
768
818
  /** The one remedy every webhook-secret refusal names (#4B review W3): the dedicated webhook secret - NEVER the
769
819
  * server's API_AUTH_TOKEN, the admin bearer, which a refusal that said "the value your deployment signs