@decionis/agent-safe-pipeline 0.1.3 → 0.1.4

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.
Files changed (68) hide show
  1. package/README.md +318 -16
  2. package/dist/Index.d.ts +1 -0
  3. package/dist/Index.d.ts.map +1 -1
  4. package/dist/Index.js +1 -0
  5. package/dist/Index.js.map +1 -1
  6. package/dist/approval/PresenceApprovalCoordinator.d.ts +25 -0
  7. package/dist/approval/PresenceApprovalCoordinator.d.ts.map +1 -1
  8. package/dist/approval/PresenceApprovalCoordinator.js +54 -6
  9. package/dist/approval/PresenceApprovalCoordinator.js.map +1 -1
  10. package/dist/audit/AuditRecorder.d.ts +122 -0
  11. package/dist/audit/AuditRecorder.d.ts.map +1 -0
  12. package/dist/audit/AuditRecorder.js +312 -0
  13. package/dist/audit/AuditRecorder.js.map +1 -0
  14. package/dist/decision/DecionisGate.d.ts +91 -2
  15. package/dist/decision/DecionisGate.d.ts.map +1 -1
  16. package/dist/decision/DecionisGate.js +793 -44
  17. package/dist/decision/DecionisGate.js.map +1 -1
  18. package/dist/decision/DecisionAuthority.d.ts +50 -1
  19. package/dist/decision/DecisionAuthority.d.ts.map +1 -1
  20. package/dist/decision/DecisionAuthority.js.map +1 -1
  21. package/dist/decision/FixtureDecisionAuthority.d.ts +27 -1
  22. package/dist/decision/FixtureDecisionAuthority.d.ts.map +1 -1
  23. package/dist/decision/FixtureDecisionAuthority.js +47 -0
  24. package/dist/decision/FixtureDecisionAuthority.js.map +1 -1
  25. package/dist/decision/ImmutableGateDecision.d.ts.map +1 -1
  26. package/dist/decision/ImmutableGateDecision.js +18 -0
  27. package/dist/decision/ImmutableGateDecision.js.map +1 -1
  28. package/dist/execution/ActionRegistry.d.ts +37 -0
  29. package/dist/execution/ActionRegistry.d.ts.map +1 -1
  30. package/dist/execution/ActionRegistry.js +75 -1
  31. package/dist/execution/ActionRegistry.js.map +1 -1
  32. package/dist/execution/AuthorizationVerifier.d.ts +29 -0
  33. package/dist/execution/AuthorizationVerifier.d.ts.map +1 -1
  34. package/dist/execution/AuthorizationVerifier.js +126 -25
  35. package/dist/execution/AuthorizationVerifier.js.map +1 -1
  36. package/dist/execution/SafeExecutor.d.ts +72 -7
  37. package/dist/execution/SafeExecutor.d.ts.map +1 -1
  38. package/dist/execution/SafeExecutor.js +253 -17
  39. package/dist/execution/SafeExecutor.js.map +1 -1
  40. package/dist/intent/CanonicalIntentHasher.d.ts +1 -1
  41. package/dist/intent/CanonicalIntentHasher.d.ts.map +1 -1
  42. package/dist/intent/CanonicalIntentHasher.js +6 -2
  43. package/dist/intent/CanonicalIntentHasher.js.map +1 -1
  44. package/dist/intent/ExecutionIntent.d.ts +14 -2
  45. package/dist/intent/ExecutionIntent.d.ts.map +1 -1
  46. package/dist/intent/ExecutionIntent.js +7 -0
  47. package/dist/intent/ExecutionIntent.js.map +1 -1
  48. package/dist/intent/IntentCapture.d.ts +5 -0
  49. package/dist/intent/IntentCapture.d.ts.map +1 -1
  50. package/dist/intent/IntentCapture.js +12 -1
  51. package/dist/intent/IntentCapture.js.map +1 -1
  52. package/dist/shadow/ShadowPipeline.d.ts +75 -5
  53. package/dist/shadow/ShadowPipeline.d.ts.map +1 -1
  54. package/dist/shadow/ShadowPipeline.js +157 -7
  55. package/dist/shadow/ShadowPipeline.js.map +1 -1
  56. package/dist/testing/Index.d.ts +12 -0
  57. package/dist/testing/Index.d.ts.map +1 -0
  58. package/dist/testing/Index.js +12 -0
  59. package/dist/testing/Index.js.map +1 -0
  60. package/dist/testing/LocalAuthority.d.ts +203 -0
  61. package/dist/testing/LocalAuthority.d.ts.map +1 -0
  62. package/dist/testing/LocalAuthority.js +799 -0
  63. package/dist/testing/LocalAuthority.js.map +1 -0
  64. package/dist/testing/LocalPresence.d.ts +210 -0
  65. package/dist/testing/LocalPresence.d.ts.map +1 -0
  66. package/dist/testing/LocalPresence.js +473 -0
  67. package/dist/testing/LocalPresence.js.map +1 -0
  68. package/package.json +40 -4
package/README.md CHANGED
@@ -1,28 +1,83 @@
1
1
  # `@decionis/agent-safe-pipeline`
2
2
 
