@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/CHANGELOG.md CHANGED
@@ -7,7 +7,117 @@ allowlist. Each entry names what changed, what a reader must do on upgrade, and
7
7
 
8
8
  ## Unreleased
9
9
 
10
- ## 0.10.0 — connect a coding assistant in one tap
10
+ ## 0.12.0 — request keys by default; an approval is collected once; the owner's guard controls
11
+
12
+ - **A request key by default** (founder 11f). Every authorize that carries a request commitment now carries an
13
+ `idempotencyKey` too: a keyed digest of a fresh id for the call and the commitment. The SDK retries the authorize twice
14
+ (after 0.3 s and 1 s) with the same request when the network fails, on a 5xx, or on a 429 that is not the workspace's
15
+ hourly limit - so a retry never runs an action twice, and a retry of a request still waiting is answered with the one
16
+ already waiting. A 4xx is never retried. A retry signs a fresh
17
+ identity proof (its nonce is single-use); nothing else changes. No key material: no commitment and no key, as before.
18
+ - `withApproval(..., { requestKey: (...args) => string })` / `protect(..., { requestKey })` uses your own key (an order
19
+ id) instead, keyed before it is sent. The same key for a changed request is refused (409 IDEMPOTENCY_CONFLICT).
20
+ - The result names the key it sent: `ProtectedExecution.requestKey`. `ExecutionContext.idempotencyKey` stays the
21
+ decision id - keep using it downstream.
22
+ - The engines key on their own ids: OpenAI on the tool call id; Temporal on the activity's workflow, run and activity
23
+ ids (`authorizeActivity`, new); Inngest on `runId` (new argument of `decidioGateStep`).
24
+ - **An approval is collected once** (founder call 24 "(a)"). Before the SDK runs an action on a person's approval it
25
+ claims it (`POST /agent/claim/<decisionId>`, `claimApproval`) with a key random to that run, and runs only if Decidio
26
+ says this run collected it. Otherwise it raises `DecidioBlockedError` in plain words, e.g. "This approval was already
27
+ used by another run. Ask again for a new approval." This covers the blocking wait, the durable resume, Temporal and the
28
+ OpenAI verified resume. A durable action keeps its claim key with it (`PendingAction.claimKey`), so a restarted worker
29
+ claims with the same key and is answered as the same run; a claim refused as used or expired is recorded like a block
30
+ (`status: "blocked"` with the reason). Auto-approvals are never claimed, and reading the status never claims.
31
+ - **A shared request is left open.** The blocking timeout no longer withdraws a request the authorize answered as
32
+ `idempotentReplay` or `openTwin`, and a 409 `SHARED_REQUEST` from the withdraw is reported, not thrown:
33
+ `DecidioTimeoutError.shared` is true and `withdrawn` false.
34
+ - The messages that said "a retry creates a NEW request" now say the SDK already retried with the same request.
35
+
36
+ On upgrade: the gate must have `POST /agent/claim` (the server's claim-once release). Against an older gate the claim
37
+ answers 404 and nothing runs on a person's approval - durable entries stay parked until the gate is upgraded. Temporal:
38
+ pass `claim: (decisionId, claimKey) => executeActivity(claimActivity, decisionId, claimKey)` (the activity returns
39
+ `claimApprovalActivity(config, decisionId, claimKey)`) beside `verify` - an injected `authorize` without it is refused
40
+ before anything is routed - and return `authorizeActivity(config, ctx)` from the authorize activity. Entries parked by an
41
+ older SDK (or put into a store directly) still resume, but carry no claim key: they claim with a fresh random key on
42
+ every resolve, so an answer lost on the way back costs a new approval - never a second run. Proved by: smoke.ts
43
+ (256 -> 292), adapters-smoke.ts (74 -> 83), cli-smoke.ts (416 -> 418), the conformance runners (a refusal-advice vector)
44
+ and the three-door gate (the authorize bodies stay byte-identical with a pinned invocation id).
45
+
46
+ ### The owner's guard controls (connect-once slice 4)
47
+
48
+ Cycle connect-once's slice 4 (founder 2026-10-30: "the owner would have controls to make the guard mandatory, disable pause").
49
+ Upgrade: nothing to do for a person; a team that wants its repositories held runs `connect --team` again and commits the file.
50
+
51
+ - **A team's committed hook line carries its guard policy key.** `connect --team` writes `--policy=<key>` into the tell line
52
+ (Team and Enterprise). A computer that isn't connected sends the key when it starts its link, and the start answers one
53
+ yes/no: whether the workspace owner holds computers that aren't connected. Held, a destructive command is refused until the
54
+ computer connects, instead of being told once and let through. If Decidio can't answer (unreachable, a server error, rate
55
+ limited), a keyed line holds; Decidio's connect switched off (404) falls back to the line's own `--unconnected`. An older
56
+ server that refuses the key is asked again without it. Editing the key out of the line is switching the check off, so it
57
+ is refused like any other such edit. A keyed line tells once ONLY when Decidio answered for its key that the team doesn't hold
58
+ (or connect is switched off): a project `.env` naming an agent or another server, or a link another check is starting, holds.
59
+ - **`connect --team` says when the line carries no team key** - a connection made before 0.12.0, or credentials from the shell
60
+ or `.env` - so the owner's hold won't reach the repository, and how to fix it (`connect --team --force`).
61
+ - **Claude Code cloud sessions**: when the hook can't reach Decidio, it says the environment's network access must allow
62
+ Decidio's host, under Network access → Allowed domains (the default level doesn't list it), with the docs link.
63
+ - **`hook managed-settings`** prints the Decidio hooks as JSON for Claude Code's managed settings (an organisation-wide
64
+ install), from the same set `hook install` writes - a test holds it equal to the plugin's `hooks.json`. Where the file goes,
65
+ and what it does and doesn't guarantee, go to stderr.
66
+ - **The session line follows the owner's keep-on switch** from the next pause answer (the computer remembers it).
67
+ - Proved by: cli-smoke 418 -> 427 (the keyed line held, told, unanswerable, a `.env` or a lock in the way, connect off, the key edited
68
+ out, the cloud network words, the keyless `--team` line, managed-settings equal to hooks.json, the remembered answer); connect.smoke and the integration suite on the server side.
69
+
70
+
71
+ ## 0.11.0 — connect once, on in every project; no surprises; the connector connects a computer
72
+
73
+ 0.10.0's tag was held for this release, so 0.11.0 is the first release meant to carry `connect` (0.10.0 was then published by
74
+ mistake on 2026-10-30, from a tag on its own merge; 0.11.0 supersedes it). It carries
75
+ cycle connect-once's slice 1, 2a and 2b (founder 2026-10-29: one tag, after they merge).
76
+
77
+ - **One connect per computer.** A connection now covers every repository on the computer, and `connect` switches the hook
78
+ on in your own `~/.claude/settings.json`, which every project reads. A repository connected the 0.10.0 way keeps its own
79
+ connection (read first). A cloud session and `--team` still use the repository's `.claude/settings.json`.
80
+ - **The hook connects the computer itself.** A destructive command on a computer with no credential is refused with a link
81
+ and a short code (to Claude, and in a line to the person); once the workspace owner allows it, the next try finishes
82
+ connecting and asks for the approval in the same run. It never writes Claude Code's settings - in auto mode the assistant
83
+ may not, and a person put the hook there. Two hooks at once share one link. It connects only the Decidio your shell
84
+ names (or the default), never one a repository's `.env` names, and only over HTTPS; a project whose `.env` names an agent
85
+ with no token is refused by name rather than given the computer's connection. The run that connects tells the person which
86
+ workspace and who allowed it, even when the command then runs at once.
87
+ - **The computer-wide hook is the pinned `npx -y @decidio/sdk@0.11.0` command**, so it works in every project; a project
88
+ install still writes `$CLAUDE_PROJECT_DIR` unescaped, so Claude Code expands it. The settings file is written whole, and
89
+ through a symlink.
90
+ - **No surprises** (founder 2026-10-29: "the user is fully aware and not surprised"):
91
+ - `hook session`, a SessionStart hook: every session opens with one line - approvals are on here, what waits and for whom,
92
+ or that this computer isn't connected yet. Local only, never blocks;
93
+ - `connect` says what waits for approval and that everything else runs as before;
94
+ - a held command names who approves and where they see it;
95
+ - the plugin skill says what connecting does before doing it, and has the person run `! npx @decidio/sdk connect` when
96
+ auto mode refuses it.
97
+ - **The plugin** (0.5.0) carries the SessionStart hook, and a marketplace file ready to publish - not published (founder:
98
+ nothing public until the listing is ready).
99
+ - **The connector connects a computer** (slice 2a):
100
+ - `connect --ticket <ticket>` finishes the link the Decidio connector allowed (`connect_device` returns the ticket): the
101
+ poll carries this computer's own secret and the ticket, and only the credential is written;
102
+ - the hook names the connector's way first when a computer isn't connected (call `connect_device` with the code, run what
103
+ it returns), then the link and code; a link the connector allowed is kept while it waits for its ticket.
104
+ - **Pause and resume** (slice 2b): `hook prompt`, a UserPromptSubmit hook that `hook install`, `connect` and the plugin add
105
+ behind a shell pre-filter, acts on the person's own typed "decidio pause" / "decidio start" (exact words, `source: user`
106
+ only) and keeps the words from the model. While paused a gated command runs with a line and no decision - asked of Decidio only
107
+ in a session paused on this computer; the settings guard (now run over the whole line, inside `sh -c` too, for the pause and
108
+ the shared file's notice alike) and the self-decide refusal stay on; an agent running `hook prompt` itself is refused. The
109
+ session line says how to pause, or that the organisation keeps the guard on.
110
+ - **A shared file tells, then lets through** (slice 2a): `hook pretool --unconnected=tell`, which `connect` writes into a
111
+ shared file, refuses a computer with no credential once per session and command, then lets the same call pass with a line
112
+ to the person - never an `allow`, so Claude Code's own permission check still decides. No flag, a value it doesn't know,
113
+ or no session id holds, as before. `connect --team --hold-everyone` writes the holding hook instead.
114
+
115
+ On upgrade: nothing to do; 0.10.0 repository connections keep working. Proved by: cli-smoke.ts (378 -> 400; slice 1: one connect for
116
+ two repositories, the 0.10.0 file read first, the hook's own link - wait - collect-then-ask without touching settings, two
117
+ hooks sharing a link, a refused link, the approver in holds, the session line, SessionStart installed; slice 2a: the
118
+ connector's way, the ticket's usage, secret and ticket on the poll, tell once then a line, hold by default, --hold-everyone).
119
+
120
+ ## 0.10.0 — connect a coding assistant in one tap (not published; folded into 0.11.0)
11
121
 
12
122
  - **`npx @decidio/sdk connect`** (cycle connect). A coding assistant sets Decidio up for you: it runs `connect`, which prints a
13
123
  link and a short code (on a real terminal also a QR, and it opens the browser); the workspace owner opens the link signed in,
@@ -16,8 +126,8 @@ allowlist. Each entry names what changed, what a reader must do on upgrade, and
16
126
  ask-only credential - never a session - kept OUTSIDE the repository (the config dir, owner-only, keyed by the repository root
17
127
  and the Decidio), and the hooks switch on in `.claude/settings.local.json` (`--team`: the shared `.claude/settings.json`).
18
128
  No `.env`, nothing to paste, the word "agent" nowhere in the setup.
19
- - **In a cloud session the hooks go in `.claude/settings.json`.** A cloud session reads only the repository's own settings file,
20
- never `.claude/settings.local.json` (Claude Code's docs), so that is where they work. connect leaves it uncommitted and says to ask
129
+ - **In a cloud session the hooks go in `.claude/settings.json`.** A cloud session reads the repository's files, not your
130
+ computer's, and only the committed shared file reaches the next cloud machine, so that is where they go. connect leaves it uncommitted and says to ask
21
131
  the person before committing it (committed, every cloud session of the repository and every teammate's clone ask too). The
22
132
  credential is that cloud machine's: a fresh one refuses destructive commands until it connects again.
23
133
  - **The machine proves its key.** The start is signed with the machine's own key (the request-bound proof every signed
package/README.md CHANGED
@@ -528,6 +528,9 @@ wake-up.
528
528
  - **Temporal**: whenever you pass `authorize`, also pass `verify: (id) => executeActivity(verifyActivity, id)`,
529
529
  where the activity returns `verifyApprovalActivity(config, id, ctx)` (from `@decidio/sdk/temporal`)
530
530
  for the same `ctx`. A signal only wakes the workflow; still pending → it waits for the next one.
531
+ Since 0.12.0 also pass `claim: (id, key) => executeActivity(claimActivity, id, key)`, where the
532
+ activity returns `claimApprovalActivity(config, id, key)`, and return `authorizeActivity(config, ctx)`
533
+ from the authorize activity: it keys the request on the activity's workflow, run and activity ids.
531
534
  - **Inngest**: the check runs as a memoized step after the event; a premature or forged event waits again.
532
535
  - **OpenAI Agents**: `applyResume(state, item, verdict)` is removed. Use
533
536
  `await applyVerifiedResume(config, state, item, decisionId, describe)`, which approves the
@@ -541,11 +544,16 @@ wake-up.
541
544
  whole node on resume, so an earlier gate in the same node runs again: if its request auto-approved and
542
545
  its action already ran, the replay answers `proceed` to your verified agent and the action runs twice.
543
546
  Put each gated action in its own node, or make the actions idempotent.
544
- - **Known limit — LangGraph and OpenAI Agents have no single-execution claim** (true before 0.7.0 too).
545
- The checkpoint or the RunState is your storage, so two concurrent resumes of the same thread or run
546
- can both pass the check and both run the action. Resume each one from one place (one worker per
547
- thread/run id, or a lock). The core durable path claims each execution in its store; Temporal and
548
- Inngest rely on their engine's own activity/step semantics.
547
+ - **An approval is collected once (0.12.0).** Before acting on a person's approval, the blocking wait,
548
+ the durable resume, Temporal and `applyVerifiedResume` claim it (`POST /agent/claim/<id>`) with a key
549
+ random to that run, and run only if Decidio says this run collected it - otherwise
550
+ `DecidioBlockedError`: "This approval was already used by another run. Ask again for a new
551
+ approval." Auto-approvals are never claimed; reading the status never claims. Every authorize with a
552
+ request commitment also sends a request key (a fresh id per call, your own `requestKey`, or the
553
+ engine's id for the call), and a retried authorize carries the same key.
554
+ - **Known limit — LangGraph and Inngest do not claim yet.** Two concurrent resumes of the same LangGraph
555
+ thread (the checkpoint is your storage) can both pass the check and both run the action; resume each
556
+ one from one place (one worker per thread id, or a lock). Inngest relies on its own step semantics.
549
557
  - **Upgrade the gate first.** A keyed SDK refuses a routed approval from a Decidio gate that does not
550
558
  return commitments (`commitment_missing`) — it fails closed, never silently passes.
551
559
 
@@ -759,14 +767,15 @@ manager for production.
759
767
  - `DecidioRequestChangedError` (0.7.0, a `DecidioBlockedError`) — Decidio approved the decision for a *different* request than the one about to run ("the request changed after approval"); nothing executed. Ask again for the request you mean to run.
760
768
  - `DecidioApprovalUnverifiedError` (0.7.0, a `DecidioBlockedError`) — the approval could not be tied to this request: `code: "commitment_missing"` (Decidio returned no request commitment although this SDK sent one — typically a gate older than 0.7.0) or `"unverifiable"`. Nothing executed.
761
769
  - `DecidioSuspendedError` — (durable mode) the action was routed and is parked for async approval; the process may exit and resume later. Not a failure.
762
- - `DecidioTimeoutError` — (blocking mode only) no human decided within `pollTimeoutMs` (default 10 min); nothing executed locally. Since 0.6.4 the SDK's last act before throwing is to tell Decidio it gave up, so the request is closed as **withdrawn** (it leaves the queue; a later approval does nothing) — `error.withdrawn` is `true` when that landed and `false` when the call failed and the request may still be open. Withdrawing needs the bound agent token and the signing key `init` writes (an unbound or unsigned caller cannot withdraw anything — it just gets `withdrawn:false`). If a human decided in the last poll window, the SDK honours that decision instead: the action runs as approved, or `DecidioRejectedError` is thrown. The durable path never withdraws: its parked action is what a later approval resumes.
770
+ - `DecidioTimeoutError` — (blocking mode only) no human decided within `pollTimeoutMs` (default 10 min); nothing executed locally. Since 0.6.4 the SDK's last act before throwing is to tell Decidio it gave up, so the request is closed as **withdrawn** (it leaves the queue; a later approval does nothing) — `error.withdrawn` is `true` when that landed and `false` when the call failed and the request may still be open. Withdrawing needs the bound agent token and the signing key `init` writes (an unbound or unsigned caller cannot withdraw anything — it just gets `withdrawn:false`). If a human decided in the last poll window, the SDK honours that decision instead: the action runs as approved, or `DecidioRejectedError` is thrown. The durable path never withdraws: its parked action is what a later approval resumes. Since 0.12.0 a request that may be shared is never withdrawn: after an `idempotentReplay` or `openTwin` answer, or a 409 `SHARED_REQUEST`, `error.shared` is `true` and a person still decides it.
763
771
  - `DecidioRateLimitError` — the workspace's governed-action limit was hit; carries the server's `limit` / `remaining` / `resetAt`.
764
772
  - `DecidioHttpError` — the gate refused the call. Carries `status`, `endpoint`, the parsed `body`,
765
773
  and **`retryable`** — the field to branch on. A 401/403 is terminal (the agent token expired, or
766
774
  someone re-ran `init` for that agent and revoked it): retrying cannot help, so the message names
767
- the fix. Anything else is transient, and retrying is safe because a retry creates a *new* request
768
- and can never double-execute.
769
- - `DecidioNetworkError` — the gate was unreachable. Nothing executed locally.
775
+ the fix. Anything else is transient: the SDK already retried the authorize with the same request key
776
+ (0.12.0) - a request still waiting is answered with the one already waiting - and retrying is safe: it
777
+ can never double-execute.
778
+ - `DecidioNetworkError` — the gate was unreachable (after the SDK's own retries, for the authorize). Nothing executed locally.
770
779
  - `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.
771
780
 
772
781
  ```js
@@ -1,13 +1,19 @@
1
- import { type GuardConfig, type ApprovalContext, type EvidenceOutcome, type ApprovalCheck, type CommittedRequest } from "../index.js";
1
+ import { type GuardConfig, type ApprovalContext, type EvidenceOutcome, type ApprovalCheck, type ClaimOutcome, type CommittedRequest, type JsonValue } 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"];
5
5
  export type AuthorizeVerdict = (typeof AUTHORIZE_VERDICTS)[number];
6
+ /** `requestKey` (0.12.0): the request key this authorize sent, when it sent one. `idempotentReplay` / `openTwin`: the gate
7
+ * answered with a request that already existed (the same key asked again, or the same keyless request another caller is
8
+ * waiting on). */
6
9
  export interface GateAuth {
7
10
  decision: AuthorizeVerdict;
8
11
  decisionId: string;
9
12
  receiptId?: string;
10
13
  reason?: string;
14
+ requestKey?: string;
15
+ idempotentReplay?: boolean;
16
+ openTwin?: boolean;
11
17
  }
12
18
  /** Fail-closed verdict allow-list. Called INSIDE gateAuthorize, so every adapter that authorizes
13
19
  * through the shared gate is protected by construction rather than by remembering — an adapter
@@ -57,10 +63,21 @@ export declare function gateAuthorizeTwinAware(config: GuardConfig, ctx: Approva
57
63
  request?: CommittedRequest;
58
64
  justification?: string;
59
65
  }): Promise<GateAuth | GateDuplicate>;
66
+ /** `naturalId` (0.12.0, 11f): the engine's own name for THIS call - an OpenAI tool call id, a Temporal workflow run and
67
+ * activity, an Inngest run - so the engine retrying the same call is the same request. The request key is keyed over it
68
+ * and the commitment. Without one (and without `idempotencyKey`) the key is over a fresh id for this call, as on the core
69
+ * path. No key material: no commitment, so no key - as before. */
60
70
  export declare function gateAuthorize(config: GuardConfig, ctx: ApprovalContext, opts?: {
61
71
  idempotencyKey?: string;
62
72
  request?: CommittedRequest;
73
+ naturalId?: Record<string, JsonValue>;
63
74
  }): Promise<GateAuth>;
75
+ /** COLLECT A PERSON'S APPROVAL ONCE on an engine door (0.12.0, call 24) - claimApproval, as a plain-JSON value an engine
76
+ * that must run HTTP in an activity (Temporal) returns unchanged. */
77
+ export declare function gateClaim(config: GuardConfig, decisionId: string, claimKey: string): Promise<ClaimOutcome>;
78
+ /** Validate a claim outcome that crossed a serialization boundary (a Temporal activity result). Anything that is not a
79
+ * well-formed outcome for THIS decision is refused - "an answer you do not understand is never permission". */
80
+ export declare function assertClaimShape(raw: unknown, decisionId: string): ClaimOutcome;
64
81
  /** Report an adapter-path execution and RETURN what Decidio recorded.
65
82
  *
66
83
  * Two 0.2.0 changes, both closing the same gap. It applies the caller's `evidence` mapper — the
@@ -1,5 +1,6 @@
1
1
  import { createRequire } from "node:module";
2
- import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, carriesCredential, canonicalize, assertDescribed, readConfirmResponse, buildConfirmBody, decisionIdProblem, bindingMaterial, requestCommitment, checkApproval } from "../index.js";
2
+ import { randomUUID } from "node:crypto";
3
+ import { DecidioBlockedError, DecidioRejectedError, signBoundProof, signConfirm, assertSafeTransport, carriesCredential, canonicalize, assertDescribed, readConfirmResponse, buildConfirmBody, decisionIdProblem, bindingMaterial, requestCommitment, idempotencyKey, invocationRequestKey, sendAuthorize, checkApproval, claimApproval } from "../index.js";
3
4
  export const AUTHORIZE_VERDICTS = ["proceed", "route", "block"];
4
5
  export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
5
6
  const a = auth;
@@ -45,48 +46,54 @@ export const isGateDuplicate = (a) => {
45
46
  return a?.decision === "duplicate" && !!d && typeof d === "object" && typeof d.decisionId === "string" && decisionIdProblem(d.decisionId) === null;
46
47
  };
47
48
  export async function gateAuthorizeTwinAware(config, ctx, opts = {}) {
48
- const a = await postAuthorize(config, ctx, { ...opts, twinCheck: true });
49
+ const { answer: a } = await postAuthorize(config, ctx, { ...opts, twinCheck: true });
49
50
  if (isGateDuplicate(a))
50
51
  return a;
51
52
  return assertAuthorizeVerdict(a, "unknown");
52
53
  }
53
54
  export async function gateAuthorize(config, ctx, opts = {}) {
54
- return assertAuthorizeVerdict(await postAuthorize(config, ctx, opts), "unknown");
55
+ const { answer, requestKey } = await postAuthorize(config, ctx, opts);
56
+ const auth = assertAuthorizeVerdict(answer, "unknown");
57
+ return requestKey ? { ...auth, requestKey } : auth;
55
58
  }
56
59
  async function postAuthorize(config, ctx, opts = {}) {
57
60
  ctx = canonicalize(ctx, "describe(...)");
58
61
  assertDescribed(ctx);
59
62
  assertSafeTransport(config.apiUrl, carriesCredential(config));
60
- const f = config.fetchImpl ?? fetch;
61
63
  const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
62
- const requesterIdentity = (config.agentKey && config.agentDid)
64
+ const sign = () => (config.agentKey && config.agentDid)
63
65
  ? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
64
66
  : undefined;
67
+ const requesterIdentity = sign();
65
68
  const material = bindingMaterial(config);
66
69
  const commitment = material ? requestCommitment(material, opts.request ?? adapterRequest(ctx)) : undefined;
67
- const res = await f(config.apiUrl.replace(/\/$/, "") + "/agent/authorize", {
68
- method: "POST",
69
- headers: { "content-type": "application/json", ...(config.apiToken ? { authorization: `Bearer ${config.apiToken}` } : {}) },
70
- body: JSON.stringify({
71
- requester: config.agentId, action: ctx.action, amount: ctx.amount, scope: ctx.scope,
72
- sourceSystem: config.sourceSystem, ...(ctx.context ? { context: ctx.context } : {}),
73
- ...(requesterIdentity ? { requesterIdentity } : {}), ...(resumeUrl ? { resumeUrl } : {}),
74
- ...(commitment ? { requestCommitment: commitment } : {}),
75
- ...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}),
76
- ...(opts.twinCheck ? { twinCheck: true } : {}),
77
- ...(opts.justification ? { justification: opts.justification } : {}),
78
- }),
79
- });
80
- if (!res.ok) {
81
- const raw = await res.text().catch(() => "");
82
- let parsed = raw;
83
- try {
84
- parsed = raw ? JSON.parse(raw) : undefined;
85
- }
86
- catch { }
87
- throw new DecidioHttpError(res.status, "/agent/authorize", parsed);
70
+ const requestKey = opts.idempotencyKey
71
+ ?? (material && commitment
72
+ ? (opts.naturalId ? idempotencyKey(material, { ...opts.naturalId, commitment }) : invocationRequestKey(material, config.invocationId ? String(config.invocationId()) : randomUUID(), commitment))
73
+ : undefined);
74
+ const answer = await sendAuthorize(config, {
75
+ requester: config.agentId, action: ctx.action, amount: ctx.amount, scope: ctx.scope,
76
+ sourceSystem: config.sourceSystem, ...(ctx.context ? { context: ctx.context } : {}),
77
+ ...(requesterIdentity ? { requesterIdentity } : {}), ...(resumeUrl ? { resumeUrl } : {}),
78
+ ...(commitment ? { requestCommitment: commitment } : {}),
79
+ ...(requestKey ? { idempotencyKey: requestKey } : {}),
80
+ ...(opts.twinCheck ? { twinCheck: true } : {}),
81
+ ...(opts.justification ? { justification: opts.justification } : {}),
82
+ }, { rateLimit: "http", resign: sign });
83
+ return { answer, ...(requestKey ? { requestKey } : {}) };
84
+ }
85
+ export function gateClaim(config, decisionId, claimKey) {
86
+ return claimApproval(config, decisionId, claimKey);
87
+ }
88
+ export function assertClaimShape(raw, decisionId) {
89
+ const c = raw;
90
+ if (c && typeof c === "object" && c.decisionId === decisionId) {
91
+ if (c.ok === true && typeof c.again === "boolean")
92
+ return { ok: true, decisionId, again: c.again };
93
+ if (c.ok === false && typeof c.code === "string" && typeof c.reason === "string" && typeof c.final === "boolean")
94
+ return c;
88
95
  }
89
- return res.json();
96
+ return { ok: false, decisionId, code: "unrecognized", final: false, reason: `the claim for decision ${decisionId} came back malformed (${JSON.stringify(raw ?? null).slice(0, 120)}) - return claimApprovalActivity(...) from your activity unchanged. Nothing ran.` };
90
97
  }
91
98
  export async function gateConfirm(config, decisionId, result, evidence) {
92
99
  const f = config.fetchImpl ?? fetch;
@@ -58,7 +58,7 @@ export async function decidioGate(config, ctx, run, opts) {
58
58
  }
59
59
  const result = await run();
60
60
  const confirmation = await gateConfirm(config, auth.decisionId, result, opts?.evidence);
61
- return { value: result, decisionId: auth.decisionId, confirmation };
61
+ return { value: result, decisionId: auth.decisionId, confirmation, ...(typeof auth.requestKey === "string" ? { requestKey: auth.requestKey } : {}) };
62
62
  }
63
63
  export function decidioResumeCommand(body) {
64
64
  const decisionId = body?.decisionId;
@@ -34,9 +34,10 @@ interface RunStateLike {
34
34
  * the call itself - its name, call id and exact argument string (toolCallRequest). An item edited after
35
35
  * approval, or a second identical call (another call id), never rides the approval.
36
36
  *
37
- * KNOWN LIMIT (predates 0.7.0): this door has no single-execution claim. The RunState is the host's own
38
- * storage, so two concurrent resumes of the same parked state can both apply the approval and both run
39
- * the tool; serialize resumes per run on your side (one worker per run id, or a lock).
37
+ * ONE APPROVAL, ONE RUN (0.12.0, founder call 24): applyVerifiedResume COLLECTS a person's approval before it
38
+ * approves the item (POST /agent/claim, a key random per call), so two concurrent resumes of the same parked state
39
+ * cannot both run the tool - the second is told the approval was already used and the RunState is not modified.
40
+ * A request key rides with every authorize, keyed on the tool call id: the same call asked again is the same request.
40
41
  *
41
42
  * Duck-typed on the state/item (no hard @openai/agents import) so it stays thin.
42
43
  *
@@ -82,10 +83,16 @@ export declare const gateInterruptions: typeof authorizeInterruptions;
82
83
  * state.reject(item), returns that verdict. Still pending, or approved for a DIFFERENT request, or not
83
84
  * tied to one → throws the typed refusal and the RunState is NOT modified.
84
85
  *
86
+ * A PERSON'S approval is COLLECTED ONCE before the item is approved (0.12.0, call 24): with `opts.claimKey` (yours to
87
+ * keep for a retry of THIS resume) or a fresh random one. Another run already used it, or it expired, or the claim could
88
+ * not be made → DecidioBlockedError, saying so, and the RunState is NOT modified. An auto-approval is never claimed.
89
+ *
85
90
  * REPLACES `applyResume(state, item, verdict)`, REMOVED in 0.7.0. It applied a bare verdict string
86
91
  * with no decision attached, so correctness depended entirely on the host routing the right verdict
87
92
  * to the right item. It was removed rather than deprecated: a deprecated function that still approves
88
93
  * keeps the hole open for every integration that never reads the warning, and one that refused
89
94
  * approvals at runtime would fail AFTER a human approved - the removal fails at build time instead. */
90
- export declare function applyVerifiedResume(config: GuardConfig, state: RunStateLike, item: ToolApprovalItem, decisionId: string, describe: (item: ToolApprovalItem) => ApprovalContext): Promise<"approved" | "rejected" | "blocked">;
95
+ export declare function applyVerifiedResume(config: GuardConfig, state: RunStateLike, item: ToolApprovalItem, decisionId: string, describe: (item: ToolApprovalItem) => ApprovalContext, opts?: {
96
+ claimKey?: string;
97
+ }): Promise<"approved" | "rejected" | "blocked">;
91
98
  export {};
@@ -1,11 +1,11 @@
1
- import { gateAuthorize, gateCheckApproval, toolCallRequest, checkEngineVersion } from "./_gate.js";
2
- import { DecidioBlockedError, approvalRefusal, decisionIdProblem } from "../index.js";
1
+ import { gateAuthorize, gateCheckApproval, gateClaim, toolCallRequest, checkEngineVersion } from "./_gate.js";
2
+ import { DecidioBlockedError, approvalRefusal, claimRefusal, decisionIdProblem, newClaimKey } from "../index.js";
3
3
  export async function authorizeInterruptions(config, state, describe) {
4
4
  const resolved = [];
5
5
  const pending = [];
6
6
  const batch = state.getInterruptions().map((item) => { const ctx = describe(item); return { item, ctx, request: toolCallRequest(item, ctx) }; });
7
7
  for (const { item, ctx, request } of batch) {
8
- const auth = await gateAuthorize(config, ctx, { request });
8
+ const auth = await gateAuthorize(config, ctx, { request, naturalId: { engine: "openai-agents", callId: request.args[0].callId } });
9
9
  if (auth.decision === "proceed") {
10
10
  state.approve(item);
11
11
  resolved.push({ item, verdict: "approved", decisionId: auth.decisionId });
@@ -22,13 +22,21 @@ export async function authorizeInterruptions(config, state, describe) {
22
22
  return { resolved, pending, confirmationSupported: false };
23
23
  }
24
24
  export const gateInterruptions = authorizeInterruptions;
25
- export async function applyVerifiedResume(config, state, item, decisionId, describe) {
25
+ export async function applyVerifiedResume(config, state, item, decisionId, describe, opts = {}) {
26
26
  const idProblem = decisionIdProblem(decisionId);
27
27
  if (idProblem)
28
28
  throw new DecidioBlockedError(`applyVerifiedResume needs the decision this item was routed as (${idProblem}) - pass the decisionId authorizeInterruptions returned for it. The RunState was NOT modified.`, typeof decisionId === "string" ? decisionId : "");
29
29
  const ctx = describe(item);
30
30
  const check = await gateCheckApproval(config, decisionId, ctx, toolCallRequest(item, ctx));
31
31
  if (check.ok) {
32
+ if (check.state === "approved") {
33
+ const claimed = await gateClaim(config, decisionId, opts.claimKey ?? newClaimKey());
34
+ if (!claimed.ok) {
35
+ const refusal = claimRefusal(claimed);
36
+ refusal.message += " The RunState was NOT modified.";
37
+ throw refusal;
38
+ }
39
+ }
32
40
  state.approve(item);
33
41
  return "approved";
34
42
  }
@@ -1,4 +1,4 @@
1
- import { type GuardConfig, type ApprovalContext, type ProtectedExecution, type EvidenceOutcome, type ApprovalCheck } from "../index.js";
1
+ import { type GuardConfig, type ApprovalContext, type ProtectedExecution, type EvidenceOutcome, type ApprovalCheck, type ClaimOutcome } from "../index.js";
2
2
  import { type GateAuth } from "./_gate.js";
3
3
  /**
4
4
  * @decidio/sdk/temporal — durable suspend via Temporal's native `condition()` + a
@@ -8,8 +8,9 @@ import { type GateAuth } from "./_gate.js";
8
8
  * Inside your workflow:
9
9
  * const verdicts = installDecidioSignal(); // wires the resume signal
10
10
  * const out = await decidioGate(config, ctx, () => createOpp(o), verdicts, {
11
- * authorize: () => executeActivity(authorizeActivity, ...), // HTTP belongs in activities
11
+ * authorize: () => executeActivity(authorizeActivity, ...), // HTTP belongs in activities; body: return authorizeActivity(config, ctx)
12
12
  * verify: (id) => executeActivity(verifyActivity, id), // body: return verifyApprovalActivity(config, id, ctx)
13
+ * claim: (id, key) => executeActivity(claimActivity, id, key), // body: return claimApprovalActivity(config, id, key)
13
14
  * confirm: (id, r) => executeActivity(confirmActivity, ...),
14
15
  * });
15
16
  * Decidio's webhook bridges to: handle.signal(decidioResumeSignal, decisionId, verdict, reason)
@@ -21,6 +22,13 @@ import { type GateAuth } from "./_gate.js";
21
22
  * → the typed refusal. `verify` is HTTP, so it must be an ACTIVITY, never workflow code (determinism) -
22
23
  * and it is required whenever `authorize` is.
23
24
  *
25
+ * ONE APPROVAL, ONE RUN (0.12.0, founder call 24): once Decidio confirms a PERSON'S approval, the workflow collects
26
+ * it (`claim`, an activity) with a key made from the workflow's own deterministic uuid4 - the same key on a replay or an
27
+ * activity retry, a different one in any other run - and runs only if Decidio says this run collected it. Another run
28
+ * already used it, or it expired → DecidioBlockedError, and the action never runs. `claim` is required whenever
29
+ * `authorize` is. A request key rides with every authorize: `authorizeActivity` keys it on the activity's own workflow,
30
+ * run and activity ids, so Temporal retrying the activity is the same request.
31
+ *
24
32
  * (This wiring doc ships in the .d.ts — the README points here.)
25
33
  */
26
34
  export declare const decidioResumeSignal: import("@temporalio/workflow").SignalDefinition<[string, string, (string | undefined)?]>;
@@ -35,6 +43,20 @@ export declare function installDecidioSignal(): Map<string, {
35
43
  * and route `verify` through it. GET /agent/status + the request commitment recomputed over `ctx`, which
36
44
  * must be the same context the gate authorized. Never call it from workflow code (it is HTTP). */
37
45
  export declare function verifyApprovalActivity(config: GuardConfig, decisionId: string, ctx: ApprovalContext): Promise<ApprovalCheck>;
46
+ /** The ids that name ONE activity execution across its retries: the workflow, its run, and the activity. */
47
+ export interface TemporalActivityIds {
48
+ workflowId: string;
49
+ runId: string;
50
+ activityId: string;
51
+ }
52
+ /** The authorize ACTIVITY body (0.12.0) - register an activity that returns this, unchanged, and route `authorize`
53
+ * through it. The request key is keyed on Temporal's own ids for this activity (11f): passed as `ids`, or read from the
54
+ * running activity's context - so Temporal retrying the activity after a timeout is the SAME request while it waits.
55
+ * Outside an activity, with no `ids`, the key is over a fresh id for the call. */
56
+ export declare function authorizeActivity(config: GuardConfig, ctx: ApprovalContext, ids?: TemporalActivityIds): Promise<GateAuth>;
57
+ /** The claim as an ACTIVITY body (0.12.0, call 24) - register an activity that returns this, unchanged, and route `claim`
58
+ * through it: POST /agent/claim with the key the workflow made. Never call it from workflow code (it is HTTP). */
59
+ export declare function claimApprovalActivity(config: GuardConfig, decisionId: string, claimKey: string): Promise<ClaimOutcome>;
38
60
  export interface DecidioGateOpts<R = unknown> {
39
61
  authorize?: () => Promise<GateAuth>;
40
62
  /** THE APPROVAL CHECK, run after the resume signal and before `run()` (0.7.0). It is HTTP, so route it
@@ -42,6 +64,11 @@ export interface DecidioGateOpts<R = unknown> {
42
64
  * `ctx` this gate authorized - and return that value unchanged. Required whenever `authorize` is
43
65
  * supplied; without either it defaults to a direct call (tests only - a workflow must not do I/O). */
44
66
  verify?: (decisionId: string) => Promise<ApprovalCheck>;
67
+ /** THE CLAIM (0.12.0, call 24), run after the approval check and before `run()` when a PERSON approved: collect the
68
+ * approval for this run, once. HTTP, so route it through an ACTIVITY whose body is
69
+ * `return claimApprovalActivity(config, decisionId, claimKey)` - return that value unchanged. Required whenever
70
+ * `authorize` is supplied; without either it defaults to a direct call (tests only). */
71
+ claim?: (decisionId: string, claimKey: string) => Promise<ClaimOutcome>;
45
72
  /** Reports through YOUR activity. Return the gate's confirm response body (validated with
46
73
  * `readConfirmResponse`) and it becomes the outcome; return nothing and the outcome says the SDK could
47
74
  * not read back what Decidio recorded (prodsec V035-03: a returned outcome used to be discarded). */
@@ -1,6 +1,7 @@
1
- import { condition, defineSignal, setHandler } from "@temporalio/workflow";
2
- import { DecidioBlockedError, approvalRefusal } from "../index.js";
3
- import { gateAuthorize, gateConfirm, gateCheckApproval, assertApprovalCheckShape, checkEngineVersion, assertAuthorizeVerdict } from "./_gate.js";
1
+ import { condition, defineSignal, setHandler, uuid4 } from "@temporalio/workflow";
2
+ import { createRequire } from "node:module";
3
+ import { DecidioBlockedError, approvalRefusal, claimRefusal, newClaimKey } from "../index.js";
4
+ import { gateAuthorize, gateConfirm, gateCheckApproval, gateClaim, assertApprovalCheckShape, assertClaimShape, checkEngineVersion, assertAuthorizeVerdict } from "./_gate.js";
4
5
  export const decidioResumeSignal = defineSignal("decidioResume");
5
6
  export function installDecidioSignal() {
6
7
  const verdicts = new Map();
@@ -10,12 +11,40 @@ export function installDecidioSignal() {
10
11
  export function verifyApprovalActivity(config, decisionId, ctx) {
11
12
  return gateCheckApproval(config, decisionId, ctx);
12
13
  }
14
+ function currentActivityIds() {
15
+ try {
16
+ const info = createRequire(import.meta.url)("@temporalio/activity").Context.current().info;
17
+ const ids = { workflowId: info?.workflowExecution?.workflowId, runId: info?.workflowExecution?.runId, activityId: info?.activityId };
18
+ return typeof ids.workflowId === "string" && typeof ids.runId === "string" && typeof ids.activityId === "string" ? ids : undefined;
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ }
24
+ export function authorizeActivity(config, ctx, ids) {
25
+ const natural = ids ?? currentActivityIds();
26
+ return gateAuthorize(config, ctx, natural ? { naturalId: { engine: "temporal", workflowId: natural.workflowId, runId: natural.runId, activityId: natural.activityId } } : {});
27
+ }
28
+ export function claimApprovalActivity(config, decisionId, claimKey) {
29
+ return gateClaim(config, decisionId, claimKey);
30
+ }
31
+ function workflowClaimKey() {
32
+ try {
33
+ return `run_${uuid4()}`;
34
+ }
35
+ catch {
36
+ return newClaimKey();
37
+ }
38
+ }
13
39
  let confirmWarned = false;
14
40
  export async function decidioGate(config, ctx, run, verdicts, opts = {}) {
15
41
  if (opts.authorize && !opts.verify) {
16
42
  throw new Error("[decidio] temporal: `authorize` runs in your activity, so the approval check must too - pass `verify: (decisionId) => executeActivity(verifyActivity, decisionId)`, where the activity returns verifyApprovalActivity(config, decisionId, ctx) (from @decidio/sdk/temporal) unchanged. Since 0.7.0 a resume signal only wakes the workflow; the approval is confirmed with Decidio before your action runs. Nothing was routed or executed.");
17
43
  }
18
- const auth = assertAuthorizeVerdict(await (opts.authorize ?? (() => gateAuthorize(config, ctx)))(), "unknown");
44
+ if (opts.authorize && !opts.claim) {
45
+ throw new Error("[decidio] temporal: `authorize` runs in your activity, so the claim must too - pass `claim: (decisionId, claimKey) => executeActivity(claimActivity, decisionId, claimKey)`, where the activity returns claimApprovalActivity(config, decisionId, claimKey) (from @decidio/sdk/temporal) unchanged. Since 0.12.0 a person's approval is collected once, by the run that acts on it, before your action runs. Nothing was routed or executed.");
46
+ }
47
+ const auth = assertAuthorizeVerdict(await (opts.authorize ?? (() => authorizeActivity(config, ctx)))(), "unknown");
19
48
  if (auth.decision === "block")
20
49
  throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
21
50
  if (auth.decision === "route") {
@@ -24,8 +53,15 @@ export async function decidioGate(config, ctx, run, verdicts, opts = {}) {
24
53
  await condition(() => verdicts.has(auth.decisionId));
25
54
  verdicts.delete(auth.decisionId);
26
55
  const check = assertApprovalCheckShape(await verify(auth.decisionId), auth.decisionId);
27
- if (check.ok)
56
+ if (check.ok) {
57
+ if (check.state === "approved") {
58
+ const claim = opts.claim ?? ((decisionId, claimKey) => gateClaim(config, decisionId, claimKey));
59
+ const claimed = assertClaimShape(await claim(auth.decisionId, workflowClaimKey()), auth.decisionId);
60
+ if (!claimed.ok)
61
+ throw claimRefusal(claimed);
62
+ }
28
63
  break;
64
+ }
29
65
  if (check.code !== "pending")
30
66
  throw approvalRefusal(check);
31
67
  }
@@ -52,6 +88,6 @@ export async function decidioGate(config, ctx, run, verdicts, opts = {}) {
52
88
  else {
53
89
  confirmation = await gateConfirm(config, auth.decisionId, result, opts?.evidence);
54
90
  }
55
- return { value: result, decisionId: auth.decisionId, confirmation };
91
+ return { value: result, decisionId: auth.decisionId, confirmation, ...(typeof auth.requestKey === "string" ? { requestKey: auth.requestKey } : {}) };
56
92
  }
57
93
  checkEngineVersion("temporal");
package/dist/cli.d.ts CHANGED
@@ -18,6 +18,8 @@ export declare function defaultAgentId(dir?: string): string;
18
18
  * distinct decision ids can never share a file (first-run review of 0.4.1: dots were STRIPPED, `a.b` and `ab`
19
19
  * collided). `~` is outside the allowed set, so the escape is unambiguous. Exported for the smokes. */
20
20
  export declare function receiptFileName(id: string): string;
21
+ /** Exactly what the person types to pause or resume the guard - nothing else is read. PURE. */
22
+ export declare function guardWords(prompt: unknown): "pause" | "start" | null;
21
23
  /** Scoped packs (GitHub App design §2): a pack's scope in words - the organisation's, a unit's (its type, and its name when
22
24
  * the server sent one: names that fail its person check are withheld there), or the reader's own. Null for an older server. */
23
25
  export declare function packScopeLabel(scope: any): string | null;