@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/resume.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createHmac, timingSafeEqual, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify, randomUUID } from "node:crypto";
1
+ import { createHmac, timingSafeEqual, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify, randomUUID, randomBytes } from "node:crypto";
2
2
  import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync, renameSync, openSync, closeSync, fsyncSync, linkSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { userInfo } from "node:os";
@@ -7,7 +7,7 @@ import { signConfirm, canonicalize as jcs, assertSafeTransport, carriesCredentia
7
7
  import { canonicalize as toCanonical, DecidioUnsafeArgumentsError } from "./canonical.js";
8
8
  import { readConfirmResponse, buildConfirmBody, tierConfirms, tombstoneTier } from "./evidence.js";
9
9
  import { DecidioHttpError, decisionIdProblem } from "./errors.js";
10
- const argsBindingBytes = (p) => Buffer.from(jcs({ decisionId: p.decisionId, action: p.action, amount: p.amount, ...(p.scope !== undefined ? { scope: p.scope } : {}), args: p.args, ...(p.requestCommitment !== undefined ? { requestCommitment: p.requestCommitment } : {}) }), "utf8");
10
+ const argsBindingBytes = (p) => Buffer.from(jcs({ decisionId: p.decisionId, action: p.action, amount: p.amount, ...(p.scope !== undefined ? { scope: p.scope } : {}), args: p.args, ...(p.requestCommitment !== undefined ? { requestCommitment: p.requestCommitment } : {}), ...(p.claimKey !== undefined ? { claimKey: p.claimKey } : {}) }), "utf8");
11
11
  function signArgs(p, agentKey, webhookSecret) {
12
12
  const bytes = argsBindingBytes(p);
13
13
  if (agentKey) {
@@ -882,6 +882,55 @@ export async function checkApproval(cfg, decisionId, request, opts = {}) {
882
882
  }
883
883
  return { ok: true, decisionId, state };
884
884
  }
885
+ export const RETRY_DELAYS_MS = [300, 1_000];
886
+ export const REQUEST_KEY_RE = /^[A-Za-z0-9._:-]{16,128}$/;
887
+ export function newClaimKey() {
888
+ return `run_${randomBytes(18).toString("base64url")}`;
889
+ }
890
+ export async function claimApproval(cfg, decisionId, claimKey) {
891
+ const no = (code, reason, status) => ({ ok: false, decisionId, code, final: code === "already_used" || code === "expired" || code === "not_claimable", reason, ...(status !== undefined ? { status } : {}) });
892
+ if (typeof claimKey !== "string" || !REQUEST_KEY_RE.test(claimKey))
893
+ return no("refused", "this run's claim key is not one Decidio accepts (16-128 letters, digits, dot, colon, underscore or dash). Nothing ran.");
894
+ const f = cfg.fetchImpl ?? fetch;
895
+ assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
896
+ const url = cfg.apiUrl.replace(/\/$/, "") + "/agent/claim/" + encodeURIComponent(decisionId);
897
+ let last = "";
898
+ for (let attempt = 0;; attempt++) {
899
+ let res;
900
+ try {
901
+ res = await f(url, { method: "POST", headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) }, body: JSON.stringify({ claimKey }) });
902
+ }
903
+ catch (e) {
904
+ last = `could not reach Decidio: ${e?.cause?.code ?? e?.code ?? e?.message ?? e}`;
905
+ }
906
+ if (res) {
907
+ const body = await res.json().catch(() => null);
908
+ if (res.status === 200) {
909
+ if (body && body.ok === true && body.claimed === true)
910
+ return { ok: true, decisionId, again: body.again === true };
911
+ return no("unrecognized", "Decidio answered the claim with HTTP 200 but did not say this run collected the approval, and an answer this SDK does not understand is never permission. Nothing ran.", 200);
912
+ }
913
+ if (res.status === 409) {
914
+ const code = body?.code;
915
+ if (code === "ALREADY_USED")
916
+ return no("already_used", "this approval was already used by another run. Ask again for a new approval. Nothing ran.", 409);
917
+ if (code === "EXPIRED")
918
+ return no("expired", "this approval has expired. Ask again for a new approval. Nothing ran.", 409);
919
+ if (code === "NOT_CLAIMABLE")
920
+ return no("not_claimable", "Decidio says this approval can't be collected by a run (only a person's approval is, and a deploy approval belongs to its deploy job). Nothing ran.", 409);
921
+ return no("unrecognized", `Decidio refused the claim with a code this SDK does not know (${JSON.stringify(code ?? null)}). Nothing ran.`, 409);
922
+ }
923
+ if (res.status === 404)
924
+ return no("not_found", "Decidio did not let this agent collect the approval (HTTP 404: it does not know this decision for this credential, or it predates approval claims). Nothing ran.", 404);
925
+ if (!(res.status === 429 || (res.status >= 500 && res.status !== 501)))
926
+ return no("refused", `Decidio refused to let this run collect the approval (HTTP ${res.status}). Nothing ran.`, res.status);
927
+ last = `HTTP ${res.status}`;
928
+ }
929
+ if (attempt >= RETRY_DELAYS_MS.length)
930
+ return no("unreachable", `could not collect the approval from Decidio after ${attempt + 1} tries (${last}). Nothing ran.`);
931
+ await sleep(RETRY_DELAYS_MS[attempt]);
932
+ }
933
+ }
885
934
  const V2_STORE_METHODS = [
886
935
  "put", "get", "delete", "list",
887
936
  "claim", "markInvoking", "recordExecuted", "markIndeterminate", "markReported",
@@ -1125,6 +1174,25 @@ export function createDecidioResume(opts) {
1125
1174
  refuseOnce(decisionId, `[decidio] REFUSING to execute ${decisionId}: ${check.reason}. Nothing ran; the parked entry is untouched for inspection. If the request really changed, reject this decision and re-run the action to request a fresh approval.`);
1126
1175
  return finish({ status: "request_unverified", decisionId, action: pending.action, reason: check.reason });
1127
1176
  }
1177
+ if (check.state === "approved") {
1178
+ let claimed;
1179
+ try {
1180
+ claimed = await claimApproval(ctrl, decisionId, pending.claimKey ?? newClaimKey());
1181
+ }
1182
+ catch (e) {
1183
+ await ctrl.store.releasePreinvoke(decisionId, attemptId).catch(() => { });
1184
+ throw e;
1185
+ }
1186
+ if (!claimed.ok) {
1187
+ if (claimed.final) {
1188
+ await ctrl.store.markDiscarded(decisionId, "blocked", claimed.reason);
1189
+ await ctrl.store.delete(decisionId);
1190
+ return finish({ status: "blocked", decisionId, action: pending.action, reason: claimed.reason });
1191
+ }
1192
+ await ctrl.store.releasePreinvoke(decisionId, attemptId).catch(() => { });
1193
+ throw new Error(`[decidio] ${decisionId}: ${claimed.reason} The entry stays parked; the next signal tries again with the same claim key.`);
1194
+ }
1195
+ }
1128
1196
  try {
1129
1197
  await ctrl.store.markInvoking(decisionId, attemptId);
1130
1198
  }
@@ -1194,10 +1262,11 @@ export function createDecidioResume(opts) {
1194
1262
  catch (e) {
1195
1263
  throw new DecidioUnsafeArgumentsError(`${e?.message ?? e} NOT parked and NOT executable — but decision ${p.decisionId} may already be routed; reject it in your queue: npx @decidio/sdk approvals reject ${p.decisionId}`, p.decisionId);
1196
1264
  }
1197
- const argsSig = signArgs(p, ctrl.agentKey, integrityKey());
1265
+ const parked = { ...p, claimKey: p.claimKey ?? newClaimKey() };
1266
+ const argsSig = signArgs(parked, ctrl.agentKey, integrityKey());
1198
1267
  if (!argsSig)
1199
1268
  console.warn(`[decidio] parking ${p.decisionId} UNBOUND — no agent key or webhook secret to sign the payload with, so a modified pending file could not be detected. Run \`npx @decidio/sdk init\` to configure keys.`);
1200
- await ctrl.store.put({ ...p, createdAt: new Date().toISOString(), ...(argsSig ? { argsSig } : {}) });
1269
+ await ctrl.store.put({ ...parked, createdAt: new Date().toISOString(), ...(argsSig ? { argsSig } : {}) });
1201
1270
  },
1202
1271
  async resolve(input) { return resolveCore(input); },
1203
1272
  webhookHandler() {
@@ -1478,7 +1547,7 @@ export function createDecidioResume(opts) {
1478
1547
  else if (outcome.status === "rejected")
1479
1548
  console.error(`[decidio] resumed ${p.decisionId}: REJECTED — the parked "${p.action}" was discarded, nothing executed.`);
1480
1549
  else if (outcome.status === "blocked")
1481
- console.error(`[decidio] resumed ${p.decisionId}: BLOCKED by policy — the parked "${p.action}" was discarded, nothing executed.`);
1550
+ console.error(`[decidio] resumed ${p.decisionId}: BLOCKED ${outcome.reason ? `(${outcome.reason})` : "by policy"} — the parked "${p.action}" was discarded, nothing executed.`);
1482
1551
  workerOpts?.onResolved?.(outcome);
1483
1552
  }
1484
1553
  catch (e) {
package/dist/signing.d.ts CHANGED
@@ -126,6 +126,14 @@ export declare function requestCommitment(material: BindingMaterial, request: Co
126
126
  /** lowercase-hex idempotency key over an execution identity (e.g. LangGraph's thread + task +
127
127
  * commitment), keyed so the host's thread ids never reach Decidio in the clear. */
128
128
  export declare function idempotencyKey(material: BindingMaterial, identity: Record<string, JsonValue>): string;
129
+ /** THE REQUEST KEY A CALL SENDS BY DEFAULT (11f, founder 2026-10-30): an idempotency key over a per-call random id and
130
+ * the request's commitment, so the SDK's own retry of one call is the same request (the gate answers the decision it
131
+ * already made) while two calls stay two requests. Python twin: invocation_request_key. */
132
+ export declare function invocationRequestKey(material: BindingMaterial, invocationId: string, commitment: string): string;
133
+ /** A CALLER'S OWN REQUEST KEY (11f), e.g. an order id: keyed like every other identity, so the caller's id never reaches
134
+ * Decidio in the clear. Deliberately NOT over the commitment: the same key for a changed request is the gate's 409
135
+ * IDEMPOTENCY_CONFLICT, never a silent second request. Python twin: own_request_key. */
136
+ export declare function ownRequestKey(material: BindingMaterial, key: string): string;
129
137
  /** The Claude Code hook's request scope: `cmd-` + 16 hex of a KEYED digest over the command text and its subject
130
138
  * (chat approvals follow-up, invariant-guard 2026-10-15). The text may carry a credential the display redacts; an
131
139
  * unkeyed digest sealed beside the redacted text would be an offline oracle for a weak password. Keyed under its own
package/dist/signing.js CHANGED
@@ -198,6 +198,12 @@ export function requestCommitment(material, request) {
198
198
  export function idempotencyKey(material, identity) {
199
199
  return createHmac("sha256", bindingSubkey(material, "decidio.idempotency-key.v1")).update(canonicalJson(identity, "idempotency identity"), "utf8").digest("hex");
200
200
  }
201
+ export function invocationRequestKey(material, invocationId, commitment) {
202
+ return idempotencyKey(material, { invocation: invocationId, commitment });
203
+ }
204
+ export function ownRequestKey(material, key) {
205
+ return idempotencyKey(material, { requestKey: key });
206
+ }
201
207
  export function boundScope(material, text) {
202
208
  return "cmd-" + createHmac("sha256", bindingSubkey(material, "decidio.hook-scope.v1")).update(text, "utf8").digest("hex").slice(0, 16);
203
209
  }
package/openapi.yaml CHANGED
@@ -5,7 +5,7 @@
5
5
  openapi: 3.1.0
6
6
  info:
7
7
  title: Decidio Agent Gate
8
- version: 0.10.0 # tracks the SDK release - checked by packages/conformance/manifests.mjs
8
+ version: 0.12.0 # tracks the SDK release - checked by packages/conformance/manifests.mjs
9
9
  description: >
10
10
  Authorize an AI-agent action (proceed | route | block), confirm the captured outcome, and
11
11
  receive the signed resume webhook when a routed action is decided. Every outcome seals a
@@ -87,6 +87,15 @@ components:
87
87
  same requestCommitment AND the same requester, action, amount and scope (the server binds that
88
88
  tuple itself) - otherwise 409 IDEMPOTENCY_CONFLICT; an earlier decision is never handed back for a
89
89
  changed request.
90
+
91
+
92
+ SDK 0.12.0 sends one BY DEFAULT beside every requestCommitment: a keyed HMAC over a fresh id for the
93
+ call and the commitment (or over the caller's own key, or an engine's own id for the call - an
94
+ OpenAI tool call id, a Temporal workflow run and activity, an Inngest run), and retries a network
95
+ failure, a 5xx or a non-AGENT_RATE 429 with the SAME key: a retry of a request still waiting is
96
+ answered with the one already waiting, and no retry runs an action twice. (A request is not
97
+ guaranteed single: an auto-approval whose answer is lost on every try, or a keyless request a policy
98
+ blocks, can make a second decision.) Without key material there is no commitment and no key.
90
99
  AuthorizeResponse:
91
100
  type: object
92
101
  required: [decision, decisionId, receiptId]
@@ -111,14 +120,27 @@ components:
111
120
  and `receipt` carries only the fields fixed at create. A sealed auto-approval replays as `proceed`
112
121
  only when THIS call could itself auto-approve (a verified, replay-protected identity or a trusted
113
122
  transport); anything else answers `route` or `block` - ask GET /agent/status for the real state.
123
+ openTwin:
124
+ type: boolean
125
+ description: >
126
+ present (true, beside idempotentReplay) when this KEYLESS request joined the same request from the
127
+ same agent that is still waiting for a person: nothing new was created, counted or summoned, and the
128
+ caller waits on that decision. The request may be shared, so a caller that gives up waiting must not
129
+ withdraw it (the SDKs skip their timeout withdraw after either flag; the server refuses a shared
130
+ withdraw with 409 SHARED_REQUEST).
114
131
  StatusResponse:
115
132
  type: object
116
133
  required: [state]
134
+ description: >
135
+ Reading the status claims NOTHING: a dashboard, the CLI or a second watcher can poll it freely. The run
136
+ that acts on a person's approval collects it with POST /agent/claim/{decisionId}.
117
137
  properties:
118
138
  state:
119
139
  type: string
120
140
  enum: [pending, approved, auto_approved, rejected, blocked]
121
- reason: { type: string }
141
+ decisionId: { type: string }
142
+ receiptId: { type: [string, "null"] }
143
+ reason: { type: [string, "null"], description: the approver's reason, tokenized; never a cleartext name }
122
144
  requestCommitment:
123
145
  type: [string, "null"]
124
146
  description: >
@@ -126,6 +148,27 @@ components:
126
148
  sent). The SDK treats an approval as covering the request it is about to run only when this
127
149
  equals its own recompute; a gate that returns no commitment for a request the SDK bound is
128
150
  refused (commitment_missing), never trusted.
151
+ spent:
152
+ type: boolean
153
+ description: >
154
+ SDK 0.8.0: true once an execution is recorded on this approval - one approval covers one action,
155
+ once. The SDKs refuse to run on a spent approval and ask again.
156
+ grantExpiresAt:
157
+ type: [string, "null"]
158
+ format: date-time
159
+ description: >
160
+ SDK 0.8.0: when a granted decision's approval lapses (its seal's time plus the gate's TTL); null
161
+ otherwise. A granted decision with no expiry has a grant of unknown age, which the SDKs treat as
162
+ expired.
163
+ handedBackAt:
164
+ type: [string, "null"]
165
+ format: date-time
166
+ description: when an idempotent replay handed this granted decision back to a retry (null otherwise)
167
+ claimed:
168
+ type: boolean
169
+ description: >
170
+ SDK 0.12.0: whether a run has collected this person's approval (POST /agent/claim) - never by whom.
171
+ Informational: it claims nothing, and a caller decides a claim only by claiming.
129
172
  ConfirmRequest:
130
173
  type: object
131
174
  required: [decisionId, capturedResponse, source]
@@ -328,6 +371,119 @@ paths:
328
371
  check runs BEFORE any lookup, so this response reveals nothing about existence.
329
372
  "401": { description: missing/invalid agent token }
330
373
  "404": { description: unknown decision }
374
+ /agent/claim/{decisionId}:
375
+ post:
376
+ summary: Collect a person's approval for ONE run, once, right before acting on it (SDK 0.12.0)
377
+ description: >
378
+ Several programs can wait on one approval (callers that asked the same request, a second poller); only
379
+ the run that collects it runs it. The first claim wins. The same claimKey claiming again is the same run
380
+ retrying (a lost answer) and is answered the same, with `again: true`. Any other key is told the
381
+ approval was already used, and asks again. Only a PERSON's approval (state `approved`) is claimed: an
382
+ auto-approval is never shared, so it is NOT_CLAIMABLE, and so are a refusal, a waiting request and a
383
+ GitHub deploy approval (its deploy job uses it). A spent or expired approval is refused before anything
384
+ is written. A person's sign-in cannot claim (people approve, runs claim): it gets the 404. A bound agent
385
+ token claims only its own signed decisions; an unsigned decision is claimable by any agent credential
386
+ of the workspace (founder call 29). The SDKs make the claimKey at random for each run (two workers with
387
+ one key would be one run), retry a network failure or a 5xx with the SAME key, and run only on a 200.
388
+ parameters:
389
+ - name: decisionId
390
+ in: path
391
+ required: true
392
+ schema: { type: string }
393
+ requestBody:
394
+ required: true
395
+ content:
396
+ application/json:
397
+ schema:
398
+ type: object
399
+ required: [claimKey]
400
+ properties:
401
+ claimKey:
402
+ type: string
403
+ pattern: "^[A-Za-z0-9._:-]{16,128}$"
404
+ description: the run's own key; stored only as a keyed digest scoped to this decision
405
+ responses:
406
+ "200":
407
+ description: this run collected the approval - run the action
408
+ content:
409
+ application/json:
410
+ schema:
411
+ type: object
412
+ required: [ok, claimed, decisionId, again]
413
+ properties:
414
+ ok: { type: boolean, enum: [true] }
415
+ claimed: { type: boolean, enum: [true] }
416
+ decisionId: { type: string }
417
+ again: { type: boolean, description: true when the same key claimed again (the same run retrying) }
418
+ "400": { description: "a malformed decision id (INVALID_DECISION_ID) or claimKey (VALIDATION)" }
419
+ "401": { description: missing/invalid agent token }
420
+ "404": { description: unknown decision, one this credential may not claim, or a person's sign-in }
421
+ "409":
422
+ description: >
423
+ Do NOT run. `ALREADY_USED` - another run collected this approval, or it was already used: ask again
424
+ for a new one. `EXPIRED` - the approval lapsed: ask again. `NOT_CLAIMABLE` - not a person's approval
425
+ (still waiting, refused, an auto-approval, or a deploy approval).
426
+ content:
427
+ application/json:
428
+ schema:
429
+ type: object
430
+ properties:
431
+ code: { type: string, enum: [ALREADY_USED, EXPIRED, NOT_CLAIMABLE] }
432
+ decisionId: { type: string }
433
+ state: { type: string, enum: [pending, approved, auto_approved, rejected, blocked] }
434
+ message: { type: string }
435
+ /agent/withdraw/{decisionId}:
436
+ post:
437
+ summary: The requester stopped waiting - close its OPEN routed request as withdrawn (SDK 0.6.4)
438
+ description: >
439
+ The blocking SDK wait calls this when it hits its ceiling, so the queue stops carrying a request nobody
440
+ will act on and a later tap cannot seal "approved" for an action that will never run. A withdrawal only
441
+ ever reduces authority. Only the requester may withdraw: a bound agent token whose agent signed the
442
+ request; everything else is the 404. A withdrawn request reads `rejected` on GET /agent/status. A SHARED
443
+ request is never withdrawn: once another caller joined it (openTwin), the answer is 409 SHARED_REQUEST and
444
+ nothing changes. The SDKs skip the withdraw after an `idempotentReplay` or `openTwin` answer.
445
+ parameters:
446
+ - name: decisionId
447
+ in: path
448
+ required: true
449
+ schema: { type: string }
450
+ requestBody:
451
+ content:
452
+ application/json:
453
+ schema:
454
+ type: object
455
+ properties:
456
+ reason: { type: string, enum: [timeout, cancelled], description: "default timeout" }
457
+ responses:
458
+ "200":
459
+ description: withdrawn
460
+ content:
461
+ application/json:
462
+ schema:
463
+ type: object
464
+ properties:
465
+ ok: { type: boolean }
466
+ withdrawn: { type: boolean, enum: [true] }
467
+ state: { type: string, enum: [rejected] }
468
+ decisionId: { type: string }
469
+ reason: { type: string }
470
+ "400": { description: "a malformed decision id (INVALID_DECISION_ID) or an unknown reason (INVALID_REASON)" }
471
+ "401": { description: missing/invalid agent token }
472
+ "404": { description: unknown decision, or not this requester's own signed request }
473
+ "409":
474
+ description: >
475
+ `NOT_OPEN` - a decision already stands (or it was already withdrawn); `state` is the derived state, so
476
+ an approval that landed in the last moment is honoured. `SHARED_REQUEST` - another caller is waiting on
477
+ this same request; nothing changed (no `state`), and a person still decides it.
478
+ content:
479
+ application/json:
480
+ schema:
481
+ type: object
482
+ properties:
483
+ code: { type: string, enum: [NOT_OPEN, SHARED_REQUEST] }
484
+ state: { type: string, enum: [pending, approved, auto_approved, rejected, blocked] }
485
+ decisionId: { type: string }
486
+ message: { type: string }
331
487
  /agent/confirm:
332
488
  post:
333
489
  summary: >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decidio/sdk",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "One-line approval gate for AI-agent actions. Wrap a risky tool call with guard.protect(); Decidio's policy engine decides proceed | route | block, a human approves routed actions, and every outcome is sealed as a verifiable Authority Receipt.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -84,7 +84,7 @@
84
84
  "prepublishOnly": "npm run typecheck && npm run build && node ../publish-boundary/check.mjs --only @decidio/sdk"
85
85
  },
86
86
  "dependencies": {
87
- "@decidio/verify": "0.10.0"
87
+ "@decidio/verify": "0.12.0"
88
88
  },
89
89
  "author": "OmniTwin Technologies Inc.",
90
90
  "bugs": {