3
- TypeScript reference implementation of Agent-Safe Pipeline: capture an immutable agent proposal, obtain an independent Decionis decision, coordinate Presence escalation, and execute only through an intent-bound single-use grant and sealed trusted handler registry.
3
+ [![npm version](https://img.shields.io/npm/v/@decionis/agent-safe-pipeline.svg)](https://www.npmjs.com/package/@decionis/agent-safe-pipeline)
4
+ [![Continuous integration](https://github.com/decionis/agent-safe-pipeline/actions/workflows/deploy.yml/badge.svg?branch=master)](https://github.com/decionis/agent-safe-pipeline/actions/workflows/deploy.yml)
5
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/decionis/agent-safe-pipeline/badge)](https://scorecard.dev/viewer/?uri=github.com/decionis/agent-safe-pipeline)
6
+ [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/14098/badge)](https://www.bestpractices.dev/projects/14098)
7
+ [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://github.com/decionis/agent-safe-pipeline/blob/master/LICENSE)
4
8
 
5
- ## Install
9
+ **Let agents propose. Let policy decide.**
10
+
11
+ `@decionis/agent-safe-pipeline` is the TypeScript reference implementation of the Execution
12
+ Authority architecture. An AI agent may reason, plan, and propose a consequential action. It cannot
13
+ authorize that action, hold the credentials that perform it, or choose which trusted code runs. The
14
+ exact proposal is captured as an immutable, short-lived intent, evaluated independently by Decionis,
15
+ escalated to a verified human when policy requires it, and executed only through a single-use grant
16
+ bound to that one intent. Every decision leaves a Decision Dossier, so the record of what was
17
+ authorized, under which policy, and on whose approval compounds over time.
18
+
19
+ ```text
20
+ Agent -> immutable intent -> Decionis -> ALLOW / ESCALATE / BLOCK -> SafeExecutor -> downstream API
21
+ |
22
+ +-> Presence -> verified human approval -> Decionis re-evaluation
23
+ |
24
+ +-> Decision Dossier -> compounding decision record
25
+ ```
26
+
27
+ The package is a library, not a hosted service. It supplies the boundary; Decionis supplies the
28
+ decision. Its safety claims hold only when the documented trust boundary is preserved: the agent
29
+ runtime never sees Decionis credentials, downstream credentials, or the handler registry.
30
+
31
+ ## Why an execution boundary
32
+
33
+ Model-level controls shape what an agent says. They cannot prove, after the fact, that a specific
34
+ side effect was authorized by a specific policy at the moment it happened. Prompt filtering,
35
+ fine-tuning, and evaluation all sit before the action; the gap is at execution. This package closes
36
+ it structurally:
37
+
38
+ - **The agent's input is only the proposal.** Action, target, and parameters. Tenant, actor,
39
+ downstream system, and credentials come from trusted server configuration and cannot be injected.
40
+ - **The decision is made elsewhere.** Decionis evaluates the canonical intent hash and returns
41
+ `ALLOW`, `ESCALATE`, or `BLOCK` with a decision identifier and a Decision Dossier identifier.
42
+ - **Approval is evidence, never authority.** Presence proves that a real person approved that exact
43
+ intent. Decionis verifies the receipt and re-evaluates current policy before any grant exists.
44
+ - **Execution consumes a grant, not a callback.** `SafeExecutor` accepts a captured intent and a
45
+ decision. It claims the grant atomically, then invokes a handler from a sealed registry.
46
+ - **Everything else fails closed.** A network error, a malformed response, a missing grant, an
47
+ expired intent, a replayed token, or a binding mismatch all result in no execution.
48
+
49
+ ## Requirements
6
50
 
7
- Install the latest stable release:
51
+ - Node.js 22.14 or later. The package is ESM-only and ships its own TypeScript declarations.
52
+ - A server-side process that holds the Decionis credentials. Never load this package into a
53
+ browser, an agent sandbox, or any runtime the model can influence.
54
+ - `zod` v4 for handler parameter schemas (installed as a dependency).
55
+
56
+ ## Install
8
57
 
9
58
  ```bash
10
59
  npm install @decionis/agent-safe-pipeline
11
60
  ```
12
61
 
13
- To evaluate this prerelease explicitly:
62
+ Stable releases publish under the `latest` tag with npm provenance. Prereleases publish under the
63
+ `next` tag and are never installed by default:
14
64
 
15
65
  ```bash
16
- npm install @decionis/agent-safe-pipeline@0.1.3-rc.2
66
+ npm install @decionis/agent-safe-pipeline@next
17
67
  ```
18
68
 
19
- Production credentials belong in the trusted executor process, never the agent runtime:
69
+ Decionis credentials belong only in the trusted executor process:
20
70
 
21
71
  ```text
22
72
  DECIONIS_API_URL=https://api.decionis.com
23
73
  DECIONIS_API_KEY=server-side-secret
24
74
  ```
25
75
 
76
+ ## Quick start: enforcement
77
+
78
+ The complete production path in one file. The proposal comes from the agent; everything else comes
79
+ from trusted configuration.
80
+
26
81
  ```ts
27
82
  import {
28
83
  ActionRegistry,
@@ -33,17 +88,37 @@ import {
33
88
  } from "@decionis/agent-safe-pipeline";
34
89
  import { z } from "zod";
35
90
 
91
+ // 1. Capture the exact proposal. The agent supplies only action, target, and parameters.
92
+ const captured = new IntentCapture().capture(
93
+ {
94
+ action: "refund_order",
95
+ target: "shopify:order:1001",
96
+ parameters: { orderId: "1001", amountMinor: 35_000 },
97
+ },
98
+ {
99
+ tenantId: config.tenantId,
100
+ actor: { id: "refund-agent", type: "AI_AGENT" },
101
+ downstreamTarget: { system: "shopify", operation: "refund", environment: "production" },
102
+ idempotencyKey: "refund-1001-v1",
103
+ },
104
+ );
105
+
106
+ // 2. Ask Decionis. The gate never returns an executable decision without a grant.
36
107
  const gate = new DecionisGate({
37
108
  baseUrl: process.env.DECIONIS_API_URL!,
38
109
  apiKey: process.env.DECIONIS_API_KEY!,
39
110
  });
40
- const captured = new IntentCapture().capture(agentProposal, trustedServerContext);
111
+ const decision = await gate.evaluate(captured);
112
+
113
+ // 3. Register trusted handlers once, then seal the registry so nothing can be added at runtime.
41
114
  const registry = new ActionRegistry()
42
115
  .register("refund_order", {
43
116
  parametersSchema: z.object({ orderId: z.string(), amountMinor: z.number().int() }).strict(),
44
117
  execute: ({ parameters }) => shopify.refund(parameters),
45
118
  })
46
119
  .seal();
120
+
121
+ // 4. Execute only through a claimed single-use grant bound to this intent.
47
122
  const executor = new SafeExecutor(
48
123
  registry,
49
124
  new DecionisGrantVerifier({
@@ -51,16 +126,243 @@ const executor = new SafeExecutor(
51
126
  apiKey: process.env.DECIONIS_API_KEY!,
52
127
  }),
53
128
  );
54
- const result = await executor.run(captured, await gate.evaluate(captured));
129
+ const result = await executor.run(captured, decision);
130
+
131
+ if (result.outcome === "COMPLETED") {
132
+ // result.result is the handler's return value.
133
+ // result.authorization carries the consumed { decisionId, dossierId, grantId, intentHash }.
134
+ }
135
+ ```
136
+
137
+ The executor validates parameters against the handler's schema before the grant is claimed, and the
138
+ grant is claimed before the handler runs. If either step fails, the handler is never invoked.
139
+
140
+ ## Outcomes
141
+
142
+ Decionis returns one of three verdicts. The executor turns a verdict into exactly one execution
143
+ outcome.
144
+
145
+ | Verdict | Executor behavior |
146
+ | ------------------------------------- | ----------------------------------------------------------------------- |
147
+ | `ALLOW` with a valid single-use grant | Claim the grant atomically, then invoke the registered handler |
148
+ | `ESCALATE` | Stop. Resolve a DIRECT or MANAGED Presence escalation, then re-evaluate |
149
+ | `BLOCK`, any error, any mismatch | Fail closed. The handler is never invoked |
150
+
151
+ `SafeExecutor.run` resolves to a discriminated result rather than throwing:
152
+
153
+ | `outcome` | Meaning |
154
+ | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
155
+ | `COMPLETED` | The grant was consumed and the handler returned. `result` holds its value. |
156
+ | `BLOCKED` | Nothing ran. `reason` names the refusal: a non-authoritative or non-`ALLOW` decision, a missing or invalid grant, an intent binding or conformance failure, or an unavailable audit sink. |
157
+ | `FAILED_BEFORE_DISPATCH` | The grant was consumed but the handler failed before reaching the provider. Safe to reason about. |
158
+ | `UNKNOWN_AFTER_DISPATCH` | The provider was called and its outcome is unknown. A `recovery` reference supports reconciliation. |
159
+
160
+ Every outcome that consumed a grant also reports `finalization` (`RECORDED`, `PENDING`, or
161
+ `UNSUPPORTED`). The executor records `COMMITTED`, `FAILED`, or `INDETERMINATE` with Decionis after
162
+ the attempt so commit evidence joins the Decision Dossier chain. Finalization is evidence, never
163
+ authority: it cannot change `outcome` or `executed`.
164
+
165
+ ## Human approval through Presence
166
+
167
+ When policy escalates, a person must approve that exact intent with independently signed evidence.
168
+ Presence delivers the request to an enrolled device and returns a receipt bound to the intent hash.
169
+ Presence never authorizes execution; Decionis verifies the receipt and re-evaluates policy. The
170
+ package supports two integration levels, and the executor's grant path is identical in both.
171
+
172
+ | Mode | Who coordinates Presence | Credentials in the executor | Entry point |
173
+ | --------- | ------------------------ | --------------------------- | ---------------------------------------------------- |
174
+ | `DIRECT` | Your trusted executor | Decionis and Presence | `PresenceApprovalCoordinator` |
175
+ | `MANAGED` | Decionis | Decionis only | `DecionisGate.evaluate` with an `escalation` request |
176
+
177
+ In MANAGED mode, pass routing and ceremony constraints outside the canonical intent, then poll
178
+ Decionis only:
179
+
180
+ ```ts
181
+ const pending = await gate.evaluate(captured, undefined, {
182
+ escalation: {
183
+ mode: "MANAGED",
184
+ approver: { principal_id: approverId, role_id: "APPROVER" },
185
+ verification_requirements: { methods: ["WEBAUTHN"], level: "HIGH_CONFIDENCE" },
186
+ },
187
+ });
188
+ // pending.verdict === "ESCALATE"; pending.managedEscalation is set; no grant exists yet.
189
+
190
+ const authorized = await gate.waitForAuthorization(captured, pending, { signal });
191
+ const result = await executor.run(captured, authorized);
192
+ ```
193
+
194
+ `waitForAuthorization` polls with capped exponential backoff and bounded jitter, and stops at the
195
+ intent or escalation expiry. It returns a normal `ALLOW` decision with a grant only after Decionis
196
+ has verified the Presence evidence and re-evaluated current policy. Approval cannot revive an intent
197
+ after it expires. Presence transport or schema failures and Decionis re-authorization failures
198
+ return stable fail-closed decisions; raw downstream error text never reaches the caller.
199
+
200
+ ## Shadow mode
201
+
202
+ Measure before you enforce. `ShadowPipeline` wraps an execution path you already run and records
203
+ what Decionis would have decided, without the ability to stop, delay, or alter it.
204
+
205
+ ```ts
206
+ import { DecionisGate, ShadowPipeline } from "@decionis/agent-safe-pipeline";
207
+
208
+ const shadow = new ShadowPipeline(new DecionisGate({ baseUrl, apiKey, mode: "SHADOW" }), {
209
+ timeoutMs: 2_000,
210
+ });
211
+
212
+ const run = await shadow.observe(captured, () => existingRefund(order));
213
+ // run.production is your unchanged result, available as soon as production settles.
214
+ const observation = await run.observation;
215
+ // The observation runs under its own timeout and never rejects; it reports the verdict
216
+ // Decionis would have returned, the dossier identifier, and whether a grant was discarded.
217
+ ```
218
+
219
+ A shadow observation carries no grant, is structurally distinct from a `GateDecision`, and is
220
+ rejected by `SafeExecutor` at runtime. See
221
+ [shadow mode](https://github.com/decionis/agent-safe-pipeline/blob/master/docs/shadow-mode.md).
222
+
223
+ ## Adoption path
224
+
225
+ The same `IntentCapture`, `ActionRegistry`, and handler code carry through every stage. Nothing is
226
+ rewritten between them.
227
+
228
+ | Stage | Authority | What it proves |
229
+ | ----------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------- |
230
+ | Development | `createFixtureAuthorityPair` (refuses `NODE_ENV=production`) | The intent, registry, and executor wiring is correct |
231
+ | Shadow | `ShadowPipeline` over `DecionisGate` with `mode: "SHADOW"` | What Decionis would have decided about actions that already run; no grant issued |
232
+ | Enforcement | `DecionisGate` plus `DecionisGrantVerifier` in `SafeExecutor` | Nothing runs without an independent decision and a consumed single-use grant |
233
+
234
+ ## Local testing
235
+
236
+ `@decionis/agent-safe-pipeline/testing` ships `LocalPresence` and `LocalAuthority`: loopback doubles
237
+ that the production clients talk to unchanged. They enforce the structural intent-hash binding,
238
+ verify receipts the way Decionis does, issue single-use grants, orchestrate managed escalations, and
239
+ record finalization. The person's ceremony becomes a method call.
240
+
241
+ ```ts
242
+ import { DecionisGate, DecionisGrantVerifier } from "@decionis/agent-safe-pipeline";
243
+ import {
244
+ LocalAuthority,
245
+ LocalPresence,
246
+ LOCAL_AUTHORITY_API_KEY,
247
+ } from "@decionis/agent-safe-pipeline/testing";
248
+
249
+ const presence = new LocalPresence({ autoComplete: "MANUAL", roles: { "synthetic-cro": "CRO" } });
250
+ const authority = new LocalAuthority({ presence });
251
+ await presence.start();
252
+ await authority.start();
253
+
254
+ const gate = new DecionisGate({
255
+ baseUrl: authority.baseUrl,
256
+ apiKey: LOCAL_AUTHORITY_API_KEY,
257
+ allowInsecureLoopback: true,
258
+ });
259
+ // ... evaluate, then complete the ceremony with presence.approve(requestId)
260
+ // and assert on grants, receipts, and recorded commits.
55
261
  ```
56
262
 
57
- | Outcome | Execution behavior |
58
- | --------------------------------- | ---------------------------------------------------- |
59
- | ALLOW plus valid single-use grant | Consume grant, then invoke registered handler |
60
- | ESCALATE | Stop; obtain Presence receipt and ask Decionis again |
61
- | BLOCK or any error/mismatch | Fail closed; do not invoke handler |
263
+ Both doubles bind to `127.0.0.1` on an ephemeral port and refuse to construct under
264
+ `NODE_ENV=production`. The testing entry also exports the development fixture primitives
265
+ (`createFixtureAuthorityPair`, `FixtureDecisionAuthority`, `FixtureAuthorizationVerifier`,
266
+ `InMemoryReplayStore`). Those remain available at the package root until 1.0; new code should import
267
+ them from the testing entry. See
268
+ [local testing](https://github.com/decionis/agent-safe-pipeline/blob/master/docs/local-testing.md).
269
+
270
+ ## API overview
271
+
272
+ | Concern | Exports | Role |
273
+ | -------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
274
+ | Intent | `IntentCapture`, `CanonicalIntentHasher`, `ExecutionIntentSchema`, `AgentProposalSchema` | Build the immutable `agent-safe.intent/1` binding and its canonical SHA-256 hash |
275
+ | Decision | `DecionisGate`, `DecisionAuthority`, `GateDecision`, `FailClosedDecision` | Obtain an independent `ALLOW` / `ESCALATE` / `BLOCK` decision with dossier identifiers |
276
+ | Human approval | `PresenceApprovalCoordinator`, `ManagedEscalationRequest`, `HumanApprovalEvidence` | Coordinate DIRECT Presence ceremonies or request MANAGED orchestration by Decionis |
277
+ | Execution | `SafeExecutor`, `ActionRegistry`, `DecionisGrantVerifier`, `AuthorizationVerifier`, `ReplayStore` | Claim the single-use grant, validate parameters, invoke a sealed handler, finalize the attempt |
278
+ | Observation | `ShadowPipeline`, `ShadowObservation` | Record what the authority would have decided without granting execution |
279
+ | Audit | `AuditRecorder`, `AuditEventV1`, `AuditSink` | Emit immutable, redacted lifecycle records through one bounded sink call |
280
+ | Testing | `LocalPresence`, `LocalAuthority`, `createFixtureAuthorityPair` (from `/testing`) | Loopback doubles and fixture authorities for development and CI |
281
+
282
+ The seam between this package and Decionis is two interfaces, `DecisionAuthority` and
283
+ `AuthorizationVerifier`, plus a published OpenAPI contract. Anyone can implement the interfaces; the
284
+ library checks no plan, key, or entitlement.
285
+
286
+ ## Production invariants
287
+
288
+ 1. Agent input contains only the proposed action, target, and parameters. Tenant, actor, downstream
289
+ target, and credentials come from trusted runtime configuration.
290
+ 2. The exact canonical intent is hashed and expires quickly.
291
+ 3. Decionis decides independently. Network errors, malformed responses, missing grants, and binding
292
+ mismatches fail closed.
293
+ 4. Presence proves a human approved that exact intent. It never authorizes execution; Decionis
294
+ verifies the receipt and re-evaluates policy.
295
+ 5. The grant is bound to the intent, decision, audience, and expiry, and is claimed atomically before
296
+ the handler runs. The attempt is finalized afterwards as evidence, never as authority.
297
+ 6. Downstream credentials exist only behind the trusted executor.
298
+ 7. Every decision is evidence-bearing. An `ALLOW` without a dossier identifier or grant is refused
299
+ as non-executable. A dossier identifier is never an execution credential.
300
+
301
+ Read the [trust boundary](https://github.com/decionis/agent-safe-pipeline/blob/master/docs/trust-boundary.md)
302
+ and the [threat model](https://github.com/decionis/agent-safe-pipeline/blob/master/THREAT-MODEL.md)
303
+ before integrating a real downstream API.
304
+
305
+ ## Assurance and supply chain
306
+
307
+ - **Provenance.** Every release is published through npm trusted publishing with a provenance
308
+ attestation, from a keyless-signed release tag, and archived under Zenodo concept DOI
309
+ [`10.5281/zenodo.22312955`](https://doi.org/10.5281/zenodo.22312955).
310
+ - **Release evidence.** Each GitHub release carries the tarball, a CycloneDX SBOM, Sigstore
311
+ provenance and SBOM attestations, and a checksum file. See
312
+ [reproducible builds](https://github.com/decionis/agent-safe-pipeline/blob/master/docs/reproducible-builds.md).
313
+ - **Testing.** Coverage gates of 90% lines, functions, and statements and 85% branches; mutation
314
+ testing on the trust boundary; deterministic property-based fuzzing of canonical intent handling;
315
+ a loopback wire-contract harness that exercises the packed package over real HTTP.
316
+ - **Adversarial proof.** The
317
+ [golden adversarial demo](https://github.com/decionis/agent-safe-pipeline/tree/master/examples/golden-adversarial-demo)
318
+ runs one legitimate path and eight attacks against the same boundary, offline, and exits 0 only
319
+ when exactly one action executes.
320
+ - **Scanning.** CodeQL, secret scanning, OpenSSF Scorecard, and OpenSSF Best Practices, with
321
+ separate production and toolchain dependency audits. The control-to-artifact map is in
322
+ [SECURITY-EVIDENCE.md](https://github.com/decionis/agent-safe-pipeline/blob/master/SECURITY-EVIDENCE.md).
323
+
324
+ ## Examples
325
+
326
+ Runnable, offline, and fixture-backed unless noted. Each one uses this package unchanged.
327
+
328
+ - [`basic-agent`](https://github.com/decionis/agent-safe-pipeline/tree/master/examples/basic-agent): the smallest `BLOCK` flow.
329
+ - [`shopify-refund-agent`](https://github.com/decionis/agent-safe-pipeline/tree/master/examples/shopify-refund-agent): amount-based `ALLOW` / `ESCALATE` / `BLOCK`.
330
+ - [`github-deploy-agent`](https://github.com/decionis/agent-safe-pipeline/tree/master/examples/github-deploy-agent): environment and force-push controls.
331
+ - [`procurement-agent`](https://github.com/decionis/agent-safe-pipeline/tree/master/examples/procurement-agent): an in-budget request held by policy.
332
+ - [`mcp-tool-gate`](https://github.com/decionis/agent-safe-pipeline/tree/master/examples/mcp-tool-gate): a real stdio MCP server with a governed tool.
333
+ - [`local-escalation`](https://github.com/decionis/agent-safe-pipeline/tree/master/examples/local-escalation): DIRECT and MANAGED Presence escalation against loopback doubles.
334
+ - [`presence-live-approval`](https://github.com/decionis/agent-safe-pipeline/tree/master/examples/presence-live-approval): DIRECT Presence enforcement against the real services with a FIDO2 or FIDO2-plus-liveness ceremony (needs credentials).
335
+ - [`presence-managed-approval`](https://github.com/decionis/agent-safe-pipeline/tree/master/examples/presence-managed-approval): Decionis-managed Presence orchestration against the real services (needs credentials).
336
+ - [`golden-adversarial-demo`](https://github.com/decionis/agent-safe-pipeline/tree/master/examples/golden-adversarial-demo): one golden path, eight attacks, zero unauthorized executions.
337
+
338
+ ## Open core
339
+
340
+ Everything in the repository is Apache-2.0: the architecture, the intent contract, the execution
341
+ boundary, the client adapters, the audit contract, shadow mode, the conformance vectors, and the
342
+ examples. Decionis operates the policy control plane behind `DecionisGate`: policy evaluation, grant
343
+ issuance and atomic consumption, Decision Dossier signing and retention, and Presence.
344
+ [OPEN-CORE.md](https://github.com/decionis/agent-safe-pipeline/blob/master/OPEN-CORE.md) states the
345
+ boundary and the commitments that keep it stable.
346
+
347
+ ## Research
348
+
349
+ Decionis Research defines the architecture, this package demonstrates it as tested code, and the
350
+ Decionis platform operates it as a hosted authority.
351
+
352
+ - Jejelowo, Festus. "The Execution Verifiability Gap: Why Model Governance Cannot Authorize
353
+ Consequential Actions." Decionis Research, version 1.0, 21 August 2026.
354
+ [Canonical article](https://decionis.com/research/execution-verifiability-gap) ·
355
+ [Archival PDF](https://decionis.com/research/execution-verifiability-gap-v1.0.pdf)
356
+
357
+ To cite the software, use the
358
+ [CITATION.cff](https://github.com/decionis/agent-safe-pipeline/blob/master/CITATION.cff) in the
359
+ repository or the Zenodo record above.
360
+
361
+ ## Support and license
62
362
 
63
- Presence transport/schema failures and Decionis reauthorization failures return stable fail-closed
64
- decisions; raw downstream error text is never part of the coordinator result.
363
+ - Vulnerabilities: [GitHub private vulnerability reporting](https://github.com/decionis/agent-safe-pipeline/security/advisories/new) or `security@decionis.com`. Never open a public issue for a security report.
364
+ - Everything else: [GitHub Issues](https://github.com/decionis/agent-safe-pipeline/issues).
365
+ - Architecture and full documentation: [Agent-Safe Pipeline README](https://github.com/decionis/agent-safe-pipeline#readme).
65
366
 
66
- Support: use [GitHub private vulnerability reporting](https://github.com/decionis/agent-safe-pipeline/security/advisories/new) or `security@decionis.com` for vulnerabilities and [GitHub Issues](https://github.com/decionis/agent-safe-pipeline/issues) for non-sensitive problems. Architecture: [Agent-Safe Pipeline README](https://github.com/decionis/agent-safe-pipeline#readme). License: Apache-2.0. Trademark terms: [TRADEMARKS.md](https://github.com/decionis/agent-safe-pipeline/blob/master/TRADEMARKS.md).
367
+ Apache-2.0. Trademark terms in
368
+ [TRADEMARKS.md](https://github.com/decionis/agent-safe-pipeline/blob/master/TRADEMARKS.md).
package/dist/Index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./approval/PresenceApprovalCoordinator.js";
2
+ export * from "./audit/AuditRecorder.js";
2
3
  export * from "./decision/DecisionAuthority.js";
3
4
  export * from "./decision/DecionisGate.js";
4
5
  export * from "./decision/FixtureDecisionAuthority.js";
@@ -1 +1 @@
1
- {"version":3,"file":"Index.d.ts","sourceRoot":"","sources":["../src/Index.ts"],"names":[],"mappings":"AAAA,cAAc,2CAA2C,CAAC;AAC1D,cAAc,iCAAiC,CAAC;AAChD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wCAAwC,CAAC;AACvD,cAAc,+BAA+B,CAAC;AAC9C,cAAc,sCAAsC,CAAC;AACrD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,mCAAmC,CAAC;AAClD,cAAc,6BAA6B,CAAC;AAC5C,cAAc,2BAA2B,CAAC;AAC1C,cAAc,uBAAuB,CAAC;AACtC,cAAc,4BAA4B,CAAC"}
1
+ {"version":3,"file":"Index.d.ts","sourceRoot":"","sources":["../src/Index.ts"],"names":[],"mappings":"AAAA,cAAc,2CAA2C,CAAC;AAC1D,cAAc,0BAA0B,CAAC;AACzC,cAAc,iCAAiC,CAAC;AAChD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wCAAwC,CAAC;AACvD,cAAc,+BAA+B,CAAC;AAC9C,cAAc,sCAAsC,CAAC;AACrD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,mCAAmC,CAAC;AAClD,cAAc,6BAA6B,CAAC;AAC5C,cAAc,2BAA2B,CAAC;AAC1C,cAAc,uBAAuB,CAAC;AACtC,cAAc,4BAA4B,CAAC"}
package/dist/Index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./approval/PresenceApprovalCoordinator.js";
2
+ export * from "./audit/AuditRecorder.js";
2
3
  export * from "./decision/DecisionAuthority.js";
3
4
  export * from "./decision/DecionisGate.js";
4
5
  export * from "./decision/FixtureDecisionAuthority.js";
package/dist/Index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"Index.js","sourceRoot":"","sources":["../src/Index.ts"],"names":[],"mappings":"AAAA,cAAc,2CAA2C,CAAC;AAC1D,cAAc,iCAAiC,CAAC;AAChD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wCAAwC,CAAC;AACvD,cAAc,+BAA+B,CAAC;AAC9C,cAAc,sCAAsC,CAAC;AACrD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,mCAAmC,CAAC;AAClD,cAAc,6BAA6B,CAAC;AAC5C,cAAc,2BAA2B,CAAC;AAC1C,cAAc,uBAAuB,CAAC;AACtC,cAAc,4BAA4B,CAAC"}
1
+ {"version":3,"file":"Index.js","sourceRoot":"","sources":["../src/Index.ts"],"names":[],"mappings":"AAAA,cAAc,2CAA2C,CAAC;AAC1D,cAAc,0BAA0B,CAAC;AACzC,cAAc,iCAAiC,CAAC;AAChD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wCAAwC,CAAC;AACvD,cAAc,+BAA+B,CAAC;AAC9C,cAAc,sCAAsC,CAAC;AACrD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,mCAAmC,CAAC;AAClD,cAAc,6BAA6B,CAAC;AAC5C,cAAc,2BAA2B,CAAC;AAC1C,cAAc,uBAAuB,CAAC;AACtC,cAAc,4BAA4B,CAAC"}
@@ -1,9 +1,22 @@
1
1
  import type { GateResult, HumanApprovalGate } from "@decionis/presence-node";
2
+ import type { AuditRecorder } from "../audit/AuditRecorder.js";
2
3
  import type { DecisionAuthority, GateDecision } from "../decision/DecisionAuthority.js";
3
4
  import type { CapturedIntent } from "../intent/ExecutionIntent.js";
4
5
  export type PresenceGateResult = GateResult;
5
6
  export type PresenceApprovalClient = Pick<HumanApprovalGate, "gate" | "outcome">;
7
+ /** Verification requirements Presence enforces for the approval ceremony (docs/22). */
8
+ export type PresenceVerificationRequirements = NonNullable<Parameters<HumanApprovalGate["gate"]>[0]["requirements"]>;
6
9
  export interface PresenceApprovalCoordinatorOptions {
10
+ /** Optional bounded lifecycle audit recorder. */
11
+ readonly audit?: AuditRecorder;
12
+ /**
13
+ * Ceremony Presence must complete before it seals a receipt, such as WebAuthn
14
+ * alone or WebAuthn with active liveness. Omitted, Presence applies its
15
+ * default standard-confidence device proof.
16
+ */
17
+ readonly requirements?: PresenceVerificationRequirements;
18
+ /** Approval-request lifetime in seconds (30-600); omitted, Presence applies its default. */
19
+ readonly ttlSeconds?: number;
7
20
  /** Maximum number of outcome lookups after Presence returns HUMAN_REQUIRED. */
8
21
  readonly maxAttempts?: number;
9
22
  /** Initial exponential-backoff delay in milliseconds. */
@@ -34,6 +47,9 @@ export declare class PresenceApprovalCoordinator {
34
47
  private readonly organization;
35
48
  private readonly approverId;
36
49
  private readonly polling;
50
+ private readonly audit;
51
+ private readonly requirements;
52
+ private readonly ttlSeconds;
37
53
  constructor(presence: PresenceApprovalClient, authority: DecisionAuthority, organization: string, approverId: string, options?: PresenceApprovalCoordinatorOptions);
38
54
  request(captured: CapturedIntent): Promise<PresenceGateResult>;
39
55
  resolveAndReauthorize(captured: CapturedIntent, result: PresenceGateResult, options?: PresenceResolutionOptions): Promise<GateDecision>;
@@ -49,7 +65,16 @@ export declare class PresenceApprovalCoordinator {
49
65
  private requestIdOf;
50
66
  private evidenceOf;
51
67
  private boundedIdentifier;
68
+ private recordPresence;
52
69
  private isRecord;
70
+ /**
71
+ * Presence replays a sealed answer for a repeated idempotency key, so the key
72
+ * must identify exactly one captured intent for one approver: a re-captured
73
+ * intent has a new hash and must get a new request. The digest keeps the key
74
+ * inside Presence's `[A-Za-z0-9._:-]{8,128}` alphabet whatever the runtime's
75
+ * business key or the approver identity contain.
76
+ */
77
+ static presenceIdempotencyKey(captured: CapturedIntent, approverId: string): string;
53
78
  private static pollingConfiguration;
54
79
  private static sleep;
55
80
  }
@@ -1 +1 @@
1
- {"version":3,"file":"PresenceApprovalCoordinator.d.ts","sourceRoot":"","sources":["../../src/approval/PresenceApprovalCoordinator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAC7E,OAAO,KAAK,EACV,iBAAiB,EACjB,YAAY,EAEb,MAAM,kCAAkC,CAAC;AAE1C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAEnE,MAAM,MAAM,kBAAkB,GAAG,UAAU,CAAC;AAC5C,MAAM,MAAM,sBAAsB,GAAG,IAAI,CAAC,iBAAiB,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;AAEjF,MAAM,WAAW,kCAAkC;IACjD,+EAA+E;IAC/E,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,yDAAyD;IACzD,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC,yDAAyD;IACzD,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,iFAAiF;IACjF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,kEAAkE;IAClE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,MAAM,CAAC;IAC9B,8DAA8D;IAC9D,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1E,+DAA+D;IAC/D,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,MAAM,CAAC;CAChC;AAED,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;CAC/B;AA4BD;;;;;GAKG;AACH,qBAAa,2BAA2B;IAIpC,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,YAAY;IAC7B,OAAO,CAAC,QAAQ,CAAC,UAAU;IAN7B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAuB;gBAG5B,QAAQ,EAAE,sBAAsB,EAChC,SAAS,EAAE,iBAAiB,EAC5B,YAAY,EAAE,MAAM,EACpB,UAAU,EAAE,MAAM,EACnC,OAAO,GAAE,kCAAuC;IAKrC,OAAO,CAAC,QAAQ,EAAE,cAAc,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAwC9D,qBAAqB,CAChC,QAAQ,EAAE,cAAc,EACxB,MAAM,EAAE,kBAAkB,EAC1B,OAAO,GAAE,yBAA8B,GACtC,OAAO,CAAC,YAAY,CAAC;YAyCV,cAAc;YA2Fd,aAAa;YAiBb,iBAAiB;YAqBjB,gBAAgB;IAgC9B,OAAO,CAAC,sBAAsB;IAY9B,OAAO,CAAC,eAAe;IAQvB,OAAO,CAAC,YAAY;IAcpB,OAAO,CAAC,QAAQ;IAShB,OAAO,CAAC,SAAS;IAWjB,OAAO,CAAC,WAAW;IAMnB,OAAO,CAAC,UAAU;IASlB,OAAO,CAAC,iBAAiB;IAIzB,OAAO,CAAC,QAAQ;IAIhB,OAAO,CAAC,MAAM,CAAC,oBAAoB;mBAuCd,KAAK;CAkB3B"}
1
+ {"version":3,"file":"PresenceApprovalCoordinator.d.ts","sourceRoot":"","sources":["../../src/approval/PresenceApprovalCoordinator.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAC7E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAC/D,OAAO,KAAK,EACV,iBAAiB,EACjB,YAAY,EAEb,MAAM,kCAAkC,CAAC;AAE1C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAEnE,MAAM,MAAM,kBAAkB,GAAG,UAAU,CAAC;AAC5C,MAAM,MAAM,sBAAsB,GAAG,IAAI,CAAC,iBAAiB,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;AACjF,uFAAuF;AACvF,MAAM,MAAM,gCAAgC,GAAG,WAAW,CACxD,UAAU,CAAC,iBAAiB,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,cAAc,CAAC,CACzD,CAAC;AAKF,MAAM,WAAW,kCAAkC;IACjD,iDAAiD;IACjD,QAAQ,CAAC,KAAK,CAAC,EAAE,aAAa,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,gCAAgC,CAAC;IACzD,4FAA4F;IAC5F,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,+EAA+E;IAC/E,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,yDAAyD;IACzD,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC,yDAAyD;IACzD,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,iFAAiF;IACjF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,kEAAkE;IAClE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,MAAM,CAAC;IAC9B,8DAA8D;IAC9D,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1E,+DAA+D;IAC/D,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,MAAM,CAAC;CAChC;AAED,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;CAC/B;AA4BD;;;;;GAKG;AACH,qBAAa,2BAA2B;IAOpC,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,YAAY;IAC7B,OAAO,CAAC,QAAQ,CAAC,UAAU;IAT7B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAuB;IAC/C,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA4B;IAClD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAA+C;IAC5E,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAqB;gBAG7B,QAAQ,EAAE,sBAAsB,EAChC,SAAS,EAAE,iBAAiB,EAC5B,YAAY,EAAE,MAAM,EACpB,UAAU,EAAE,MAAM,EACnC,OAAO,GAAE,kCAAuC;IAgBrC,OAAO,CAAC,QAAQ,EAAE,cAAc,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAmD9D,qBAAqB,CAChC,QAAQ,EAAE,cAAc,EACxB,MAAM,EAAE,kBAAkB,EAC1B,OAAO,GAAE,yBAA8B,GACtC,OAAO,CAAC,YAAY,CAAC;YA4CV,cAAc;YA2Fd,aAAa;YAiBb,iBAAiB;YAqBjB,gBAAgB;IAgC9B,OAAO,CAAC,sBAAsB;IAY9B,OAAO,CAAC,eAAe;IAQvB,OAAO,CAAC,YAAY;IAcpB,OAAO,CAAC,QAAQ;IAShB,OAAO,CAAC,SAAS;IAWjB,OAAO,CAAC,WAAW;IAMnB,OAAO,CAAC,UAAU;IASlB,OAAO,CAAC,iBAAiB;YAIX,cAAc;IAe5B,OAAO,CAAC,QAAQ;IAIhB;;;;;;OAMG;WACW,sBAAsB,CAAC,QAAQ,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM;IAS1F,OAAO,CAAC,MAAM,CAAC,oBAAoB;mBAuCd,KAAK;CAkB3B"}
@@ -1,4 +1,7 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { FailClosedDecision } from "../decision/DecisionAuthority.js";
3
+ const MIN_PRESENCE_TTL_SECONDS = 30;
4
+ const MAX_PRESENCE_TTL_SECONDS = 600;
2
5
  const DEFAULT_MAX_ATTEMPTS = 20;
3
6
  const DEFAULT_INITIAL_DELAY_MS = 250;
4
7
  const DEFAULT_MAX_DELAY_MS = 2_000;
@@ -18,21 +21,37 @@ export class PresenceApprovalCoordinator {
18
21
  organization;
19
22
  approverId;
20
23
  polling;
24
+ audit;
25
+ requirements;
26
+ ttlSeconds;
21
27
  constructor(presence, authority, organization, approverId, options = {}) {
22
28
  this.presence = presence;
23
29
  this.authority = authority;
24
30
  this.organization = organization;
25
31
  this.approverId = approverId;
26
32
  this.polling = PresenceApprovalCoordinator.pollingConfiguration(options);
33
+ this.audit = options.audit;
34
+ this.requirements = options.requirements;
35
+ if (options.ttlSeconds !== undefined &&
36
+ (!Number.isInteger(options.ttlSeconds) ||
37
+ options.ttlSeconds < MIN_PRESENCE_TTL_SECONDS ||
38
+ options.ttlSeconds > MAX_PRESENCE_TTL_SECONDS)) {
39
+ throw new Error("PRESENCE_TTL_INVALID");
40
+ }
41
+ this.ttlSeconds = options.ttlSeconds;
27
42
  }
28
43
  async request(captured) {
29
44
  try {
45
+ // `intentHash` is the structural binding Presence seals into the envelope
46
+ // when the SDK supports it; the display field below stays the human copy.
47
+ const action = {
48
+ intent: captured.intent.action,
49
+ target: captured.intent.target,
50
+ surface: "agent_safe_pipeline",
51
+ intentHash: captured.intentHash,
52
+ };
30
53
  const result = await this.presence.gate({
31
- action: {
32
- intent: captured.intent.action,
33
- target: captured.intent.target,
34
- surface: "agent_safe_pipeline",
35
- },
54
+ action,
36
55
  agent: {
37
56
  id: captured.intent.actor.id,
38
57
  display: captured.intent.actor.id,
@@ -49,11 +68,14 @@ export class PresenceApprovalCoordinator {
49
68
  { key: "intent_hash", label: "Intent hash", value: captured.intentHash },
50
69
  ],
51
70
  },
52
- }, `presence:${captured.intent.idempotencyKey}`);
71
+ ...(this.requirements === undefined ? {} : { requirements: this.requirements }),
72
+ ...(this.ttlSeconds === undefined ? {} : { ttlSeconds: this.ttlSeconds }),
73
+ }, PresenceApprovalCoordinator.presenceIdempotencyKey(captured, this.approverId));
53
74
  const verdict = this.verdictOf(result);
54
75
  if (verdict === null || (verdict === "HUMAN_REQUIRED" && this.requestIdOf(result) === null)) {
55
76
  throw new Error("Presence returned an invalid gate response");
56
77
  }
78
+ await this.recordPresence(captured, verdict === "HUMAN_REQUIRED" ? "PRESENCE_ESCALATED" : "PRESENCE_RESOLVED", verdict);
57
79
  return result;
58
80
  }
59
81
  catch {
@@ -74,12 +96,14 @@ export class PresenceApprovalCoordinator {
74
96
  return FailClosedDecision.create(captured.intentHash, "PRESENCE_RESPONSE_INVALID");
75
97
  }
76
98
  if (verdict !== "PROCEED") {
99
+ await this.recordPresence(captured, "PRESENCE_RESOLVED", verdict);
77
100
  return FailClosedDecision.create(captured.intentHash, `PRESENCE_${verdict}`);
78
101
  }
79
102
  const evidence = this.evidenceOf(resolution.result);
80
103
  if (evidence === null) {
81
104
  return FailClosedDecision.create(captured.intentHash, "PRESENCE_PROOF_MISSING");
82
105
  }
106
+ await this.recordPresence(captured, "PRESENCE_RESOLVED", verdict, evidence.receiptDossierId);
83
107
  const preAuthorizationFailure = this.boundaryFailure(captured, options.signal);
84
108
  if (preAuthorizationFailure !== null) {
85
109
  return FailClosedDecision.create(captured.intentHash, preAuthorizationFailure);
@@ -268,9 +292,33 @@ export class PresenceApprovalCoordinator {
268
292
  boundedIdentifier(value) {
269
293
  return typeof value === "string" && value.length > 0 && value.length <= 200;
270
294
  }
295
+ async recordPresence(captured, eventType, verdict, dossierId) {
296
+ await this.audit?.record({
297
+ eventType,
298
+ captured,
299
+ authority: "NON_AUTHORITATIVE",
300
+ ...(dossierId === undefined ? {} : { dossierId }),
301
+ reasonCodes: [`PRESENCE_${verdict}`],
302
+ });
303
+ }
271
304
  isRecord(value) {
272
305
  return typeof value === "object" && value !== null && !Array.isArray(value);
273
306
  }
307
+ /**
308
+ * Presence replays a sealed answer for a repeated idempotency key, so the key
309
+ * must identify exactly one captured intent for one approver: a re-captured
310
+ * intent has a new hash and must get a new request. The digest keeps the key
311
+ * inside Presence's `[A-Za-z0-9._:-]{8,128}` alphabet whatever the runtime's
312
+ * business key or the approver identity contain.
313
+ */
314
+ static presenceIdempotencyKey(captured, approverId) {
315
+ const digest = createHash("sha256")
316
+ .update(captured.intentHash)
317
+ .update("\u0000")
318
+ .update(approverId)
319
+ .digest("hex");
320
+ return `presence-${digest}`;
321
+ }
274
322
  static pollingConfiguration(options) {
275
323
  const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
276
324
  const initialDelayMs = options.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS;