@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
@@ -4,40 +4,170 @@ import { BoundedResponseBody } from "../http/BoundedResponseBody.js";
4
4
  import { CanonicalIntentHasher } from "../intent/CanonicalIntentHasher.js";
5
5
  import { FailClosedDecision, } from "./DecisionAuthority.js";
6
6
  import { immutableGateDecision } from "./ImmutableGateDecision.js";
7
- const reasonCode = z
7
+ const boundedIdentifier = z
8
8
  .string()
9
9
  .min(1)
10
- .max(120)
11
- .regex(/^[A-Z][A-Z0-9_:-]*$/);
10
+ .max(200)
11
+ .refine(hasNoControlCharacters, { message: "IDENTIFIER_INVALID" });
12
+ const intentHash = z.string().regex(/^sha256:[0-9a-f]{64}$/);
13
+ const reasonCodes = z.array(boundedIdentifier).max(50);
14
+ const roleIdentifier = z.string().regex(/^[A-Z][A-Z0-9_]{1,63}$/);
15
+ const ManagedEscalationRequestSchema = z
16
+ .object({
17
+ mode: z.literal("MANAGED"),
18
+ approver: z
19
+ .object({
20
+ principal_id: boundedIdentifier.optional(),
21
+ role_id: roleIdentifier.optional(),
22
+ })
23
+ .strict()
24
+ .refine((approver) => approver.principal_id !== undefined || approver.role_id !== undefined, {
25
+ message: "MANAGED_ESCALATION_APPROVER_EMPTY",
26
+ })
27
+ .optional(),
28
+ verification_requirements: z
29
+ .object({
30
+ methods: z
31
+ .array(z.enum(["WEBAUTHN", "ACTIVE_LIVENESS"]))
32
+ .min(1)
33
+ .max(3)
34
+ .refine((methods) => new Set(methods).size === methods.length, {
35
+ message: "MANAGED_ESCALATION_METHOD_DUPLICATE",
36
+ }),
37
+ level: z.enum(["STANDARD", "HIGH_CONFIDENCE"]).optional(),
38
+ })
39
+ .strict()
40
+ .optional(),
41
+ })
42
+ .strict();
43
+ const managedEscalationStatuses = [
44
+ "PENDING_PRESENCE",
45
+ "PRESENCE_REQUESTED",
46
+ "AWAITING_APPROVER",
47
+ "PRESENCE_VERIFIED",
48
+ "REAUTHORIZING",
49
+ "GRANT_READY",
50
+ "EXPIRED",
51
+ "REJECTED",
52
+ "BLOCKED",
53
+ "CANCELLED",
54
+ "FAILED",
55
+ ];
56
+ const managedEscalationPendingStatuses = new Set([
57
+ "PENDING_PRESENCE",
58
+ "PRESENCE_REQUESTED",
59
+ "AWAITING_APPROVER",
60
+ "PRESENCE_VERIFIED",
61
+ "REAUTHORIZING",
62
+ ]);
63
+ const ManagedEscalationWireSchema = z
64
+ .object({
65
+ outcome: z.enum(["ESCALATE_PENDING", "ALLOW", "BLOCK", "ERROR"]),
66
+ escalation_id: boundedIdentifier,
67
+ intent_id: boundedIdentifier,
68
+ status: z.enum(managedEscalationStatuses),
69
+ expires_at: z.string().datetime(),
70
+ reason_codes: reasonCodes,
71
+ })
72
+ .strict()
73
+ .superRefine((value, context) => {
74
+ const expectedOutcome = value.status === "GRANT_READY"
75
+ ? "ALLOW"
76
+ : value.status === "FAILED"
77
+ ? "ERROR"
78
+ : managedEscalationPendingStatuses.has(value.status)
79
+ ? "ESCALATE_PENDING"
80
+ : "BLOCK";
81
+ if (value.outcome !== expectedOutcome) {
82
+ context.addIssue({
83
+ code: z.ZodIssueCode.custom,
84
+ message: "MANAGED_ESCALATION_STATE_OUTCOME_MISMATCH",
85
+ });
86
+ }
87
+ });
88
+ /**
89
+ * Mirrors `ExecutionAuthorityDecision` in the Decionis OpenAPI contract, which
90
+ * declares `additionalProperties: false`; an undocumented field therefore fails
91
+ * closed instead of being interpreted as execution semantics.
92
+ */
12
93
  const AuthorityResponseSchema = z
