@emilia-protocol/gate 0.16.1 → 0.18.1

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.
@@ -0,0 +1,1534 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+
3
+ /**
4
+ * Gate Qualification v2 orchestration over the canonical admission-custody
5
+ * contract. Qualification is evidence, not authorization: only a complete
6
+ * immutable AdmissionSnapshot may cross the one-time invocation boundary.
7
+ */
8
+
9
+ import { createAdmissionSnapshot } from './admission-store.js';
10
+ import type {
11
+ AdmissionSnapshot,
12
+ AdmissionStore,
13
+ } from './admission-store.js';
14
+ import type { QualificationDecision } from '@emilia-protocol/verify/gate-qualification';
15
+
16
+ export const GATE_QUALIFICATION_V2_VERSION = 'EP-GATE-QUALIFICATION-v2';
17
+
18
+ const DIGEST = /^sha256:[0-9a-f]{64}$/;
19
+ const REQUIRED_QUALIFICATION_CHECKS = Object.freeze([
20
+ 'schemas',
21
+ 'payload_signatures',
22
+ 'trust_accepted',
23
+ 'campaign_lineage',
24
+ 'terminal_outcomes_complete',
25
+ 'hidden_challenge_commitments',
26
+ 'qualification_statement_binding',
27
+ 'status_chain',
28
+ 'status_current_as_observed',
29
+ 'runtime_candidate_exact_match',
30
+ 'assignment_in_scope',
31
+ 'protected_request_bound',
32
+ ] as const);
33
+ const DEFAULT_ADAPTER_TIMEOUT_MS = 30_000;
34
+ const MAX_ADAPTER_TIMEOUT_MS = 300_000;
35
+
36
+ type AdmissionDigestV2 = AdmissionSnapshot['snapshot_digest'];
37
+ type StoreRecordV2 = NonNullable<
38
+ Awaited<ReturnType<AdmissionStore['read']>>
39
+ >;
40
+
41
+ export type QualificationModeV2 = 'enforce' | 'shadow';
42
+ export type ProviderOutcomeV2 =
43
+ | 'COMMITTED'
44
+ | 'PROVEN_NOT_COMMITTED'
45
+ | 'INDETERMINATE';
46
+
47
+ export interface BoundRequirementDecisionV2 {
48
+ readonly decision: 'allow' | 'deny';
49
+ readonly requirementId: string;
50
+ readonly caid: string;
51
+ readonly actionDigest: AdmissionDigestV2;
52
+ readonly evidenceDigest: AdmissionDigestV2;
53
+ readonly reason?: string;
54
+ }
55
+
56
+ export interface BoundLocalPolicyDecisionV2 {
57
+ readonly decision: 'allow' | 'deny';
58
+ readonly policyId: string;
59
+ readonly caid: string;
60
+ readonly actionDigest: AdmissionDigestV2;
61
+ readonly evidenceDigest: AdmissionDigestV2;
62
+ readonly reason?: string;
63
+ }
64
+
65
+ /** The qualification leg is the verifier's real three-way result. */
66
+ export interface GateQualificationBundleV2 {
67
+ readonly qualification: Readonly<QualificationDecision>;
68
+ readonly aeb: Readonly<BoundRequirementDecisionV2>;
69
+ readonly aec: Readonly<BoundRequirementDecisionV2>;
70
+ readonly localPolicy: Readonly<BoundLocalPolicyDecisionV2>;
71
+ }
72
+
73
+ export interface QualificationCompositionInputV2 {
74
+ readonly snapshot: Readonly<AdmissionSnapshot>;
75
+ readonly qualification: Readonly<GateQualificationBundleV2>;
76
+ }
77
+
78
+ export interface GateQualificationDecisionV2 {
79
+ readonly version: typeof GATE_QUALIFICATION_V2_VERSION;
80
+ readonly allow: boolean;
81
+ readonly reasons: readonly string[];
82
+ readonly tenantId: string;
83
+ readonly admissionId: string;
84
+ readonly operationId: string;
85
+ readonly caid: string;
86
+ readonly actionDigest: string;
87
+ readonly snapshotDigest: string;
88
+ readonly effectKey: string;
89
+ readonly requirements: Readonly<{
90
+ qualificationEvidenceDigest: string;
91
+ aebRequirementId: string;
92
+ aebEvidenceDigest: string;
93
+ aecRequirementId: string;
94
+ aecEvidenceDigest: string;
95
+ localPolicyId: string;
96
+ localPolicyEvidenceDigest: string;
97
+ }>;
98
+ }
99
+
100
+ /**
101
+ * The adapter sees only the exact immutable snapshot returned by
102
+ * AdmissionStore.beginInvocation and the store-issued invocation capability.
103
+ */
104
+ export interface ProtectedInvocationV2 {
105
+ readonly snapshot: Readonly<AdmissionSnapshot>;
106
+ readonly invocationToken: string;
107
+ }
108
+
109
+ export interface ProtectedReconciliationV2 extends ProtectedInvocationV2 {
110
+ readonly reconciliationOnly: true;
111
+ }
112
+
113
+ /** Provider credentials and mutation implementation remain inside this object. */
114
+ export interface ProtectedAdapterV2 {
115
+ readonly custody: 'protected';
116
+ readonly credentialsExposed: false;
117
+ invoke(input: Readonly<ProtectedInvocationV2>): Promise<unknown>;
118
+ reconcile(input: Readonly<ProtectedReconciliationV2>): Promise<unknown>;
119
+ }
120
+
121
+ export interface ProviderEvidenceV2 {
122
+ readonly evidenceId: string;
123
+ readonly evidenceDigest: AdmissionDigestV2;
124
+ readonly tenantId: string;
125
+ readonly admissionId: string;
126
+ readonly operationId: string;
127
+ readonly snapshotDigest: AdmissionDigestV2;
128
+ readonly caid: string;
129
+ readonly actionDigest: AdmissionDigestV2;
130
+ readonly effectRequestDigest: AdmissionDigestV2;
131
+ readonly provider: Readonly<AdmissionSnapshot['body']['provider']>;
132
+ readonly executorAdapterDigest: AdmissionDigestV2;
133
+ readonly idempotencyKey: string;
134
+ readonly outcome: ProviderOutcomeV2;
135
+ readonly observedAt: string;
136
+ }
137
+
138
+ export type ProviderEvidenceVerificationV2 =
139
+ | { readonly ok: true; readonly evidence: Readonly<ProviderEvidenceV2> }
140
+ | { readonly ok: false; readonly reason: string };
141
+
142
+ export interface ProviderEvidenceVerifierV2 {
143
+ verify(
144
+ rawEvidence: unknown,
145
+ expected: Readonly<AdmissionSnapshot>,
146
+ ): Promise<ProviderEvidenceVerificationV2>;
147
+ }
148
+
149
+ export interface ObservedEffectRelationV2 {
150
+ readonly relation:
151
+ | 'OBSERVED_AS_REQUESTED'
152
+ | 'DIVERGED'
153
+ | 'INDETERMINATE';
154
+ readonly evidenceDigest: AdmissionDigestV2 | null;
155
+ readonly tenantId: string;
156
+ readonly admissionId: string;
157
+ readonly operationId: string;
158
+ readonly snapshotDigest: AdmissionDigestV2;
159
+ readonly caid: string;
160
+ readonly actionDigest: AdmissionDigestV2;
161
+ readonly providerEvidenceDigest: AdmissionDigestV2;
162
+ readonly observedEffectDigest: AdmissionDigestV2 | null;
163
+ readonly observedAt: string;
164
+ }
165
+
166
+ export interface ObservedEffectRelatorV2 {
167
+ relate(
168
+ evidence: Readonly<ProviderEvidenceV2>,
169
+ expected: Readonly<AdmissionSnapshot>,
170
+ ): Promise<Readonly<ObservedEffectRelationV2>>;
171
+ }
172
+
173
+ export interface LegacyQualificationV1 {
174
+ qualify(input: Readonly<QualificationCompositionInputV2>): Promise<{
175
+ readonly allow: boolean;
176
+ readonly reasons: readonly string[];
177
+ }>;
178
+ }
179
+
180
+ export type GateQualificationV2Options =
181
+ | {
182
+ readonly mode: 'shadow';
183
+ readonly legacyQualification?: LegacyQualificationV1;
184
+ readonly admissionStore?: never;
185
+ readonly protectedAdapter?: never;
186
+ readonly providerEvidenceVerifier?: never;
187
+ readonly observedEffectRelator?: never;
188
+ readonly invocationRemeasurer?: never;
189
+ readonly authorityCustody?: never;
190
+ readonly adapterTimeoutMs?: never;
191
+ readonly testOnly?: never;
192
+ }
193
+ | {
194
+ readonly mode: 'enforce';
195
+ readonly admissionStore: AdmissionStore;
196
+ readonly protectedAdapter: ProtectedAdapterV2;
197
+ readonly providerEvidenceVerifier: ProviderEvidenceVerifierV2;
198
+ readonly observedEffectRelator: ObservedEffectRelatorV2;
199
+ readonly invocationRemeasurer: InvocationRemeasurerV2;
200
+ readonly authorityCustody: InvocationAuthorityCustodyV2;
201
+ readonly adapterTimeoutMs?: number;
202
+ readonly testOnly?: true;
203
+ readonly legacyQualification?: never;
204
+ };
205
+
206
+ export type GateQualificationExecutionInputV2 = QualificationCompositionInputV2;
207
+
208
+ export interface ShadowComparisonV2 {
209
+ readonly legacyAllowed: boolean | null;
210
+ readonly v2Allowed: boolean;
211
+ readonly match: boolean | null;
212
+ readonly legacyReasons: readonly string[];
213
+ readonly v2Reasons: readonly string[];
214
+ }
215
+
216
+ export type GateQualificationExecutionResultV2 =
217
+ | {
218
+ readonly status: 'shadow';
219
+ readonly decision: Readonly<GateQualificationDecisionV2>;
220
+ readonly comparison: Readonly<ShadowComparisonV2>;
221
+ }
222
+ | {
223
+ readonly status: 'refused';
224
+ readonly reason: string;
225
+ readonly decision?: Readonly<GateQualificationDecisionV2>;
226
+ }
227
+ | {
228
+ readonly status: 'reconciliation_required';
229
+ readonly reason: string;
230
+ readonly admissionId: string;
231
+ }
232
+ | {
233
+ readonly status: 'committed' | 'not_committed';
234
+ readonly admissionId: string;
235
+ readonly evidence: Readonly<ProviderEvidenceV2>;
236
+ readonly relation: Readonly<ObservedEffectRelationV2>;
237
+ };
238
+
239
+ interface InvocationAuthorityV2 {
240
+ ownerToken: string;
241
+ invocationToken: string;
242
+ snapshotDigest: AdmissionDigestV2;
243
+ }
244
+
245
+ /**
246
+ * A protected, restart-safe custody boundary for the capabilities needed to
247
+ * reconcile an invocation whose provider outcome is not yet final.
248
+ */
249
+ export interface InvocationAuthorityCustodyV2 {
250
+ readonly custody: 'protected';
251
+ readonly durable: boolean;
252
+ readonly testOnly?: true;
253
+ put(input: Readonly<{
254
+ tenantId: string;
255
+ admissionId: string;
256
+ authority: Readonly<InvocationAuthorityV2>;
257
+ }>): Promise<void>;
258
+ get(input: Readonly<{
259
+ tenantId: string;
260
+ admissionId: string;
261
+ }>): Promise<Readonly<InvocationAuthorityV2> | null>;
262
+ delete(input: Readonly<{
263
+ tenantId: string;
264
+ admissionId: string;
265
+ }>): Promise<void>;
266
+ }
267
+
268
+ /**
269
+ * Rereads every mutable qualification/authority leg from authoritative
270
+ * sources immediately before the store atomically consumes execution rights.
271
+ */
272
+ export interface InvocationRemeasurerV2 {
273
+ readonly source: 'authoritative';
274
+ remeasure(
275
+ snapshot: Readonly<AdmissionSnapshot>,
276
+ ): Promise<Readonly<GateQualificationBundleV2>>;
277
+ }
278
+
279
+ /** Explicitly test-only custody. Production callers must supply durable KMS-backed custody. */
280
+ export function createMemoryInvocationAuthorityCustodyV2():
281
+ InvocationAuthorityCustodyV2 & { readonly testOnly: true } {
282
+ const values = new Map<string, Readonly<InvocationAuthorityV2>>();
283
+ return {
284
+ custody: 'protected',
285
+ durable: false,
286
+ testOnly: true,
287
+ async put(input) {
288
+ values.set(authorityKey(input.tenantId, input.admissionId), frozenCopy(input.authority));
289
+ },
290
+ async get(input) {
291
+ return values.get(authorityKey(input.tenantId, input.admissionId)) ?? null;
292
+ },
293
+ async delete(input) {
294
+ values.delete(authorityKey(input.tenantId, input.admissionId));
295
+ },
296
+ };
297
+ }
298
+
299
+ type SnapshotValidationV2 =
300
+ | { ok: true; snapshot: Readonly<AdmissionSnapshot> }
301
+ | { ok: false };
302
+
303
+ function isRecord(value: unknown): value is Record<string, unknown> {
304
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
305
+ return false;
306
+ }
307
+ const prototype = Object.getPrototypeOf(value);
308
+ return prototype === Object.prototype || prototype === null;
309
+ }
310
+
311
+ function validString(value: unknown): value is string {
312
+ return typeof value === 'string' && value.length > 0 && value.length <= 512;
313
+ }
314
+
315
+ function validDigest(value: unknown): value is AdmissionDigestV2 {
316
+ return typeof value === 'string' && DIGEST.test(value);
317
+ }
318
+
319
+ function validInstant(value: unknown): value is string {
320
+ return typeof value === 'string' && Number.isFinite(Date.parse(value));
321
+ }
322
+
323
+ function clone<T>(value: T): T {
324
+ return structuredClone(value);
325
+ }
326
+
327
+ function deepFreeze<T>(value: T): Readonly<T> {
328
+ if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
329
+ for (const child of Object.values(value)) deepFreeze(child);
330
+ Object.freeze(value);
331
+ }
332
+ return value;
333
+ }
334
+
335
+ function frozenCopy<T>(value: T): Readonly<T> {
336
+ return deepFreeze(clone(value));
337
+ }
338
+
339
+ function deeplyFrozen(value: unknown, seen = new WeakSet<object>()): boolean {
340
+ if (value === null || typeof value !== 'object') return true;
341
+ if (seen.has(value)) return true;
342
+ seen.add(value);
343
+ if (!Object.isFrozen(value)) return false;
344
+ return Object.values(value).every((child) => deeplyFrozen(child, seen));
345
+ }
346
+
347
+ function addReason(reasons: string[], reason: string): void {
348
+ if (!reasons.includes(reason)) reasons.push(reason);
349
+ }
350
+
351
+ function canonicalSnapshot(value: unknown): SnapshotValidationV2 {
352
+ if (!isRecord(value) || !isRecord(value.body)
353
+ || !validDigest(value.snapshot_digest)) return { ok: false };
354
+ try {
355
+ const normalized = createAdmissionSnapshot(
356
+ value.body as unknown as Parameters<typeof createAdmissionSnapshot>[0],
357
+ );
358
+ if (normalized.snapshot_digest !== value.snapshot_digest) {
359
+ return { ok: false };
360
+ }
361
+ return { ok: true, snapshot: normalized };
362
+ } catch {
363
+ return { ok: false };
364
+ }
365
+ }
366
+
367
+ function actuationKey(snapshot: Readonly<AdmissionSnapshot>): string {
368
+ return JSON.stringify([
369
+ snapshot.body.tenant_id,
370
+ snapshot.body.operation_id,
371
+ ]);
372
+ }
373
+
374
+ function rolePayloads(
375
+ snapshot: Readonly<AdmissionSnapshot>,
376
+ role: AdmissionSnapshot['body']['inputs'][number]['role'],
377
+ ): AdmissionDigestV2[] {
378
+ return snapshot.body.inputs
379
+ .filter((entry) => entry.role === role)
380
+ .map((entry) => entry.payload_digest);
381
+ }
382
+
383
+ function sameDigests(
384
+ left: readonly string[],
385
+ right: readonly string[],
386
+ ): boolean {
387
+ if (left.length !== right.length) return false;
388
+ const a = [...left].sort();
389
+ const b = [...right].sort();
390
+ return a.every((value, index) => value === b[index]);
391
+ }
392
+
393
+ function boundedReason(value: unknown): string {
394
+ return validString(value) ? value : 'unspecified';
395
+ }
396
+
397
+ function validateQualification(
398
+ qualification: Readonly<QualificationDecision> | undefined,
399
+ snapshot: Readonly<AdmissionSnapshot>,
400
+ reasons: string[],
401
+ ): void {
402
+ if (!isRecord(qualification)) {
403
+ addReason(reasons, 'qualification_result_invalid');
404
+ return;
405
+ }
406
+ if (qualification.decision !== 'QUALIFIED') {
407
+ addReason(
408
+ reasons,
409
+ `qualification_not_qualified:${boundedReason(qualification.reason)}`,
410
+ );
411
+ }
412
+ if (qualification.verification !== 'VERIFIED') {
413
+ addReason(reasons, 'qualification_not_verified');
414
+ }
415
+ if (qualification.acceptance !== 'ACCEPTED') {
416
+ addReason(reasons, 'qualification_not_accepted');
417
+ }
418
+ if (qualification.candidate_match !== 'EXACT_MATCH') {
419
+ addReason(reasons, 'qualification_candidate_mismatch');
420
+ }
421
+ if (qualification.assignment_scope !== 'IN_SCOPE') {
422
+ addReason(reasons, 'qualification_assignment_out_of_scope');
423
+ }
424
+ if (qualification.currentness !== 'CURRENT_AS_OBSERVED') {
425
+ addReason(reasons, 'qualification_not_current');
426
+ }
427
+ if (qualification.campaign_graph !== 'COMPLETE') {
428
+ addReason(reasons, 'qualification_campaign_incomplete');
429
+ }
430
+ if (typeof qualification.remeasure_at_begin_invocation !== 'boolean') {
431
+ addReason(reasons, 'qualification_remeasurement_invalid');
432
+ }
433
+ const qualificationChecks = qualification.checks;
434
+ if (!isRecord(qualificationChecks)
435
+ || REQUIRED_QUALIFICATION_CHECKS.some(
436
+ (check) => qualificationChecks[check] !== true,
437
+ )) {
438
+ addReason(reasons, 'qualification_checks_incomplete');
439
+ }
440
+
441
+ const payloads = qualification.payload_digests;
442
+ if (!isRecord(payloads)) {
443
+ addReason(reasons, 'qualification_payload_digests_invalid');
444
+ return;
445
+ }
446
+ if (payloads.candidate_manifest !== snapshot.body.candidate_manifest_digest) {
447
+ addReason(reasons, 'qualification_candidate_manifest_binding_mismatch');
448
+ }
449
+ if (payloads.runtime_measurement !== snapshot.body.runtime_measurement_digest) {
450
+ addReason(reasons, 'qualification_runtime_measurement_binding_mismatch');
451
+ }
452
+ if (payloads.protected_request_digest
453
+ !== snapshot.body.effect_request_digest) {
454
+ addReason(reasons, 'qualification_protected_request_binding_mismatch');
455
+ }
456
+ if (payloads.qualification_statement
457
+ !== snapshot.body.qualification_statement_payload_digest) {
458
+ addReason(reasons, 'qualification_statement_binding_mismatch');
459
+ }
460
+ if (payloads.qualification_status_head
461
+ !== snapshot.body.qualification_status.head_payload_digest) {
462
+ addReason(reasons, 'qualification_status_binding_mismatch');
463
+ }
464
+ if (!validDigest(payloads.campaign_head)
465
+ || !validDigest(payloads.qualification_graph)) {
466
+ addReason(reasons, 'qualification_graph_binding_invalid');
467
+ }
468
+ }
469
+
470
+ function validateRequirement(
471
+ name: 'aeb' | 'aec',
472
+ leg: Readonly<BoundRequirementDecisionV2> | undefined,
473
+ snapshot: Readonly<AdmissionSnapshot>,
474
+ reasons: string[],
475
+ ): void {
476
+ if (!isRecord(leg)) {
477
+ addReason(reasons, `${name}_missing`);
478
+ return;
479
+ }
480
+ if (leg.decision !== 'allow') addReason(reasons, `${name}_denied`);
481
+ if (!validString(leg.requirementId)) {
482
+ addReason(reasons, `${name}_requirement_missing`);
483
+ }
484
+ if (leg.caid !== snapshot.body.caid
485
+ || leg.actionDigest !== snapshot.body.action_digest) {
486
+ addReason(reasons, `${name}_binding_mismatch`);
487
+ }
488
+ if (!validDigest(leg.evidenceDigest)) {
489
+ addReason(reasons, `${name}_evidence_invalid`);
490
+ } else if (!sameDigests(rolePayloads(snapshot, name), [leg.evidenceDigest])) {
491
+ addReason(reasons, `${name}_snapshot_binding_mismatch`);
492
+ }
493
+ }
494
+
495
+ function validateLocalPolicy(
496
+ leg: Readonly<BoundLocalPolicyDecisionV2> | undefined,
497
+ snapshot: Readonly<AdmissionSnapshot>,
498
+ reasons: string[],
499
+ ): void {
500
+ if (!isRecord(leg)) {
501
+ addReason(reasons, 'localPolicy_missing');
502
+ return;
503
+ }
504
+ if (leg.decision !== 'allow') addReason(reasons, 'localPolicy_denied');
505
+ if (!validString(leg.policyId)) {
506
+ addReason(reasons, 'localPolicy_policy_missing');
507
+ }
508
+ if (leg.caid !== snapshot.body.caid
509
+ || leg.actionDigest !== snapshot.body.action_digest) {
510
+ addReason(reasons, 'localPolicy_binding_mismatch');
511
+ }
512
+ if (!validDigest(leg.evidenceDigest)) {
513
+ addReason(reasons, 'localPolicy_evidence_invalid');
514
+ } else if (!sameDigests(
515
+ rolePayloads(snapshot, 'local_policy'),
516
+ [leg.evidenceDigest],
517
+ )) {
518
+ addReason(reasons, 'localPolicy_snapshot_binding_mismatch');
519
+ }
520
+ }
521
+
522
+ function validateSnapshotBindings(
523
+ snapshot: Readonly<AdmissionSnapshot>,
524
+ reasons: string[],
525
+ ): void {
526
+ const body = snapshot.body;
527
+ const singletonBindings = [
528
+ ['candidate_manifest', body.candidate_manifest_digest],
529
+ ['runtime_measurement', body.runtime_measurement_digest],
530
+ ['qualification_statement', body.qualification_statement_payload_digest],
531
+ ['qualification_status', body.qualification_status.head_payload_digest],
532
+ ] as const;
533
+ for (const [role, expected] of singletonBindings) {
534
+ if (!sameDigests(rolePayloads(snapshot, role), [expected])) {
535
+ addReason(reasons, `snapshot_${role}_binding_mismatch`);
536
+ }
537
+ }
538
+ if (!sameDigests(
539
+ rolePayloads(snapshot, 'test_result'),
540
+ body.test_result_payload_digests,
541
+ )) addReason(reasons, 'snapshot_test_result_binding_mismatch');
542
+ if (!sameDigests(
543
+ rolePayloads(snapshot, 'agent_evaluation_evidence'),
544
+ body.agent_evaluation_evidence_payload_digests,
545
+ )) addReason(reasons, 'snapshot_agent_evidence_binding_mismatch');
546
+
547
+ if (body.candidate_custody.request_construction !== 'EXECUTOR_ADAPTER'
548
+ || body.candidate_custody.mutation_credential_custody
549
+ !== 'EXECUTOR_ADAPTER') {
550
+ addReason(reasons, 'snapshot_adapter_custody_mismatch');
551
+ }
552
+ const providerOperations = body.resource_reservations.filter(
553
+ (resource) => resource.kind === 'provider_operation'
554
+ && resource.resource_id === body.operation_id,
555
+ );
556
+ if (providerOperations.length !== 1) {
557
+ addReason(reasons, 'snapshot_provider_operation_binding_mismatch');
558
+ }
559
+ if (body.supersedes_admission_id !== null) {
560
+ addReason(reasons, 'supersession_requires_atomic_store_transition');
561
+ }
562
+ if (body.remedy_for !== null) {
563
+ if (body.remedy_for.admission_id === body.admission_id) {
564
+ addReason(reasons, 'remedy_requires_new_admission');
565
+ }
566
+ if (body.remedy_for.operation_id === body.operation_id) {
567
+ addReason(reasons, 'remedy_requires_new_operation');
568
+ }
569
+ }
570
+ }
571
+
572
+ function emptyDecision(reasons: readonly string[]): GateQualificationDecisionV2 {
573
+ return deepFreeze({
574
+ version: GATE_QUALIFICATION_V2_VERSION,
575
+ allow: false,
576
+ reasons: deepFreeze([...reasons]),
577
+ tenantId: '',
578
+ admissionId: '',
579
+ operationId: '',
580
+ caid: '',
581
+ actionDigest: '',
582
+ snapshotDigest: '',
583
+ effectKey: '',
584
+ requirements: deepFreeze({
585
+ qualificationEvidenceDigest: '',
586
+ aebRequirementId: '',
587
+ aebEvidenceDigest: '',
588
+ aecRequirementId: '',
589
+ aecEvidenceDigest: '',
590
+ localPolicyId: '',
591
+ localPolicyEvidenceDigest: '',
592
+ }),
593
+ });
594
+ }
595
+
596
+ function composeCanonical(
597
+ snapshot: Readonly<AdmissionSnapshot>,
598
+ qualification: Readonly<GateQualificationBundleV2> | undefined,
599
+ ): Readonly<GateQualificationDecisionV2> {
600
+ const reasons: string[] = [];
601
+ validateSnapshotBindings(snapshot, reasons);
602
+ validateQualification(qualification?.qualification, snapshot, reasons);
603
+ validateRequirement('aeb', qualification?.aeb, snapshot, reasons);
604
+ validateRequirement('aec', qualification?.aec, snapshot, reasons);
605
+ validateLocalPolicy(qualification?.localPolicy, snapshot, reasons);
606
+ return deepFreeze({
607
+ version: GATE_QUALIFICATION_V2_VERSION,
608
+ allow: reasons.length === 0,
609
+ reasons: deepFreeze([...reasons]),
610
+ tenantId: snapshot.body.tenant_id,
611
+ admissionId: snapshot.body.admission_id,
612
+ operationId: snapshot.body.operation_id,
613
+ caid: snapshot.body.caid,
614
+ actionDigest: snapshot.body.action_digest,
615
+ snapshotDigest: snapshot.snapshot_digest,
616
+ effectKey: actuationKey(snapshot),
617
+ requirements: deepFreeze({
618
+ qualificationEvidenceDigest:
619
+ snapshot.body.qualification_statement_payload_digest,
620
+ aebRequirementId: qualification?.aeb?.requirementId ?? '',
621
+ aebEvidenceDigest: qualification?.aeb?.evidenceDigest ?? '',
622
+ aecRequirementId: qualification?.aec?.requirementId ?? '',
623
+ aecEvidenceDigest: qualification?.aec?.evidenceDigest ?? '',
624
+ localPolicyId: qualification?.localPolicy?.policyId ?? '',
625
+ localPolicyEvidenceDigest:
626
+ qualification?.localPolicy?.evidenceDigest ?? '',
627
+ }),
628
+ });
629
+ }
630
+
631
+ /** Pure deterministic composition; it performs no store or adapter access. */
632
+ export function composeQualificationDecisionV2(
633
+ input: QualificationCompositionInputV2,
634
+ ): Readonly<GateQualificationDecisionV2> {
635
+ const checked = canonicalSnapshot(input?.snapshot);
636
+ if (!checked.ok) return emptyDecision(['admission_snapshot_invalid']);
637
+ return composeCanonical(checked.snapshot, input?.qualification);
638
+ }
639
+
640
+ function verifyStoreCapabilities(store: AdmissionStore, testOnly: boolean): void {
641
+ if (!store || store.atomic !== true || store.compareAndSwap !== true
642
+ || store.appendOnlyJournal !== true
643
+ || store.exclusiveActuation !== true) {
644
+ throw new TypeError(
645
+ 'Gate Qualification v2 requires an atomic, compare-and-swap, '
646
+ + 'append-only, exclusive AdmissionStore',
647
+ );
648
+ }
649
+ if (store.transactionalCurrentness !== true) {
650
+ throw new TypeError(
651
+ 'Gate Qualification v2 requires a transactional-currentness AdmissionStore',
652
+ );
653
+ }
654
+ const candidate = store as unknown as Record<string, unknown>;
655
+ for (const method of [
656
+ 'reserve',
657
+ 'release',
658
+ 'beginInvocation',
659
+ 'recoverIndeterminate',
660
+ 'recordProviderOutcome',
661
+ 'recordEffectRelation',
662
+ 'read',
663
+ 'readByOperation',
664
+ 'readSnapshot',
665
+ ]) {
666
+ if (typeof candidate[method] !== 'function') {
667
+ throw new TypeError(
668
+ `Gate Qualification v2 AdmissionStore requires ${method}()`,
669
+ );
670
+ }
671
+ }
672
+ if (testOnly) {
673
+ if (store.testOnly !== true) {
674
+ throw new TypeError(
675
+ 'Gate Qualification v2 testOnly requires an explicit test store',
676
+ );
677
+ }
678
+ } else if (store.durable !== true || store.testOnly === true) {
679
+ throw new TypeError(
680
+ 'Gate Qualification v2 production mode requires a durable AdmissionStore',
681
+ );
682
+ }
683
+ }
684
+
685
+ function protectedInvocation(
686
+ snapshot: Readonly<AdmissionSnapshot>,
687
+ invocationToken: string,
688
+ ): Readonly<ProtectedInvocationV2> {
689
+ return deepFreeze({ snapshot, invocationToken });
690
+ }
691
+
692
+ function providerEvidenceBindingValid(
693
+ evidence: Readonly<ProviderEvidenceV2>,
694
+ snapshot: Readonly<AdmissionSnapshot>,
695
+ ): boolean {
696
+ const body = snapshot.body;
697
+ return isRecord(evidence)
698
+ && validString(evidence.evidenceId)
699
+ && validDigest(evidence.evidenceDigest)
700
+ && evidence.tenantId === body.tenant_id
701
+ && evidence.admissionId === body.admission_id
702
+ && evidence.operationId === body.operation_id
703
+ && evidence.snapshotDigest === snapshot.snapshot_digest
704
+ && evidence.caid === body.caid
705
+ && evidence.actionDigest === body.action_digest
706
+ && evidence.effectRequestDigest === body.effect_request_digest
707
+ && isRecord(evidence.provider)
708
+ && evidence.provider.provider_id === body.provider.provider_id
709
+ && evidence.provider.account_id === body.provider.account_id
710
+ && evidence.provider.environment === body.provider.environment
711
+ && evidence.executorAdapterDigest === body.executor_adapter_digest
712
+ && evidence.idempotencyKey === body.idempotency_key
713
+ && ['COMMITTED', 'PROVEN_NOT_COMMITTED', 'INDETERMINATE'].includes(
714
+ evidence.outcome,
715
+ )
716
+ && validInstant(evidence.observedAt);
717
+ }
718
+
719
+ function observedRelationBindingValid(
720
+ relation: Readonly<ObservedEffectRelationV2>,
721
+ evidence: Readonly<ProviderEvidenceV2>,
722
+ snapshot: Readonly<AdmissionSnapshot>,
723
+ ): boolean {
724
+ const body = snapshot.body;
725
+ if (!isRecord(relation)
726
+ || !['OBSERVED_AS_REQUESTED', 'DIVERGED', 'INDETERMINATE'].includes(
727
+ relation.relation,
728
+ )
729
+ || relation.tenantId !== body.tenant_id
730
+ || relation.admissionId !== body.admission_id
731
+ || relation.operationId !== body.operation_id
732
+ || relation.snapshotDigest !== snapshot.snapshot_digest
733
+ || relation.caid !== body.caid
734
+ || relation.actionDigest !== body.action_digest
735
+ || relation.providerEvidenceDigest !== evidence.evidenceDigest
736
+ || !validInstant(relation.observedAt)
737
+ || (relation.evidenceDigest !== null
738
+ && !validDigest(relation.evidenceDigest))
739
+ || (relation.observedEffectDigest !== null
740
+ && !validDigest(relation.observedEffectDigest))) return false;
741
+ if (relation.relation === 'INDETERMINATE') return true;
742
+ return validDigest(relation.evidenceDigest)
743
+ && validDigest(relation.observedEffectDigest);
744
+ }
745
+
746
+ class AdapterTimeoutError extends Error {
747
+ constructor() {
748
+ super('protected adapter timed out');
749
+ this.name = 'AdapterTimeoutError';
750
+ }
751
+ }
752
+
753
+ async function withinTimeout<T>(
754
+ operation: () => Promise<T>,
755
+ timeoutMs: number,
756
+ ): Promise<T> {
757
+ let timer: ReturnType<typeof setTimeout> | undefined;
758
+ try {
759
+ return await Promise.race([
760
+ Promise.resolve().then(operation),
761
+ new Promise<T>((_resolve, reject) => {
762
+ timer = setTimeout(() => reject(new AdapterTimeoutError()), timeoutMs);
763
+ }),
764
+ ]);
765
+ } finally {
766
+ if (timer !== undefined) clearTimeout(timer);
767
+ }
768
+ }
769
+
770
+ function reference(snapshot: Readonly<AdmissionSnapshot>) {
771
+ return {
772
+ tenant_id: snapshot.body.tenant_id,
773
+ admission_id: snapshot.body.admission_id,
774
+ };
775
+ }
776
+
777
+ function authorityKey(tenantId: string, admissionId: string): string {
778
+ return JSON.stringify([tenantId, admissionId]);
779
+ }
780
+
781
+ function reconciliationRequired(
782
+ admissionId: string,
783
+ reason: string,
784
+ ): Readonly<GateQualificationExecutionResultV2> {
785
+ return deepFreeze({ status: 'reconciliation_required', reason, admissionId });
786
+ }
787
+
788
+ export class GateQualificationV2 {
789
+ readonly mode: QualificationModeV2;
790
+ readonly #store?: AdmissionStore;
791
+ readonly #adapter?: ProtectedAdapterV2;
792
+ readonly #evidenceVerifier?: ProviderEvidenceVerifierV2;
793
+ readonly #effectRelator?: ObservedEffectRelatorV2;
794
+ readonly #remeasurer?: InvocationRemeasurerV2;
795
+ readonly #authorityCustody?: InvocationAuthorityCustodyV2;
796
+ readonly #legacy?: LegacyQualificationV1;
797
+ readonly #adapterTimeoutMs: number;
798
+
799
+ constructor(options: GateQualificationV2Options) {
800
+ if (!options || !['enforce', 'shadow'].includes(options.mode)) {
801
+ throw new TypeError(
802
+ 'Gate Qualification v2 mode must be enforce or shadow',
803
+ );
804
+ }
805
+ if (options.mode === 'shadow') {
806
+ if ('admissionStore' in options || 'protectedAdapter' in options
807
+ || 'providerEvidenceVerifier' in options
808
+ || 'observedEffectRelator' in options
809
+ || 'invocationRemeasurer' in options
810
+ || 'authorityCustody' in options) {
811
+ throw new TypeError(
812
+ 'Gate Qualification v2 shadow mode accepts no AdmissionStore or protected adapter',
813
+ );
814
+ }
815
+ if (options.legacyQualification
816
+ && typeof options.legacyQualification.qualify !== 'function') {
817
+ throw new TypeError(
818
+ 'Gate Qualification v2 legacy qualification must expose qualify()',
819
+ );
820
+ }
821
+ this.mode = 'shadow';
822
+ this.#legacy = options.legacyQualification;
823
+ this.#adapterTimeoutMs = DEFAULT_ADAPTER_TIMEOUT_MS;
824
+ return;
825
+ }
826
+
827
+ verifyStoreCapabilities(options.admissionStore, options.testOnly === true);
828
+ if (!options.protectedAdapter
829
+ || options.protectedAdapter.custody !== 'protected'
830
+ || options.protectedAdapter.credentialsExposed !== false
831
+ || typeof options.protectedAdapter.invoke !== 'function'
832
+ || typeof options.protectedAdapter.reconcile !== 'function') {
833
+ throw new TypeError(
834
+ 'Gate Qualification v2 requires protected adapter custody',
835
+ );
836
+ }
837
+ if (!options.providerEvidenceVerifier
838
+ || typeof options.providerEvidenceVerifier.verify !== 'function') {
839
+ throw new TypeError(
840
+ 'Gate Qualification v2 requires a provider evidence verifier',
841
+ );
842
+ }
843
+ if (!options.observedEffectRelator
844
+ || typeof options.observedEffectRelator.relate !== 'function') {
845
+ throw new TypeError(
846
+ 'Gate Qualification v2 requires an observed-effect relator',
847
+ );
848
+ }
849
+ if (!options.invocationRemeasurer
850
+ || options.invocationRemeasurer.source !== 'authoritative'
851
+ || typeof options.invocationRemeasurer.remeasure !== 'function') {
852
+ throw new TypeError(
853
+ 'Gate Qualification v2 requires authoritative invocation remeasurement',
854
+ );
855
+ }
856
+ if (!options.authorityCustody
857
+ || options.authorityCustody.custody !== 'protected'
858
+ || typeof options.authorityCustody.put !== 'function'
859
+ || typeof options.authorityCustody.get !== 'function'
860
+ || typeof options.authorityCustody.delete !== 'function') {
861
+ throw new TypeError(
862
+ 'Gate Qualification v2 requires protected invocation-authority custody',
863
+ );
864
+ }
865
+ if (options.testOnly === true) {
866
+ if (options.authorityCustody.testOnly !== true) {
867
+ throw new TypeError(
868
+ 'Gate Qualification v2 testOnly requires explicit test authority custody',
869
+ );
870
+ }
871
+ } else if (options.authorityCustody.durable !== true
872
+ || options.authorityCustody.testOnly === true) {
873
+ throw new TypeError(
874
+ 'Gate Qualification v2 production mode requires durable authority custody',
875
+ );
876
+ }
877
+ const timeout = options.adapterTimeoutMs ?? DEFAULT_ADAPTER_TIMEOUT_MS;
878
+ if (!Number.isSafeInteger(timeout) || timeout < 1
879
+ || timeout > MAX_ADAPTER_TIMEOUT_MS) {
880
+ throw new TypeError(
881
+ 'Gate Qualification v2 adapterTimeoutMs is invalid',
882
+ );
883
+ }
884
+ this.mode = 'enforce';
885
+ this.#store = options.admissionStore;
886
+ this.#adapter = options.protectedAdapter;
887
+ this.#evidenceVerifier = options.providerEvidenceVerifier;
888
+ this.#effectRelator = options.observedEffectRelator;
889
+ this.#remeasurer = options.invocationRemeasurer;
890
+ this.#authorityCustody = options.authorityCustody;
891
+ this.#adapterTimeoutMs = timeout;
892
+ }
893
+
894
+ async #shadow(
895
+ input: GateQualificationExecutionInputV2,
896
+ decision: Readonly<GateQualificationDecisionV2>,
897
+ ): Promise<GateQualificationExecutionResultV2> {
898
+ let legacyAllowed: boolean | null = null;
899
+ let legacyReasons: readonly string[] = [];
900
+ if (this.#legacy) {
901
+ try {
902
+ const legacy = await this.#legacy.qualify(frozenCopy(input));
903
+ legacyAllowed = legacy.allow === true;
904
+ legacyReasons = deepFreeze([...legacy.reasons]);
905
+ } catch {
906
+ legacyAllowed = false;
907
+ legacyReasons = deepFreeze(['legacy_qualification_failed']);
908
+ }
909
+ }
910
+ return deepFreeze({
911
+ status: 'shadow',
912
+ decision,
913
+ comparison: {
914
+ legacyAllowed,
915
+ v2Allowed: decision.allow,
916
+ match: legacyAllowed === null
917
+ ? null
918
+ : legacyAllowed === decision.allow,
919
+ legacyReasons,
920
+ v2Reasons: decision.reasons,
921
+ },
922
+ });
923
+ }
924
+
925
+ async #saveAuthority(
926
+ snapshot: Readonly<AdmissionSnapshot>,
927
+ authority: InvocationAuthorityV2,
928
+ ): Promise<void> {
929
+ await this.#authorityCustody!.put({
930
+ tenantId: snapshot.body.tenant_id,
931
+ admissionId: snapshot.body.admission_id,
932
+ authority: frozenCopy(authority),
933
+ });
934
+ }
935
+
936
+ async #getAuthority(
937
+ tenantId: string,
938
+ admissionId: string,
939
+ ): Promise<Readonly<InvocationAuthorityV2> | null> {
940
+ return this.#authorityCustody!.get({ tenantId, admissionId });
941
+ }
942
+
943
+ async #recordIndeterminateEffect(
944
+ snapshot: Readonly<AdmissionSnapshot>,
945
+ authority: InvocationAuthorityV2,
946
+ expectedRevision: number,
947
+ ): Promise<StoreRecordV2 | null> {
948
+ try {
949
+ const recorded = await this.#store!.recordEffectRelation({
950
+ ...reference(snapshot),
951
+ expected_revision: expectedRevision,
952
+ owner_token: authority.ownerToken,
953
+ invocation_token: authority.invocationToken,
954
+ value: 'INDETERMINATE',
955
+ evidence_digest: null,
956
+ observed_at: new Date().toISOString(),
957
+ });
958
+ return recorded.ok ? recorded.record : null;
959
+ } catch {
960
+ return null;
961
+ }
962
+ }
963
+
964
+ async #recoverInvoking(
965
+ snapshot: Readonly<AdmissionSnapshot>,
966
+ ownerToken: string,
967
+ ): Promise<InvocationAuthorityV2 | null> {
968
+ try {
969
+ const recovered = await this.#store!.recoverIndeterminate({
970
+ ...reference(snapshot),
971
+ owner_token: ownerToken,
972
+ });
973
+ if (!recovered.ok) return null;
974
+ const authority: InvocationAuthorityV2 = {
975
+ ownerToken,
976
+ invocationToken: recovered.reconciliation_token,
977
+ snapshotDigest: snapshot.snapshot_digest,
978
+ };
979
+ await this.#saveAuthority(snapshot, authority);
980
+ await this.#recordIndeterminateEffect(
981
+ snapshot,
982
+ authority,
983
+ recovered.record.revision,
984
+ );
985
+ return authority;
986
+ } catch {
987
+ return null;
988
+ }
989
+ }
990
+
991
+ async #beginAmbiguity(
992
+ snapshot: Readonly<AdmissionSnapshot>,
993
+ ownerToken: string,
994
+ ): Promise<GateQualificationExecutionResultV2> {
995
+ let record: StoreRecordV2 | null;
996
+ try {
997
+ record = await this.#store!.read(reference(snapshot));
998
+ } catch {
999
+ return reconciliationRequired(
1000
+ snapshot.body.admission_id,
1001
+ 'begin_invocation_read_ambiguous',
1002
+ );
1003
+ }
1004
+ if (!record) {
1005
+ return reconciliationRequired(
1006
+ snapshot.body.admission_id,
1007
+ 'begin_invocation_read_ambiguous',
1008
+ );
1009
+ }
1010
+ if (record.state === 'INVOKING') {
1011
+ await this.#recoverInvoking(snapshot, ownerToken);
1012
+ }
1013
+ return reconciliationRequired(
1014
+ snapshot.body.admission_id,
1015
+ 'begin_invocation_unconfirmed',
1016
+ );
1017
+ }
1018
+
1019
+ async #existingAdmissionResult(
1020
+ snapshot: Readonly<AdmissionSnapshot>,
1021
+ storeReason: string,
1022
+ decision: Readonly<GateQualificationDecisionV2>,
1023
+ ): Promise<GateQualificationExecutionResultV2> {
1024
+ if (storeReason !== 'admission_exists') {
1025
+ return deepFreeze({ status: 'refused', reason: storeReason, decision });
1026
+ }
1027
+ try {
1028
+ const record = await this.#store!.read(reference(snapshot));
1029
+ if (record && (record.state === 'RESERVED'
1030
+ || record.state === 'INVOKING'
1031
+ || record.state === 'INDETERMINATE')) {
1032
+ return reconciliationRequired(record.admission_id, storeReason);
1033
+ }
1034
+ } catch {
1035
+ return reconciliationRequired(
1036
+ snapshot.body.admission_id,
1037
+ 'admission_read_ambiguous',
1038
+ );
1039
+ }
1040
+ return deepFreeze({ status: 'refused', reason: storeReason, decision });
1041
+ }
1042
+
1043
+ async #verifyProviderEvidence(
1044
+ rawEvidence: unknown,
1045
+ snapshot: Readonly<AdmissionSnapshot>,
1046
+ ): Promise<
1047
+ | { ok: true; evidence: Readonly<ProviderEvidenceV2> }
1048
+ | { ok: false; reason: string }
1049
+ > {
1050
+ let verification: ProviderEvidenceVerificationV2;
1051
+ try {
1052
+ verification = await withinTimeout(
1053
+ () => this.#evidenceVerifier!.verify(rawEvidence, snapshot),
1054
+ this.#adapterTimeoutMs,
1055
+ );
1056
+ } catch {
1057
+ return { ok: false, reason: 'provider_evidence_verification_failed' };
1058
+ }
1059
+ if (!verification.ok) {
1060
+ return {
1061
+ ok: false,
1062
+ reason: `provider_evidence_verification_failed:${verification.reason}`,
1063
+ };
1064
+ }
1065
+ const evidence = frozenCopy(verification.evidence);
1066
+ if (!providerEvidenceBindingValid(evidence, snapshot)) {
1067
+ return { ok: false, reason: 'provider_evidence_binding_mismatch' };
1068
+ }
1069
+ return { ok: true, evidence };
1070
+ }
1071
+
1072
+ async #relateEffect(
1073
+ evidence: Readonly<ProviderEvidenceV2>,
1074
+ snapshot: Readonly<AdmissionSnapshot>,
1075
+ ): Promise<
1076
+ | { ok: true; relation: Readonly<ObservedEffectRelationV2> }
1077
+ | { ok: false; reason: string }
1078
+ > {
1079
+ let relation: Readonly<ObservedEffectRelationV2>;
1080
+ try {
1081
+ relation = frozenCopy(await withinTimeout(
1082
+ () => this.#effectRelator!.relate(evidence, snapshot),
1083
+ this.#adapterTimeoutMs,
1084
+ ));
1085
+ } catch {
1086
+ return { ok: false, reason: 'observed_effect_relation_failed' };
1087
+ }
1088
+ if (!observedRelationBindingValid(relation, evidence, snapshot)) {
1089
+ return { ok: false, reason: 'observed_effect_relation_mismatch' };
1090
+ }
1091
+ return { ok: true, relation };
1092
+ }
1093
+
1094
+ async #processEvidence(
1095
+ rawEvidence: unknown,
1096
+ snapshot: Readonly<AdmissionSnapshot>,
1097
+ authority: InvocationAuthorityV2,
1098
+ record: StoreRecordV2,
1099
+ ): Promise<GateQualificationExecutionResultV2> {
1100
+ const verified = await this.#verifyProviderEvidence(rawEvidence, snapshot);
1101
+ if (!verified.ok) {
1102
+ if (record.state === 'INVOKING') {
1103
+ await this.#recoverInvoking(snapshot, authority.ownerToken);
1104
+ }
1105
+ return reconciliationRequired(snapshot.body.admission_id, verified.reason);
1106
+ }
1107
+
1108
+ let providerRecord: StoreRecordV2;
1109
+ const currentProvider = record.provider_outcome;
1110
+ if (currentProvider && currentProvider.value !== 'INDETERMINATE') {
1111
+ if (currentProvider.value !== verified.evidence.outcome
1112
+ || currentProvider.evidence_digest
1113
+ !== verified.evidence.evidenceDigest) {
1114
+ return reconciliationRequired(
1115
+ snapshot.body.admission_id,
1116
+ 'provider_outcome_conflict',
1117
+ );
1118
+ }
1119
+ providerRecord = record;
1120
+ } else if (currentProvider?.value === 'INDETERMINATE'
1121
+ && verified.evidence.outcome === 'INDETERMINATE') {
1122
+ providerRecord = record;
1123
+ } else {
1124
+ try {
1125
+ const recorded = await this.#store!.recordProviderOutcome({
1126
+ ...reference(snapshot),
1127
+ expected_revision: record.revision,
1128
+ owner_token: authority.ownerToken,
1129
+ invocation_token: authority.invocationToken,
1130
+ value: verified.evidence.outcome,
1131
+ evidence_digest: verified.evidence.evidenceDigest,
1132
+ observed_at: verified.evidence.observedAt,
1133
+ });
1134
+ if (!recorded.ok) {
1135
+ return reconciliationRequired(
1136
+ snapshot.body.admission_id,
1137
+ `provider_outcome_unconfirmed:${recorded.reason}`,
1138
+ );
1139
+ }
1140
+ providerRecord = recorded.record;
1141
+ } catch {
1142
+ return reconciliationRequired(
1143
+ snapshot.body.admission_id,
1144
+ 'provider_outcome_unconfirmed',
1145
+ );
1146
+ }
1147
+ }
1148
+
1149
+ const related = await this.#relateEffect(verified.evidence, snapshot);
1150
+ if (!related.ok) {
1151
+ await this.#recordIndeterminateEffect(
1152
+ snapshot,
1153
+ authority,
1154
+ providerRecord.revision,
1155
+ );
1156
+ return reconciliationRequired(snapshot.body.admission_id, related.reason);
1157
+ }
1158
+
1159
+ let effectRecord: StoreRecordV2;
1160
+ const currentEffect = providerRecord.effect_relation;
1161
+ if (currentEffect && currentEffect.value !== 'INDETERMINATE') {
1162
+ if (currentEffect.value !== related.relation.relation
1163
+ || currentEffect.evidence_digest !== related.relation.evidenceDigest) {
1164
+ return reconciliationRequired(
1165
+ snapshot.body.admission_id,
1166
+ 'effect_relation_conflict',
1167
+ );
1168
+ }
1169
+ effectRecord = providerRecord;
1170
+ } else if (currentEffect?.value === 'INDETERMINATE'
1171
+ && related.relation.relation === 'INDETERMINATE') {
1172
+ effectRecord = providerRecord;
1173
+ } else {
1174
+ try {
1175
+ const recorded = await this.#store!.recordEffectRelation({
1176
+ ...reference(snapshot),
1177
+ expected_revision: providerRecord.revision,
1178
+ owner_token: authority.ownerToken,
1179
+ invocation_token: authority.invocationToken,
1180
+ value: related.relation.relation,
1181
+ evidence_digest: related.relation.evidenceDigest,
1182
+ observed_at: related.relation.observedAt,
1183
+ });
1184
+ if (!recorded.ok) {
1185
+ return reconciliationRequired(
1186
+ snapshot.body.admission_id,
1187
+ `effect_relation_unconfirmed:${recorded.reason}`,
1188
+ );
1189
+ }
1190
+ effectRecord = recorded.record;
1191
+ } catch {
1192
+ return reconciliationRequired(
1193
+ snapshot.body.admission_id,
1194
+ 'effect_relation_unconfirmed',
1195
+ );
1196
+ }
1197
+ }
1198
+
1199
+ if (verified.evidence.outcome === 'INDETERMINATE'
1200
+ || related.relation.relation === 'INDETERMINATE'
1201
+ || effectRecord.provider_outcome?.value === 'INDETERMINATE'
1202
+ || effectRecord.effect_relation?.value === 'INDETERMINATE') {
1203
+ return reconciliationRequired(
1204
+ snapshot.body.admission_id,
1205
+ 'provider_or_effect_indeterminate',
1206
+ );
1207
+ }
1208
+ await this.#authorityCustody!.delete({
1209
+ tenantId: snapshot.body.tenant_id,
1210
+ admissionId: snapshot.body.admission_id,
1211
+ });
1212
+ return deepFreeze({
1213
+ status: verified.evidence.outcome === 'COMMITTED'
1214
+ ? 'committed'
1215
+ : 'not_committed',
1216
+ admissionId: snapshot.body.admission_id,
1217
+ evidence: verified.evidence,
1218
+ relation: related.relation,
1219
+ });
1220
+ }
1221
+
1222
+ async execute(
1223
+ input: GateQualificationExecutionInputV2,
1224
+ ): Promise<GateQualificationExecutionResultV2> {
1225
+ const checked = canonicalSnapshot(input?.snapshot);
1226
+ const decision = checked.ok
1227
+ ? composeCanonical(checked.snapshot, input?.qualification)
1228
+ : emptyDecision(['admission_snapshot_invalid']);
1229
+ if (this.mode === 'shadow') return this.#shadow(input, decision);
1230
+ if (!checked.ok || !decision.allow) {
1231
+ return deepFreeze({
1232
+ status: 'refused',
1233
+ reason: decision.reasons[0] ?? 'qualification_refused',
1234
+ decision,
1235
+ });
1236
+ }
1237
+
1238
+ const snapshot = checked.snapshot;
1239
+ let reserved: Awaited<ReturnType<AdmissionStore['reserve']>>;
1240
+ try {
1241
+ reserved = await this.#store!.reserve(snapshot);
1242
+ } catch {
1243
+ return deepFreeze({
1244
+ status: 'refused',
1245
+ reason: 'admission_reserve_failed',
1246
+ decision,
1247
+ });
1248
+ }
1249
+ if (!reserved.ok) {
1250
+ return this.#existingAdmissionResult(snapshot, reserved.reason, decision);
1251
+ }
1252
+
1253
+ const reservedSnapshot = canonicalSnapshot(reserved.snapshot);
1254
+ if (!reservedSnapshot.ok || !deeplyFrozen(reserved.snapshot)
1255
+ || reserved.snapshot.snapshot_digest !== snapshot.snapshot_digest
1256
+ || reserved.record.snapshot_digest !== snapshot.snapshot_digest) {
1257
+ try {
1258
+ await this.#store!.release({
1259
+ ...reference(snapshot),
1260
+ expected_revision: reserved.record.revision,
1261
+ owner_token: reserved.owner_token,
1262
+ }, 'reserve_snapshot_mismatch');
1263
+ } catch {
1264
+ // No provider entry occurred; the reservation remains closed.
1265
+ }
1266
+ return deepFreeze({
1267
+ status: 'refused',
1268
+ reason: 'reserve_snapshot_mismatch',
1269
+ decision,
1270
+ });
1271
+ }
1272
+
1273
+ // Reread the candidate, qualification status, AEB, AEC, local policy and
1274
+ // protected-request binding from authoritative sources immediately before
1275
+ // the store's transactional currentness check consumes execution rights.
1276
+ // The caller-supplied decision is never reused across this boundary.
1277
+ let refreshedBundle: Readonly<GateQualificationBundleV2>;
1278
+ try {
1279
+ refreshedBundle = await withinTimeout(
1280
+ () => this.#remeasurer!.remeasure(reserved.snapshot),
1281
+ this.#adapterTimeoutMs,
1282
+ );
1283
+ } catch {
1284
+ try {
1285
+ await this.#store!.release({
1286
+ ...reference(reserved.snapshot),
1287
+ expected_revision: reserved.record.revision,
1288
+ owner_token: reserved.owner_token,
1289
+ }, 'invocation_remeasurement_failed');
1290
+ } catch {
1291
+ // No execution right was consumed and no provider entry occurred.
1292
+ }
1293
+ return deepFreeze({
1294
+ status: 'refused',
1295
+ reason: 'invocation_remeasurement_failed',
1296
+ decision,
1297
+ });
1298
+ }
1299
+ const refreshedDecision = composeCanonical(
1300
+ reserved.snapshot,
1301
+ frozenCopy(refreshedBundle),
1302
+ );
1303
+ if (!refreshedDecision.allow) {
1304
+ try {
1305
+ await this.#store!.release({
1306
+ ...reference(reserved.snapshot),
1307
+ expected_revision: reserved.record.revision,
1308
+ owner_token: reserved.owner_token,
1309
+ }, 'invocation_remeasurement_refused');
1310
+ } catch {
1311
+ // No execution right was consumed and no provider entry occurred.
1312
+ }
1313
+ return deepFreeze({
1314
+ status: 'refused',
1315
+ reason: refreshedDecision.reasons[0]
1316
+ ?? 'invocation_remeasurement_refused',
1317
+ decision: refreshedDecision,
1318
+ });
1319
+ }
1320
+
1321
+ let begun: Awaited<ReturnType<AdmissionStore['beginInvocation']>>;
1322
+ try {
1323
+ begun = await this.#store!.beginInvocation({
1324
+ ...reference(reserved.snapshot),
1325
+ expected_revision: reserved.record.revision,
1326
+ owner_token: reserved.owner_token,
1327
+ });
1328
+ } catch {
1329
+ return this.#beginAmbiguity(reserved.snapshot, reserved.owner_token);
1330
+ }
1331
+ if (!begun.ok) {
1332
+ if (begun.reason === 'state_conflict'
1333
+ || begun.reason === 'revision_conflict') {
1334
+ return this.#beginAmbiguity(reserved.snapshot, reserved.owner_token);
1335
+ }
1336
+ return deepFreeze({
1337
+ status: 'refused',
1338
+ reason: begun.reason,
1339
+ decision,
1340
+ });
1341
+ }
1342
+
1343
+ const begunSnapshot = canonicalSnapshot(begun.snapshot);
1344
+ if (!begunSnapshot.ok || !deeplyFrozen(begun.snapshot)
1345
+ || begun.snapshot.snapshot_digest !== reserved.snapshot.snapshot_digest
1346
+ || begun.record.snapshot_digest !== begun.snapshot.snapshot_digest
1347
+ || begun.record.state !== 'INVOKING'
1348
+ || begun.record.execution_right !== 'CONSUMED'
1349
+ || begun.record.resources.some((resource) => resource.state !== 'CONSUMED')) {
1350
+ await this.#recoverInvoking(begun.snapshot, reserved.owner_token);
1351
+ return reconciliationRequired(
1352
+ reserved.snapshot.body.admission_id,
1353
+ 'begin_invocation_snapshot_mismatch',
1354
+ );
1355
+ }
1356
+
1357
+ const authority: InvocationAuthorityV2 = {
1358
+ ownerToken: reserved.owner_token,
1359
+ invocationToken: begun.invocation_token,
1360
+ snapshotDigest: begun.snapshot.snapshot_digest,
1361
+ };
1362
+ try {
1363
+ await this.#saveAuthority(begun.snapshot, authority);
1364
+ } catch {
1365
+ await this.#recoverInvoking(begun.snapshot, reserved.owner_token);
1366
+ return reconciliationRequired(
1367
+ begun.snapshot.body.admission_id,
1368
+ 'invocation_authority_custody_failed',
1369
+ );
1370
+ }
1371
+ const adapterInput = protectedInvocation(
1372
+ begun.snapshot,
1373
+ begun.invocation_token,
1374
+ );
1375
+ let rawEvidence: unknown;
1376
+ try {
1377
+ rawEvidence = await withinTimeout(
1378
+ () => this.#adapter!.invoke(adapterInput),
1379
+ this.#adapterTimeoutMs,
1380
+ );
1381
+ } catch (error) {
1382
+ await this.#recoverInvoking(begun.snapshot, authority.ownerToken);
1383
+ return reconciliationRequired(
1384
+ begun.snapshot.body.admission_id,
1385
+ error instanceof AdapterTimeoutError
1386
+ ? 'provider_invocation_timed_out'
1387
+ : 'provider_invocation_outcome_unknown',
1388
+ );
1389
+ }
1390
+
1391
+ return this.#processEvidence(
1392
+ rawEvidence,
1393
+ begun.snapshot,
1394
+ authority,
1395
+ begun.record,
1396
+ );
1397
+ }
1398
+
1399
+ /** Evidence-only reconciliation. This method has no mutation-adapter path. */
1400
+ async reconcile(
1401
+ input: Readonly<Pick<
1402
+ AdmissionSnapshot['body'],
1403
+ 'tenant_id' | 'admission_id'
1404
+ >>,
1405
+ ): Promise<GateQualificationExecutionResultV2> {
1406
+ if (this.mode !== 'enforce') {
1407
+ return deepFreeze({
1408
+ status: 'refused',
1409
+ reason: 'reconciliation_unavailable_in_shadow',
1410
+ });
1411
+ }
1412
+ if (!input || !validString(input.tenant_id)
1413
+ || !validString(input.admission_id)) {
1414
+ return deepFreeze({ status: 'refused', reason: 'admission_reference_invalid' });
1415
+ }
1416
+
1417
+ let record: StoreRecordV2 | null;
1418
+ try {
1419
+ record = await this.#store!.read(input);
1420
+ } catch {
1421
+ return reconciliationRequired(
1422
+ input.admission_id,
1423
+ 'admission_read_ambiguous',
1424
+ );
1425
+ }
1426
+ if (!record) {
1427
+ return deepFreeze({ status: 'refused', reason: 'admission_not_found' });
1428
+ }
1429
+ if (record.execution_right !== 'CONSUMED') {
1430
+ return deepFreeze({
1431
+ status: 'refused',
1432
+ reason: 'reconciliation_not_required',
1433
+ });
1434
+ }
1435
+
1436
+ let snapshot: Readonly<AdmissionSnapshot> | null;
1437
+ try {
1438
+ snapshot = await this.#store!.readSnapshot(record.snapshot_digest);
1439
+ } catch {
1440
+ return reconciliationRequired(
1441
+ input.admission_id,
1442
+ 'admission_snapshot_read_ambiguous',
1443
+ );
1444
+ }
1445
+ const checked = canonicalSnapshot(snapshot);
1446
+ if (!snapshot || !checked.ok || !deeplyFrozen(snapshot)
1447
+ || snapshot.snapshot_digest !== record.snapshot_digest) {
1448
+ return reconciliationRequired(
1449
+ input.admission_id,
1450
+ 'admission_snapshot_read_ambiguous',
1451
+ );
1452
+ }
1453
+
1454
+ let authority: Readonly<InvocationAuthorityV2> | null;
1455
+ try {
1456
+ authority = await this.#getAuthority(input.tenant_id, input.admission_id);
1457
+ } catch {
1458
+ return reconciliationRequired(
1459
+ input.admission_id,
1460
+ 'reconciliation_authority_unavailable',
1461
+ );
1462
+ }
1463
+ if (!authority || authority.snapshotDigest !== snapshot.snapshot_digest) {
1464
+ return reconciliationRequired(
1465
+ input.admission_id,
1466
+ 'reconciliation_authority_unavailable',
1467
+ );
1468
+ }
1469
+ if (record.state === 'INVOKING') {
1470
+ const recovered = await this.#recoverInvoking(
1471
+ snapshot,
1472
+ authority.ownerToken,
1473
+ );
1474
+ if (!recovered) {
1475
+ return reconciliationRequired(
1476
+ input.admission_id,
1477
+ 'recovery_unconfirmed',
1478
+ );
1479
+ }
1480
+ authority = recovered;
1481
+ try {
1482
+ record = await this.#store!.read(input);
1483
+ } catch {
1484
+ return reconciliationRequired(
1485
+ input.admission_id,
1486
+ 'admission_read_ambiguous',
1487
+ );
1488
+ }
1489
+ if (!record) {
1490
+ return reconciliationRequired(
1491
+ input.admission_id,
1492
+ 'admission_read_ambiguous',
1493
+ );
1494
+ }
1495
+ }
1496
+
1497
+ const providerUnresolved = !record.provider_outcome
1498
+ || record.provider_outcome.value === 'INDETERMINATE';
1499
+ const effectUnresolved = !record.effect_relation
1500
+ || record.effect_relation.value === 'INDETERMINATE';
1501
+ if (!providerUnresolved && !effectUnresolved) {
1502
+ return deepFreeze({
1503
+ status: 'refused',
1504
+ reason: 'reconciliation_not_required',
1505
+ });
1506
+ }
1507
+
1508
+ const base = protectedInvocation(snapshot, authority.invocationToken);
1509
+ let rawEvidence: unknown;
1510
+ try {
1511
+ rawEvidence = await withinTimeout(
1512
+ () => this.#adapter!.reconcile(deepFreeze({
1513
+ ...base,
1514
+ reconciliationOnly: true as const,
1515
+ })),
1516
+ this.#adapterTimeoutMs,
1517
+ );
1518
+ } catch (error) {
1519
+ return reconciliationRequired(
1520
+ input.admission_id,
1521
+ error instanceof AdapterTimeoutError
1522
+ ? 'provider_reconciliation_timed_out'
1523
+ : 'provider_reconciliation_failed',
1524
+ );
1525
+ }
1526
+ return this.#processEvidence(rawEvidence, snapshot, authority, record);
1527
+ }
1528
+ }
1529
+
1530
+ export default {
1531
+ GATE_QUALIFICATION_V2_VERSION,
1532
+ composeQualificationDecisionV2,
1533
+ GateQualificationV2,
1534
+ };