13
94
  .object({
14
- decision_id: z.string().min(1).max(200),
95
+ decision_id: boundedIdentifier,
96
+ chain_id: boundedIdentifier.nullable(),
15
97
  status: z.enum(["ALLOW", "BLOCK", "ESCALATE", "REVIEW_REQUIRED", "ERROR"]),
16
98
  should_execute: z.boolean(),
17
- reason_codes: z.array(reasonCode).max(50),
18
- action_hash: z.string().regex(/^sha256:[0-9a-f]{64}$/),
19
- execution_token: z
20
- .string()
21
- .min(1)
22
- .max(100 * 1024)
23
- .nullable(),
99
+ reason_codes: reasonCodes,
100
+ action_hash: intentHash,
101
+ policy_version: boundedIdentifier.nullable().optional(),
102
+ mode: z.enum(["SHADOW", "PARALLEL", "ENFORCEMENT"]).nullable().optional(),
103
+ execution_token: z.string().min(1).max(20_000).nullable(),
24
104
  execution_token_expires_at: z.string().datetime().nullable(),
25
- dossier_id: z.string().min(1).max(200).nullable(),
105
+ dossier_id: boundedIdentifier.nullable(),
106
+ dossier_sha256: boundedIdentifier.nullable().optional(),
107
+ dossier_url: z.string().min(1).max(2_000).nullable(),
108
+ approval_request_id: boundedIdentifier.nullable().optional(),
109
+ ledger_entry_id: boundedIdentifier.nullable().optional(),
110
+ authority_classification: z.enum(["AUTHORITATIVE", "OBSERVATIONAL"]).optional(),
111
+ execution_eligible: z.boolean().optional(),
112
+ execution_binding_digest: intentHash.nullable().optional(),
113
+ execution_token_jti: boundedIdentifier.nullable().optional(),
114
+ execution_token_key_id: boundedIdentifier.nullable().optional(),
115
+ managed_escalation: ManagedEscalationWireSchema.nullable().optional(),
116
+ })
117
+ .strict();
118
+ const ManagedEscalationStatusResponseSchema = z
119
+ .object({
120
+ escalation_id: boundedIdentifier,
121
+ intent_id: boundedIdentifier,
122
+ action_hash: intentHash,
123
+ status: z.enum(managedEscalationStatuses),
124
+ outcome: z.enum(["ESCALATE_PENDING", "ALLOW", "BLOCK", "ERROR"]),
125
+ expires_at: z.string().datetime(),
126
+ reason_codes: reasonCodes,
127
+ decision: AuthorityResponseSchema.nullable(),
26
128
  })
27
129
  .strict();
130
+ const WAITING_STATUS_RANK = Object.freeze({
131
+ PENDING_PRESENCE: 0,
132
+ PRESENCE_REQUESTED: 1,
133
+ AWAITING_APPROVER: 2,
134
+ PRESENCE_VERIFIED: 3,
135
+ REAUTHORIZING: 4,
136
+ });
28
137
  const MAX_RESPONSE_BYTES = 100 * 1024;
138
+ const DEFAULT_MANAGED_INITIAL_DELAY_MS = 500;
139
+ const DEFAULT_MANAGED_MAX_DELAY_MS = 5_000;
140
+ const MAX_MANAGED_DELAY_MS = 5_000;
141
+ const MAX_MANAGED_ATTEMPTS = 1_000;
29
142
  export class DecionisGate {
143
+ evaluationMode;
30
144
  baseUrl;
31
145
  apiKey;
32
146
  timeoutMs;
33
147
  fetchImpl;
148
+ pendingAuthorizationWaits = new Map();
34
149
  constructor(options) {
35
150
  this.baseUrl = AuthorityBaseUrl.normalize(options.baseUrl, options.allowInsecureLoopback === true);
36
151
  this.apiKey = options.apiKey;
37
152
  this.timeoutMs = Math.min(Math.max(options.timeoutMs ?? 4_000, 1), 15_000);
38
153
  this.fetchImpl = options.fetch ?? fetch;
154
+ const mode = options.mode ?? "ENFORCEMENT";
155
+ if (mode !== "ENFORCEMENT" && mode !== "SHADOW") {
156
+ throw new Error("DECIONIS_GATE_MODE_INVALID");
157
+ }
158
+ this.evaluationMode = mode;
39
159
  }
40
- async evaluate(captured, evidence) {
160
+ async evaluate(captured, evidence, options = {}) {
161
+ const escalation = this.validEscalationRequest(options.escalation);
162
+ if (escalation === null) {
163
+ return FailClosedDecision.create(captured.intentHash, "MANAGED_ESCALATION_REQUEST_INVALID");
164
+ }
165
+ if (escalation !== undefined && this.evaluationMode !== "ENFORCEMENT") {
166
+ return FailClosedDecision.create(captured.intentHash, "MANAGED_ESCALATION_SHADOW_FORBIDDEN");
167
+ }
168
+ if (escalation !== undefined && evidence !== undefined) {
169
+ return FailClosedDecision.create(captured.intentHash, "MANAGED_ESCALATION_EVIDENCE_FORBIDDEN");
170
+ }
41
171
  const controller = new AbortController();
42
172
  const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
43
173
  try {
@@ -46,63 +176,682 @@ export class DecionisGate {
46
176
  headers: {
47
177
  authorization: `Bearer ${this.apiKey}`,
48
178
  "content-type": "application/json",
49
- "idempotency-key": captured.intent.idempotencyKey,
179
+ // The contract requires the header to equal the signed intent_id;
180
+ // intent_id is the authority's grant-issuance boundary.
181
+ "idempotency-key": captured.intent.intentId,
50
182
  },
51
183
  body: JSON.stringify({
52
184
  ...CanonicalIntentHasher.bindingOf(captured.intent),
53
185
  intent_hash: captured.intentHash,
54
- mode: "ENFORCEMENT",
186
+ mode: this.evaluationMode,
55
187
  ...(evidence === undefined ? {} : { evidence }),
188
+ ...(escalation === undefined ? {} : { escalation }),
56
189
  }),
57
190
  signal: controller.signal,
58
191
  });
59
- if (!response.ok) {
60
- return FailClosedDecision.create(captured.intentHash, "AUTHORITY_REQUEST_FAILED");
61
- }
62
192
  const text = await BoundedResponseBody.read(response, MAX_RESPONSE_BYTES);
63
193
  if (text === null) {
64
194
  return FailClosedDecision.create(captured.intentHash, "AUTHORITY_RESPONSE_TOO_LARGE");
65
195
  }
196
+ if (!response.ok) {
197
+ return DecionisGate.failedResponse(captured, text);
198
+ }
66
199
  const parsed = AuthorityResponseSchema.parse(JSON.parse(text));
67
- if (parsed.action_hash !== captured.intentHash) {
68
- return FailClosedDecision.create(captured.intentHash, "AUTHORITY_BINDING_MISMATCH");
69
- }
70
- const verdict = parsed.status === "ALLOW"
71
- ? "ALLOW"
72
- : parsed.status === "ESCALATE" || parsed.status === "REVIEW_REQUIRED"
73
- ? "ESCALATE"
74
- : "BLOCK";
75
- const canExecute = verdict === "ALLOW" &&
76
- parsed.should_execute &&
77
- parsed.dossier_id !== null &&
78
- parsed.execution_token !== null &&
79
- parsed.execution_token_expires_at !== null &&
80
- Date.parse(parsed.execution_token_expires_at) > Date.now() &&
81
- Date.parse(parsed.execution_token_expires_at) <= Date.parse(captured.intent.expiresAt);
82
- if (verdict === "ALLOW" && !canExecute) {
83
- return FailClosedDecision.create(captured.intentHash, "AUTHORITY_GRANT_MISSING");
200
+ const managed = this.initialManagedState(captured, parsed, escalation);
201
+ if (managed.reasonCode !== undefined) {
202
+ return FailClosedDecision.create(captured.intentHash, managed.reasonCode);
84
203
  }
204
+ return this.decisionFromResponse(captured, parsed, evidence, managed.state);
205
+ }
206
+ catch {
207
+ return FailClosedDecision.create(captured.intentHash, "AUTHORITY_UNAVAILABLE");
208
+ }
209
+ finally {
210
+ clearTimeout(timeout);
211
+ }
212
+ }
213
+ /** Performs one authenticated, read-only status lookup against Decionis. */
214
+ async getManagedEscalationStatus(captured, escalation, options = {}) {
215
+ const expectedFailure = this.expectedStateFailure(captured, escalation);
216
+ if (expectedFailure !== null) {
217
+ return this.failedManagedStatus(captured, escalation, expectedFailure);
218
+ }
219
+ const now = DecionisGate.clockNow(options.clock ?? Date.now);
220
+ if (now === null) {
221
+ return this.failedManagedStatus(captured, escalation, "MANAGED_ESCALATION_CLOCK_INVALID");
222
+ }
223
+ const deadline = this.managedDeadline(captured, escalation);
224
+ if (deadline === null) {
225
+ return this.failedManagedStatus(captured, escalation, "MANAGED_ESCALATION_EXPIRY_INVALID");
226
+ }
227
+ if (now >= deadline)
228
+ return this.expiredManagedStatus(captured, escalation);
229
+ if (options.signal?.aborted === true) {
230
+ return this.failedManagedStatus(captured, escalation, "MANAGED_ESCALATION_ABORTED");
231
+ }
232
+ const controller = new AbortController();
233
+ let callerAborted = false;
234
+ const onAbort = () => {
235
+ callerAborted = true;
236
+ controller.abort();
237
+ };
238
+ options.signal?.addEventListener("abort", onAbort, { once: true });
239
+ if (DecionisGate.signalAborted(options.signal))
240
+ onAbort();
241
+ const timeout = setTimeout(() => controller.abort(), Math.max(1, Math.min(this.timeoutMs, deadline - now)));
242
+ try {
243
+ const response = await this.fetchImpl(`${this.baseUrl}/v1/authority/escalations/${encodeURIComponent(escalation.escalationId)}`, {
244
+ method: "GET",
245
+ headers: { authorization: `Bearer ${this.apiKey}` },
246
+ signal: controller.signal,
247
+ });
248
+ const text = await BoundedResponseBody.read(response, MAX_RESPONSE_BYTES);
249
+ if (text === null) {
250
+ return this.failedManagedStatus(captured, escalation, "MANAGED_ESCALATION_RESPONSE_TOO_LARGE");
251
+ }
252
+ if (!response.ok) {
253
+ return this.failedManagedStatus(captured, escalation, "MANAGED_ESCALATION_STATUS_FAILED");
254
+ }
255
+ let body;
256
+ try {
257
+ body = JSON.parse(text);
258
+ }
259
+ catch {
260
+ return this.failedManagedStatus(captured, escalation, "MANAGED_ESCALATION_RESPONSE_INVALID");
261
+ }
262
+ const result = ManagedEscalationStatusResponseSchema.safeParse(body);
263
+ if (!result.success) {
264
+ return this.failedManagedStatus(captured, escalation, "MANAGED_ESCALATION_RESPONSE_INVALID");
265
+ }
266
+ const parsed = result.data;
267
+ const mismatch = this.statusBindingFailure(captured, escalation, parsed);
268
+ if (mismatch !== null)
269
+ return this.failedManagedStatus(captured, escalation, mismatch);
270
+ const after = DecionisGate.clockNow(options.clock ?? Date.now);
271
+ if (after === null) {
272
+ return this.failedManagedStatus(captured, escalation, "MANAGED_ESCALATION_CLOCK_INVALID");
273
+ }
274
+ if (after >= deadline)
275
+ return this.expiredManagedStatus(captured, escalation);
276
+ return this.statusResult(captured, parsed);
277
+ }
278
+ catch {
279
+ if (callerAborted) {
280
+ return this.failedManagedStatus(captured, escalation, "MANAGED_ESCALATION_ABORTED");
281
+ }
282
+ const after = DecionisGate.clockNow(options.clock ?? Date.now);
283
+ if (after !== null && after >= deadline) {
284
+ return this.expiredManagedStatus(captured, escalation);
285
+ }
286
+ return this.failedManagedStatus(captured, escalation, "MANAGED_ESCALATION_STATUS_FAILED");
287
+ }
288
+ finally {
289
+ clearTimeout(timeout);
290
+ options.signal?.removeEventListener("abort", onAbort);
291
+ }
292
+ }
293
+ /**
294
+ * Waits only on Decionis. Presence credentials, invitation locators, and
295
+ * receipt evidence never cross this client boundary in managed mode.
296
+ */
297
+ async waitForAuthorization(captured, pendingDecision, options = {}) {
298
+ const escalation = pendingDecision.managedEscalation;
299
+ if (escalation === undefined ||
300
+ pendingDecision.verdict !== "ESCALATE" ||
301
+ pendingDecision.authorization !== null ||
302
+ pendingDecision.intentHash !== captured.intentHash ||
303
+ !DecionisGate.validManagedStateSemantics(escalation) ||
304
+ this.expectedStateFailure(captured, escalation) !== null) {
305
+ return FailClosedDecision.create(captured.intentHash, "MANAGED_ESCALATION_REFERENCE_INVALID");
306
+ }
307
+ const configuration = DecionisGate.waitConfiguration(options);
308
+ if (configuration === null) {
309
+ return this.failedWaitDecision(captured, pendingDecision, escalation, "MANAGED_ESCALATION_POLLING_OPTIONS_INVALID");
310
+ }
311
+ // Default waits for the same exact binding share one read-only poll loop.
312
+ // Custom scheduling or cancellation stays caller-local.
313
+ if (Object.keys(options).length === 0) {
314
+ const key = `${captured.intentHash}\u0000${escalation.escalationId}`;
315
+ const existing = this.pendingAuthorizationWaits.get(key);
316
+ if (existing !== undefined)
317
+ return await existing;
318
+ const wait = this.waitForAuthorizationInternal(captured, pendingDecision, escalation, configuration, options.signal).finally(() => this.pendingAuthorizationWaits.delete(key));
319
+ this.pendingAuthorizationWaits.set(key, wait);
320
+ return await wait;
321
+ }
322
+ return await this.waitForAuthorizationInternal(captured, pendingDecision, escalation, configuration, options.signal);
323
+ }
324
+ async waitForAuthorizationInternal(captured, pendingDecision, escalation, configuration, signal) {
325
+ const deadline = this.managedDeadline(captured, escalation);
326
+ if (deadline === null) {
327
+ return this.failedWaitDecision(captured, pendingDecision, escalation, "MANAGED_ESCALATION_EXPIRY_INVALID");
328
+ }
329
+ let lastStatus = DecionisGate.isPendingStatus(escalation.status)
330
+ ? escalation.status
331
+ : undefined;
332
+ for (let attempt = 0; configuration.maxAttempts === null || attempt < configuration.maxAttempts; attempt += 1) {
333
+ const boundary = this.waitBoundaryFailure(configuration.clock, deadline, signal);
334
+ if (boundary !== null) {
335
+ return boundary === "INTENT_EXPIRED"
336
+ ? this.expiredWaitDecision(captured, pendingDecision, escalation)
337
+ : this.failedWaitDecision(captured, pendingDecision, escalation, boundary);
338
+ }
339
+ const status = await this.getManagedEscalationStatus(captured, escalation, {
340
+ ...(signal === undefined ? {} : { signal }),
341
+ clock: configuration.clock,
342
+ });
343
+ const afterLookup = this.waitBoundaryFailure(configuration.clock, deadline, signal);
344
+ if (afterLookup !== null) {
345
+ return afterLookup === "INTENT_EXPIRED"
346
+ ? this.expiredWaitDecision(captured, pendingDecision, escalation)
347
+ : this.failedWaitDecision(captured, pendingDecision, escalation, afterLookup);
348
+ }
349
+ if (!DecionisGate.isPendingStatus(escalation.status) && status.status !== escalation.status) {
350
+ return this.failedWaitDecision(captured, pendingDecision, escalation, "MANAGED_ESCALATION_STATE_REGRESSION");
351
+ }
352
+ if (status.status === "GRANT_READY")
353
+ return status.decision;
354
+ if (status.outcome !== "ESCALATE_PENDING") {
355
+ return this.terminalWaitDecision(captured, pendingDecision, status);
356
+ }
357
+ if (lastStatus === undefined ||
358
+ WAITING_STATUS_RANK[status.status] < WAITING_STATUS_RANK[lastStatus]) {
359
+ return this.failedWaitDecision(captured, pendingDecision, escalation, "MANAGED_ESCALATION_STATE_REGRESSION");
360
+ }
361
+ lastStatus = status.status;
362
+ if (configuration.maxAttempts !== null && attempt + 1 >= configuration.maxAttempts) {
363
+ return this.failedWaitDecision(captured, pendingDecision, escalation, "MANAGED_ESCALATION_TIMEOUT");
364
+ }
365
+ const delay = DecionisGate.backoffDelay(configuration, attempt);
366
+ if (delay === null) {
367
+ return this.failedWaitDecision(captured, pendingDecision, escalation, "MANAGED_ESCALATION_POLLING_RANDOM_INVALID");
368
+ }
369
+ const now = DecionisGate.clockNow(configuration.clock);
370
+ if (now === null) {
371
+ return this.failedWaitDecision(captured, pendingDecision, escalation, "MANAGED_ESCALATION_CLOCK_INVALID");
372
+ }
373
+ const remaining = deadline - now;
374
+ if (remaining <= 0)
375
+ return this.expiredWaitDecision(captured, pendingDecision, escalation);
376
+ const slept = await DecionisGate.boundedSleep(configuration.sleep, Math.min(delay, remaining), remaining, signal);
377
+ if (slept !== null) {
378
+ return slept === "INTENT_EXPIRED"
379
+ ? this.expiredWaitDecision(captured, pendingDecision, escalation)
380
+ : this.failedWaitDecision(captured, pendingDecision, escalation, slept);
381
+ }
382
+ }
383
+ return this.failedWaitDecision(captured, pendingDecision, escalation, "MANAGED_ESCALATION_TIMEOUT");
384
+ }
385
+ decisionFromResponse(captured, parsed, evidence, managedEscalation) {
386
+ if (parsed.action_hash !== captured.intentHash) {
387
+ return FailClosedDecision.create(captured.intentHash, "AUTHORITY_BINDING_MISMATCH");
388
+ }
389
+ const mode = parsed.mode ?? null;
390
+ if (mode !== null && mode !== this.evaluationMode) {
391
+ return FailClosedDecision.create(captured.intentHash, "AUTHORITY_MODE_MISMATCH");
392
+ }
393
+ const verdict = parsed.status === "ALLOW"
394
+ ? "ALLOW"
395
+ : parsed.status === "ESCALATE" || parsed.status === "REVIEW_REQUIRED"
396
+ ? "ESCALATE"
397
+ : "BLOCK";
398
+ if (this.evaluationMode === "SHADOW") {
85
399
  return immutableGateDecision({
86
400
  verdict,
87
401
  decisionId: parsed.decision_id,
88
402
  dossierId: parsed.dossier_id,
89
403
  intentHash: parsed.action_hash,
90
404
  reasonCodes: parsed.reason_codes,
91
- authorization: canExecute
92
- ? {
93
- token: parsed.execution_token,
94
- expiresAt: parsed.execution_token_expires_at,
95
- }
96
- : null,
405
+ authorization: null,
97
406
  failClosed: parsed.status === "ERROR",
407
+ ...(evidence === undefined ? {} : { evidence }),
98
408
  });
99
409
  }
410
+ const canExecute = verdict === "ALLOW" &&
411
+ parsed.should_execute &&
412
+ mode === "ENFORCEMENT" &&
413
+ (parsed.authority_classification === undefined ||
414
+ parsed.authority_classification === "AUTHORITATIVE") &&
415
+ (parsed.execution_eligible === undefined || parsed.execution_eligible === true) &&
416
+ (parsed.execution_binding_digest === undefined || parsed.execution_binding_digest !== null) &&
417
+ (parsed.execution_token_jti === undefined || parsed.execution_token_jti !== null) &&
418
+ (parsed.execution_token_key_id === undefined || parsed.execution_token_key_id !== null) &&
419
+ parsed.dossier_id !== null &&
420
+ parsed.execution_token !== null &&
421
+ parsed.execution_token_expires_at !== null &&
422
+ Date.parse(parsed.execution_token_expires_at) > Date.now() &&
423
+ Date.parse(parsed.execution_token_expires_at) <= Date.parse(captured.intent.expiresAt);
424
+ if (verdict === "ALLOW" && !canExecute) {
425
+ return FailClosedDecision.create(captured.intentHash, "AUTHORITY_GRANT_MISSING");
426
+ }
427
+ return immutableGateDecision({
428
+ verdict,
429
+ decisionId: parsed.decision_id,
430
+ dossierId: parsed.dossier_id,
431
+ intentHash: parsed.action_hash,
432
+ reasonCodes: parsed.reason_codes,
433
+ authorization: canExecute
434
+ ? {
435
+ token: parsed.execution_token,
436
+ expiresAt: parsed.execution_token_expires_at,
437
+ }
438
+ : null,
439
+ failClosed: parsed.status === "ERROR",
440
+ ...(evidence === undefined ? {} : { evidence }),
441
+ ...(managedEscalation === undefined ? {} : { managedEscalation }),
442
+ });
443
+ }
444
+ initialManagedState(captured, response, requested) {
445
+ const managed = response.managed_escalation ?? undefined;
446
+ if (managed === undefined) {
447
+ return requested !== undefined &&
448
+ (response.status === "ESCALATE" || response.status === "REVIEW_REQUIRED")
449
+ ? { reasonCode: "MANAGED_ESCALATION_MISSING" }
450
+ : {};
451
+ }
452
+ if (requested === undefined)
453
+ return { reasonCode: "MANAGED_ESCALATION_UNEXPECTED" };
454
+ if (response.status !== "ESCALATE" ||
455
+ response.should_execute ||
456
+ response.execution_token !== null ||
457
+ response.execution_token_expires_at !== null ||
458
+ response.execution_eligible === true ||
459
+ response.execution_binding_digest != null ||
460
+ response.execution_token_jti != null ||
461
+ response.execution_token_key_id != null) {
462
+ return { reasonCode: "MANAGED_ESCALATION_PENDING_GRANT_FORBIDDEN" };
463
+ }
464
+ const state = DecionisGate.managedStateFromWire(managed);
465
+ const failure = this.expectedStateFailure(captured, state);
466
+ return failure === null ? { state } : { reasonCode: failure };
467
+ }
468
+ statusResult(captured, response) {
469
+ const base = {
470
+ escalationId: response.escalation_id,
471
+ intentId: response.intent_id,
472
+ actionHash: response.action_hash,
473
+ expiresAt: response.expires_at,
474
+ reasonCodes: Object.freeze([...response.reason_codes]),
475
+ };
476
+ if (DecionisGate.isPendingStatus(response.status)) {
477
+ if (response.outcome !== "ESCALATE_PENDING" || response.decision !== null) {
478
+ return this.failedStatusFromBase(base, "MANAGED_ESCALATION_RESPONSE_INVALID");
479
+ }
480
+ return Object.freeze({
481
+ ...base,
482
+ status: response.status,
483
+ outcome: "ESCALATE_PENDING",
484
+ decision: null,
485
+ });
486
+ }
487
+ if (response.status === "GRANT_READY") {
488
+ if (response.outcome !== "ALLOW" ||
489
+ response.decision === null ||
490
+ response.decision.managed_escalation != null) {
491
+ return this.failedStatusFromBase(base, "MANAGED_ESCALATION_GRANT_INVALID");
492
+ }
493
+ const state = DecionisGate.managedStateFromStatus(response);
494
+ const decision = this.decisionFromResponse(captured, response.decision, undefined, state);
495
+ if (decision.verdict !== "ALLOW" || decision.authorization === null || decision.failClosed) {
496
+ return this.failedStatusFromBase(base, decision.reasonCodes[0] ?? "MANAGED_ESCALATION_GRANT_INVALID");
497
+ }
498
+ return Object.freeze({
499
+ ...base,
500
+ status: "GRANT_READY",
501
+ outcome: "ALLOW",
502
+ decision,
503
+ });
504
+ }
505
+ if (response.status === "FAILED") {
506
+ if (response.outcome !== "ERROR" || response.decision !== null) {
507
+ return this.failedStatusFromBase(base, "MANAGED_ESCALATION_RESPONSE_INVALID");
508
+ }
509
+ return Object.freeze({ ...base, status: "FAILED", outcome: "ERROR", decision: null });
510
+ }
511
+ if (response.outcome !== "BLOCK" || response.decision !== null) {
512
+ return this.failedStatusFromBase(base, "MANAGED_ESCALATION_RESPONSE_INVALID");
513
+ }
514
+ return Object.freeze({
515
+ ...base,
516
+ status: response.status,
517
+ outcome: "BLOCK",
518
+ decision: null,
519
+ });
520
+ }
521
+ expectedStateFailure(captured, escalation) {
522
+ if (!DecionisGate.isBoundedIdentifier(escalation.escalationId)) {
523
+ return "MANAGED_ESCALATION_ID_INVALID";
524
+ }
525
+ if (escalation.intentId !== captured.intent.intentId) {
526
+ return "MANAGED_ESCALATION_INTENT_MISMATCH";
527
+ }
528
+ const expiresAt = Date.parse(escalation.expiresAt);
529
+ const intentExpiresAt = Date.parse(captured.intent.expiresAt);
530
+ if (!Number.isFinite(expiresAt) ||
531
+ !Number.isFinite(intentExpiresAt) ||
532
+ expiresAt > intentExpiresAt ||
533
+ expiresAt <= Date.parse(captured.intent.capturedAt)) {
534
+ return "MANAGED_ESCALATION_EXPIRY_INVALID";
535
+ }
536
+ return null;
537
+ }
538
+ statusBindingFailure(captured, expected, actual) {
539
+ if (actual.escalation_id !== expected.escalationId) {
540
+ return "MANAGED_ESCALATION_ID_MISMATCH";
541
+ }
542
+ if (actual.intent_id !== expected.intentId || actual.intent_id !== captured.intent.intentId) {
543
+ return "MANAGED_ESCALATION_INTENT_MISMATCH";
544
+ }
545
+ if (actual.action_hash !== captured.intentHash)
546
+ return "AUTHORITY_BINDING_MISMATCH";
547
+ if (actual.expires_at !== expected.expiresAt)
548
+ return "MANAGED_ESCALATION_EXPIRY_MISMATCH";
549
+ return null;
550
+ }
551
+ managedDeadline(captured, escalation) {
552
+ const intentExpiresAt = Date.parse(captured.intent.expiresAt);
553
+ const escalationExpiresAt = Date.parse(escalation.expiresAt);
554
+ return Number.isFinite(intentExpiresAt) && Number.isFinite(escalationExpiresAt)
555
+ ? Math.min(intentExpiresAt, escalationExpiresAt)
556
+ : null;
557
+ }
558
+ waitBoundaryFailure(clock, deadline, signal) {
559
+ if (signal?.aborted === true)
560
+ return "MANAGED_ESCALATION_ABORTED";
561
+ const now = DecionisGate.clockNow(clock);
562
+ if (now === null)
563
+ return "MANAGED_ESCALATION_CLOCK_INVALID";
564
+ return now >= deadline ? "INTENT_EXPIRED" : null;
565
+ }
566
+ terminalWaitDecision(captured, pending, status) {
567
+ const fallback = status.status === "EXPIRED"
568
+ ? ["INTENT_EXPIRED", "RECAPTURE_REQUIRED"]
569
+ : status.status === "REJECTED"
570
+ ? ["PRESENCE_REJECTED"]
571
+ : status.status === "BLOCKED"
572
+ ? ["REAUTHORIZATION_BLOCKED"]
573
+ : status.status === "CANCELLED"
574
+ ? ["CANCELLED"]
575
+ : ["MANAGED_ESCALATION_FAILED"];
576
+ const codes = status.reasonCodes.length === 0 ? fallback : status.reasonCodes;
577
+ return immutableGateDecision({
578
+ verdict: "BLOCK",
579
+ decisionId: pending.decisionId,
580
+ dossierId: pending.dossierId,
581
+ intentHash: captured.intentHash,
582
+ reasonCodes: codes,
583
+ authorization: null,
584
+ failClosed: status.outcome === "ERROR",
585
+ managedEscalation: DecionisGate.managedStateFromStatusResult(status),
586
+ });
587
+ }
588
+ expiredWaitDecision(captured, pending, escalation) {
589
+ return immutableGateDecision({
590
+ verdict: "BLOCK",
591
+ decisionId: pending.decisionId,
592
+ dossierId: pending.dossierId,
593
+ intentHash: captured.intentHash,
594
+ reasonCodes: ["INTENT_EXPIRED", "RECAPTURE_REQUIRED"],
595
+ authorization: null,
596
+ failClosed: false,
597
+ managedEscalation: {
598
+ ...escalation,
599
+ status: "EXPIRED",
600
+ outcome: "BLOCK",
601
+ reasonCodes: ["INTENT_EXPIRED", "RECAPTURE_REQUIRED"],
602
+ },
603
+ });
604
+ }
605
+ failedWaitDecision(captured, pending, escalation, reasonCode) {
606
+ return immutableGateDecision({
607
+ verdict: "BLOCK",
608
+ decisionId: pending.decisionId,
609
+ dossierId: pending.dossierId,
610
+ intentHash: captured.intentHash,
611
+ reasonCodes: [reasonCode],
612
+ authorization: null,
613
+ failClosed: true,
614
+ managedEscalation: {
615
+ ...escalation,
616
+ status: "FAILED",
617
+ outcome: "ERROR",
618
+ reasonCodes: [reasonCode],
619
+ },
620
+ });
621
+ }
622
+ failedManagedStatus(captured, escalation, reasonCode) {
623
+ return this.failedStatusFromBase({
624
+ escalationId: escalation.escalationId,
625
+ intentId: escalation.intentId,
626
+ actionHash: captured.intentHash,
627
+ expiresAt: escalation.expiresAt,
628
+ reasonCodes: Object.freeze([reasonCode]),
629
+ }, reasonCode);
630
+ }
631
+ expiredManagedStatus(captured, escalation) {
632
+ return Object.freeze({
633
+ escalationId: escalation.escalationId,
634
+ intentId: escalation.intentId,
635
+ actionHash: captured.intentHash,
636
+ expiresAt: escalation.expiresAt,
637
+ reasonCodes: Object.freeze(["INTENT_EXPIRED", "RECAPTURE_REQUIRED"]),
638
+ status: "EXPIRED",
639
+ outcome: "BLOCK",
640
+ decision: null,
641
+ });
642
+ }
643
+ failedStatusFromBase(base, reasonCode) {
644
+ return Object.freeze({
645
+ ...base,
646
+ reasonCodes: Object.freeze([reasonCode]),
647
+ status: "FAILED",
648
+ outcome: "ERROR",
649
+ decision: null,
650
+ });
651
+ }
652
+ validEscalationRequest(value) {
653
+ if (value === undefined)
654
+ return undefined;
655
+ const parsed = ManagedEscalationRequestSchema.safeParse(value);
656
+ if (!parsed.success)
657
+ return null;
658
+ return {
659
+ mode: parsed.data.mode,
660
+ ...(parsed.data.approver === undefined
661
+ ? {}
662
+ : {
663
+ approver: {
664
+ ...(parsed.data.approver.principal_id === undefined
665
+ ? {}
666
+ : { principal_id: parsed.data.approver.principal_id }),
667
+ ...(parsed.data.approver.role_id === undefined
668
+ ? {}
669
+ : { role_id: parsed.data.approver.role_id }),
670
+ },
671
+ }),
672
+ ...(parsed.data.verification_requirements === undefined
673
+ ? {}
674
+ : {
675
+ verification_requirements: {
676
+ methods: parsed.data.verification_requirements.methods,
677
+ ...(parsed.data.verification_requirements.level === undefined
678
+ ? {}
679
+ : { level: parsed.data.verification_requirements.level }),
680
+ },
681
+ }),
682
+ };
683
+ }
684
+ static managedStateFromWire(value) {
685
+ return {
686
+ escalationId: value.escalation_id,
687
+ intentId: value.intent_id,
688
+ status: value.status,
689
+ outcome: value.outcome,
690
+ expiresAt: value.expires_at,
691
+ reasonCodes: value.reason_codes,
692
+ };
693
+ }
694
+ static managedStateFromStatus(value) {
695
+ return {
696
+ escalationId: value.escalation_id,
697
+ intentId: value.intent_id,
698
+ status: value.status,
699
+ outcome: value.outcome,
700
+ expiresAt: value.expires_at,
701
+ reasonCodes: value.reason_codes,
702
+ };
703
+ }
704
+ static managedStateFromStatusResult(value) {
705
+ return {
706
+ escalationId: value.escalationId,
707
+ intentId: value.intentId,
708
+ status: value.status,
709
+ outcome: value.outcome,
710
+ expiresAt: value.expiresAt,
711
+ reasonCodes: value.reasonCodes,
712
+ };
713
+ }
714
+ static isPendingStatus(status) {
715
+ return Object.hasOwn(WAITING_STATUS_RANK, status);
716
+ }
717
+ static validManagedStateSemantics(state) {
718
+ if (DecionisGate.isPendingStatus(state.status)) {
719
+ return state.outcome === "ESCALATE_PENDING";
720
+ }
721
+ if (state.status === "GRANT_READY")
722
+ return state.outcome === "ALLOW";
723
+ if (state.status === "FAILED")
724
+ return state.outcome === "ERROR";
725
+ return state.outcome === "BLOCK";
726
+ }
727
+ static waitConfiguration(options) {
728
+ const maxAttempts = options.maxAttempts ?? null;
729
+ const initialDelayMs = options.initialDelayMs ?? DEFAULT_MANAGED_INITIAL_DELAY_MS;
730
+ const maxDelayMs = options.maxDelayMs ?? DEFAULT_MANAGED_MAX_DELAY_MS;
731
+ if ((maxAttempts !== null &&
732
+ (!Number.isInteger(maxAttempts) ||
733
+ maxAttempts < 1 ||
734
+ maxAttempts > MAX_MANAGED_ATTEMPTS)) ||
735
+ !Number.isInteger(initialDelayMs) ||
736
+ initialDelayMs < 1 ||
737
+ initialDelayMs > MAX_MANAGED_DELAY_MS ||
738
+ !Number.isInteger(maxDelayMs) ||
739
+ maxDelayMs < initialDelayMs ||
740
+ maxDelayMs > MAX_MANAGED_DELAY_MS ||
741
+ (options.clock !== undefined && typeof options.clock !== "function") ||
742
+ (options.sleep !== undefined && typeof options.sleep !== "function") ||
743
+ (options.random !== undefined && typeof options.random !== "function")) {
744
+ return null;
745
+ }
746
+ return {
747
+ maxAttempts,
748
+ initialDelayMs,
749
+ maxDelayMs,
750
+ clock: options.clock ?? Date.now,
751
+ sleep: options.sleep ?? DecionisGate.sleep,
752
+ random: options.random ?? Math.random,
753
+ };
754
+ }
755
+ static backoffDelay(configuration, attempt) {
756
+ let random;
757
+ try {
758
+ random = configuration.random();
759
+ }
100
760
  catch {
101
- return FailClosedDecision.create(captured.intentHash, "AUTHORITY_UNAVAILABLE");
761
+ return null;
102
762
  }
103
- finally {
104
- clearTimeout(timeout);
763
+ if (!Number.isFinite(random) || random < 0 || random > 1)
764
+ return null;
765
+ const exponential = configuration.initialDelayMs * 2 ** Math.min(attempt, 30);
766
+ const capped = Math.min(exponential, configuration.maxDelayMs);
767
+ return Math.max(1, Math.floor(capped * (0.5 + random * 0.5)));
768
+ }
769
+ static async boundedSleep(sleep, delayMs, remainingMs, signal) {
770
+ if (signal?.aborted === true)
771
+ return "MANAGED_ESCALATION_ABORTED";
772
+ return await new Promise((resolve) => {
773
+ let settled = false;
774
+ const settle = (reason) => {
775
+ if (settled)
776
+ return;
777
+ settled = true;
778
+ clearTimeout(timer);
779
+ signal?.removeEventListener("abort", onAbort);
780
+ resolve(reason);
781
+ };
782
+ const onAbort = () => settle("MANAGED_ESCALATION_ABORTED");
783
+ const timer = setTimeout(() => settle("INTENT_EXPIRED"), remainingMs);
784
+ signal?.addEventListener("abort", onAbort, { once: true });
785
+ void Promise.resolve()
786
+ .then(async () => await sleep(delayMs, signal))
787
+ .then(() => settle(null), () => settle(signal?.aborted === true
788
+ ? "MANAGED_ESCALATION_ABORTED"
789
+ : "MANAGED_ESCALATION_UNAVAILABLE"));
790
+ });
791
+ }
792
+ static async sleep(delayMs, signal) {
793
+ await new Promise((resolve, reject) => {
794
+ if (signal?.aborted === true) {
795
+ reject(new Error("MANAGED_ESCALATION_ABORTED"));
796
+ return;
797
+ }
798
+ const onAbort = () => {
799
+ clearTimeout(timer);
800
+ reject(new Error("MANAGED_ESCALATION_ABORTED"));
801
+ };
802
+ const timer = setTimeout(() => {
803
+ signal?.removeEventListener("abort", onAbort);
804
+ resolve();
805
+ }, delayMs);
806
+ signal?.addEventListener("abort", onAbort, { once: true });
807
+ });
808
+ }
809
+ static clockNow(clock) {
810
+ try {
811
+ const now = clock();
812
+ return Number.isFinite(now) ? now : null;
105
813
  }
814
+ catch {
815
+ return null;
816
+ }
817
+ }
818
+ static signalAborted(signal) {
819
+ return signal?.aborted === true;
820
+ }
821
+ static isBoundedIdentifier(value) {
822
+ return value.length > 0 && value.length <= 200 && hasNoControlCharacters(value);
823
+ }
824
+ /**
825
+ * The contract returns an `ERROR` decision body on 409 and 503 so the
826
+ * refusal is evidence-bearing. Anything else fails closed generically.
827
+ */
828
+ static failedResponse(captured, text) {
829
+ try {
830
+ const parsed = AuthorityResponseSchema.parse(JSON.parse(text));
831
+ if (parsed.status !== "ERROR" || parsed.action_hash !== captured.intentHash) {
832
+ return FailClosedDecision.create(captured.intentHash, "AUTHORITY_REQUEST_FAILED");
833
+ }
834
+ return immutableGateDecision({
835
+ verdict: "BLOCK",
836
+ decisionId: parsed.decision_id,
837
+ dossierId: parsed.dossier_id,
838
+ intentHash: captured.intentHash,
839
+ reasonCodes: parsed.reason_codes.length === 0 ? ["AUTHORITY_REQUEST_FAILED"] : parsed.reason_codes,
840
+ authorization: null,
841
+ failClosed: true,
842
+ });
843
+ }
844
+ catch {
845
+ return FailClosedDecision.create(captured.intentHash, "AUTHORITY_REQUEST_FAILED");
846
+ }
847
+ }
848
+ }
849
+ function hasNoControlCharacters(value) {
850
+ for (let index = 0; index < value.length; index += 1) {
851
+ const codeUnit = value.charCodeAt(index);
852
+ if (codeUnit <= 0x1f || codeUnit === 0x7f)
853
+ return false;
106
854
  }
855
+ return true;
107
856
  }
108
857
  //# sourceMappingURL=DecionisGate.js.map