@emilia-protocol/gate 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +32 -1
  3. package/aeb-consumption-store.js +3 -0
  4. package/dist/aeb-consumption-store.d.ts +80 -0
  5. package/dist/aeb-consumption-store.d.ts.map +1 -0
  6. package/dist/aeb-consumption-store.js +430 -0
  7. package/dist/aeb-consumption-store.js.map +1 -0
  8. package/dist/capability-receipt.d.ts +8 -0
  9. package/dist/capability-receipt.d.ts.map +1 -1
  10. package/dist/capability-receipt.js +17 -0
  11. package/dist/capability-receipt.js.map +1 -1
  12. package/dist/index.d.ts +12 -3
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +16 -4
  15. package/dist/index.js.map +1 -1
  16. package/dist/proposal-to-effect-postgres.d.ts +129 -0
  17. package/dist/proposal-to-effect-postgres.d.ts.map +1 -0
  18. package/dist/proposal-to-effect-postgres.js +1511 -0
  19. package/dist/proposal-to-effect-postgres.js.map +1 -0
  20. package/dist/proposal-to-effect-status.d.ts +55 -0
  21. package/dist/proposal-to-effect-status.d.ts.map +1 -0
  22. package/dist/proposal-to-effect-status.js +280 -0
  23. package/dist/proposal-to-effect-status.js.map +1 -0
  24. package/dist/proposal-to-effect.d.ts +151 -3
  25. package/dist/proposal-to-effect.d.ts.map +1 -1
  26. package/dist/proposal-to-effect.js +589 -46
  27. package/dist/proposal-to-effect.js.map +1 -1
  28. package/dist/remedy-case-set-postgres.d.ts +73 -0
  29. package/dist/remedy-case-set-postgres.d.ts.map +1 -0
  30. package/dist/remedy-case-set-postgres.js +846 -0
  31. package/dist/remedy-case-set-postgres.js.map +1 -0
  32. package/dist/remedy-case-set.d.ts +53 -0
  33. package/dist/remedy-case-set.d.ts.map +1 -0
  34. package/dist/remedy-case-set.js +571 -0
  35. package/dist/remedy-case-set.js.map +1 -0
  36. package/dist/remedy-program-adapters.d.ts +137 -0
  37. package/dist/remedy-program-adapters.d.ts.map +1 -0
  38. package/dist/remedy-program-adapters.js +590 -0
  39. package/dist/remedy-program-adapters.js.map +1 -0
  40. package/dist/trust-program-revocation.js.map +1 -1
  41. package/package.json +278 -63
  42. package/proposal-to-effect-postgres.js +3 -0
  43. package/proposal-to-effect-status.js +2 -0
  44. package/remedy-case-set-postgres.js +3 -0
  45. package/remedy-case-set.js +3 -0
  46. package/remedy-program-adapters.js +3 -0
  47. package/src/aeb-consumption-store.ts +521 -0
  48. package/src/capability-receipt.ts +17 -0
  49. package/src/index.ts +52 -3
  50. package/src/proposal-to-effect-postgres.ts +1740 -0
  51. package/src/proposal-to-effect-status.ts +357 -0
  52. package/src/proposal-to-effect.ts +755 -50
  53. package/src/remedy-case-set-postgres.ts +1008 -0
  54. package/src/remedy-case-set.ts +603 -0
  55. package/src/remedy-program-adapters.ts +657 -0
  56. package/src/trust-program-revocation.ts +1 -1
@@ -6,6 +6,7 @@
6
6
  * requirement; consequence custody remains in Gate and its durable stores.
7
7
  */
8
8
 
9
+ import crypto from 'node:crypto';
9
10
  import {
10
11
  beginReceiptApproval,
11
12
  pollReceiptApproval,
@@ -27,6 +28,8 @@ import {
27
28
  type AebDurableConsumptionStore,
28
29
  type AebEvaluationRecord,
29
30
  type AebPinnedConfig,
31
+ type AebStatusInput,
32
+ type AebVerificationOptions,
30
33
  } from '@emilia-protocol/verify/aeb-adapter-contract';
31
34
  import { actionDigest as aecActionDigest } from '@emilia-protocol/verify/evidence-chain';
32
35
 
@@ -37,6 +40,75 @@ type FetchLike = typeof fetch;
37
40
 
38
41
  const CAID_PATTERN = /^caid:1:[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*\.[1-9][0-9]*:[a-z0-9]+(?:-[a-z0-9]+)*:[A-Za-z0-9_-]{43}$/;
39
42
  const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{2,255}$/;
43
+ const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
44
+ const PROPOSAL_INTEGRITY_DOMAIN = `${PROPOSAL_TO_EFFECT_VERSION}:INTEGRITY\0`;
45
+
46
+ declare const CONSEQUENCE_ATTEMPT_OWNER: unique symbol;
47
+ export type ConsequenceAttemptOwnerHandle = string & { readonly [CONSEQUENCE_ATTEMPT_OWNER]: true };
48
+ export type ConsequenceAttemptState =
49
+ | 'RESERVED'
50
+ | 'INVOKING'
51
+ | 'INDETERMINATE'
52
+ | 'COMMITTED'
53
+ | 'RELEASED'
54
+ | 'ESCALATED';
55
+ export type ProposalToEffectProviderOutcome = 'COMMITTED' | 'NOT_COMMITTED' | 'ESCALATED';
56
+
57
+ export interface ConsequenceAttemptBinding {
58
+ tenant_id: string;
59
+ provider_id: string;
60
+ provider_account_id: string;
61
+ environment: string;
62
+ attempt_id: string;
63
+ request_digest: AebDigest;
64
+ }
65
+
66
+ export interface ConsequenceAttemptReference {
67
+ tenant_id: string;
68
+ attempt_id: string;
69
+ owner: ConsequenceAttemptOwnerHandle;
70
+ }
71
+
72
+ export interface AuthenticatedProviderEvidenceBinding extends ConsequenceAttemptBinding {
73
+ operation_id: string;
74
+ caid: string;
75
+ action_digest: AebDigest;
76
+ evidence_id: string;
77
+ observed_at: string;
78
+ outcome: ProposalToEffectProviderOutcome;
79
+ evidence_digest: AebDigest;
80
+ }
81
+
82
+ export type ConsequenceAttemptTransition =
83
+ | { expected_state: 'RESERVED'; next_state: 'INVOKING' }
84
+ | { expected_state: 'INVOKING'; next_state: 'INDETERMINATE' }
85
+ | {
86
+ expected_state: 'INDETERMINATE';
87
+ next_state: 'COMMITTED' | 'RELEASED' | 'ESCALATED';
88
+ };
89
+
90
+ /** Owner-fenced durable CAS custody for one provider invocation attempt. */
91
+ export interface ProposalToEffectConsequenceAttemptStore {
92
+ durable: true;
93
+ ownershipFenced: true;
94
+ compareAndSwap: true;
95
+ atomicEvidenceBinding: true;
96
+ reserve(binding: ConsequenceAttemptBinding): Promise<
97
+ | { reserved: true; owner: ConsequenceAttemptOwnerHandle }
98
+ | { reserved: false; reason: string }
99
+ >;
100
+ transition(input: ConsequenceAttemptReference & ConsequenceAttemptTransition): Promise<boolean>;
101
+ reconcile(input: ConsequenceAttemptReference & {
102
+ expected_state: 'INDETERMINATE';
103
+ next_state: 'COMMITTED' | 'RELEASED' | 'ESCALATED';
104
+ evidence: AuthenticatedProviderEvidenceBinding;
105
+ }): Promise<boolean>;
106
+ /** Read terminal custody without exposing owner material, for saga repair. */
107
+ read?(binding: ConsequenceAttemptBinding): Promise<{
108
+ state: ConsequenceAttemptState;
109
+ evidence_digest?: AebDigest | null;
110
+ } | null>;
111
+ }
40
112
 
41
113
  export interface ProposalToEffectProfile {
42
114
  id: string;
@@ -79,11 +151,23 @@ export interface ProposalToEffectProposal {
79
151
  authorization_endpoint: string;
80
152
  flow: typeof EP_APPROVAL_FLOW;
81
153
  };
154
+ consequence: {
155
+ tenant_id: string;
156
+ provider_id: string;
157
+ provider_account_id: string;
158
+ environment: string;
159
+ executor_id: string;
160
+ request_digest: AebDigest;
161
+ };
82
162
  aeb: {
83
163
  requirement_ref: string;
84
164
  pinned_config_digest: AebDigest;
85
165
  consumption_nonce: AebDigest;
86
166
  };
167
+ integrity: {
168
+ alg: 'HMAC-SHA256';
169
+ value: string;
170
+ };
87
171
  }
88
172
 
89
173
  export interface ProposalToEffectGate {
@@ -93,13 +177,49 @@ export interface ProposalToEffectGate {
93
177
 
94
178
  export interface ProposalToEffectProviderVerification {
95
179
  valid: boolean;
96
- outcome?: 'COMMITTED' | 'NOT_COMMITTED';
97
- evidence_digest?: AebDigest | null;
180
+ outcome?: ProposalToEffectProviderOutcome;
181
+ evidence_id?: string;
182
+ observed_at?: string;
183
+ tenant_id?: string;
184
+ request_digest?: AebDigest;
185
+ provider_id?: string;
186
+ provider_account_id?: string;
187
+ environment?: string;
188
+ attempt_id?: string;
189
+ operation_id?: string;
190
+ caid?: string;
191
+ action_digest?: AebDigest;
192
+ evidence_digest?: AebDigest;
193
+ reason?: string;
194
+ }
195
+
196
+ export interface ProposalToEffectCurrentStatusVerification {
197
+ valid: boolean;
198
+ outcome: 'current_not_revoked' | 'revoked' | 'indeterminate';
199
+ /** Authenticated normalized AEB status; never raw presenter data. */
200
+ status?: AebStatusInput | null;
98
201
  reason?: string;
99
202
  }
100
203
 
101
204
  export interface ProposalToEffectOptions {
102
205
  gate: ProposalToEffectGate;
206
+ proposal_integrity: {
207
+ /** Server-held key copied at controller construction; minimum 256 bits. */
208
+ hmac_sha256_key: Uint8Array;
209
+ };
210
+ consequence: {
211
+ tenant_id: string;
212
+ provider_id: string;
213
+ provider_account_id: string;
214
+ environment: string;
215
+ executor_id: string;
216
+ store: ProposalToEffectConsequenceAttemptStore;
217
+ /** Server-side allocator. Presented execute input never selects attempt_id. */
218
+ create_attempt_id?: (input: {
219
+ tenant_id: string;
220
+ request_digest: AebDigest;
221
+ }) => Promise<string> | string;
222
+ };
103
223
  profiles: Record<string, ProposalToEffectProfile>;
104
224
  aeb: {
105
225
  config: AebPinnedConfig;
@@ -109,12 +229,37 @@ export interface ProposalToEffectOptions {
109
229
  proposal: ProposalToEffectProposal;
110
230
  evaluation: AebEvaluationRecord;
111
231
  }): Promise<Record<string, unknown>> | Record<string, unknown>;
232
+ currentStatusResolver(input: {
233
+ proposal: ProposalToEffectProposal;
234
+ evaluation: AebEvaluationRecord;
235
+ leg: AebEvaluationRecord['legs'][number];
236
+ }): Promise<unknown> | unknown;
237
+ /** Configure this around EP-STATUS-v1 verifyStatusArtifact and server pins. */
238
+ statusVerifier(input: {
239
+ status_artifact: unknown;
240
+ expected: {
241
+ tenant_id: string;
242
+ executor_id: string;
243
+ operation_id: string;
244
+ caid: string;
245
+ artifact_ref: string;
246
+ evidence_digest: AebDigest;
247
+ replay_unit: AebDigest;
248
+ };
249
+ now: string;
250
+ }): Promise<ProposalToEffectCurrentStatusVerification> | ProposalToEffectCurrentStatusVerification;
112
251
  verify_provider_evidence(input: {
113
252
  evidence: unknown;
114
253
  expected: {
115
254
  operation_id: string;
116
255
  caid: string;
117
256
  action_digest: AebDigest;
257
+ tenant_id: string;
258
+ request_digest: AebDigest;
259
+ provider_id: string;
260
+ provider_account_id: string;
261
+ environment: string;
262
+ attempt_id: string;
118
263
  };
119
264
  }): Promise<ProposalToEffectProviderVerification> | ProposalToEffectProviderVerification;
120
265
  };
@@ -137,6 +282,43 @@ function assertIdentifier(value: unknown, name: string): asserts value is string
137
282
  }
138
283
  }
139
284
 
285
+ function validDigest(value: unknown): value is AebDigest {
286
+ return typeof value === 'string' && DIGEST_PATTERN.test(value);
287
+ }
288
+
289
+ function canonicalInstant(value: unknown): number {
290
+ if (typeof value !== 'string') return NaN;
291
+ const parsed = Date.parse(value);
292
+ if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== value) return NaN;
293
+ return parsed;
294
+ }
295
+
296
+ function exactKeys(value: unknown, keys: readonly string[]): value is JsonObject {
297
+ if (!isPlainObject(value)) return false;
298
+ const actual = Object.keys(value).sort();
299
+ const expected = [...keys].sort();
300
+ return actual.length === expected.length && expected.every((key, index) => key === actual[index]);
301
+ }
302
+
303
+ function secureConsequenceStore(store: unknown): store is ProposalToEffectConsequenceAttemptStore {
304
+ return store !== null && typeof store === 'object'
305
+ && (store as any).durable === true && (store as any).ownershipFenced === true
306
+ && (store as any).compareAndSwap === true && (store as any).atomicEvidenceBinding === true
307
+ && typeof (store as any).reserve === 'function' && typeof (store as any).transition === 'function'
308
+ && typeof (store as any).reconcile === 'function' && typeof (store as any).read === 'function';
309
+ }
310
+
311
+ function normalizedCurrentStatus(value: unknown): value is AebStatusInput {
312
+ return exactKeys(value, [
313
+ 'checked_at', 'expires_at', 'revocation_checked', 'revoked', 'consumed',
314
+ ...(isPlainObject(value) && Object.hasOwn(value, 'unavailable') ? ['unavailable'] : []),
315
+ ]) && typeof value.checked_at === 'string' && Number.isFinite(Date.parse(value.checked_at))
316
+ && typeof value.expires_at === 'string' && Number.isFinite(Date.parse(value.expires_at))
317
+ && typeof value.revocation_checked === 'boolean' && typeof value.revoked === 'boolean'
318
+ && typeof value.consumed === 'boolean'
319
+ && (value.unavailable === undefined || typeof value.unavailable === 'boolean');
320
+ }
321
+
140
322
  function assertProfile(profile: ProposalToEffectProfile): void {
141
323
  if (!isPlainObject(profile)) throw new Error('proposal_profile_invalid');
142
324
  assertIdentifier(profile.id, 'proposal_profile_id');
@@ -194,8 +376,8 @@ function assertSameObject(left: unknown, right: unknown, reason: string): void {
194
376
  function exactProposalKeys(proposal: JsonObject): boolean {
195
377
  const expected = [
196
378
  '@version', 'action', 'action_digest', 'aeb', 'aeb_action_digest', 'authorization',
197
- 'caid', 'challenge', 'created_at', 'expires_at', 'initiator_id', 'operation_id',
198
- 'profile_id', 'proposal_id',
379
+ 'caid', 'challenge', 'consequence', 'created_at', 'expires_at', 'initiator_id',
380
+ 'integrity', 'operation_id', 'profile_id', 'proposal_id',
199
381
  ].sort();
200
382
  const actual = Object.keys(proposal).sort();
201
383
  return expected.length === actual.length && expected.every((key, index) => key === actual[index]);
@@ -251,11 +433,118 @@ export function createProposalToEffect(options: ProposalToEffectOptions) {
251
433
  }
252
434
  if (!options.aeb?.config || !options.aeb.adapters || !options.aeb.store
253
435
  || typeof options.aeb.resolve_artifacts !== 'function'
436
+ || typeof options.aeb.currentStatusResolver !== 'function'
437
+ || typeof options.aeb.statusVerifier !== 'function'
254
438
  || typeof options.aeb.verify_provider_evidence !== 'function') {
255
439
  throw new Error('proposal_aeb_configuration_required');
256
440
  }
441
+ if (!(options.proposal_integrity?.hmac_sha256_key instanceof Uint8Array)
442
+ || options.proposal_integrity.hmac_sha256_key.byteLength < 32) {
443
+ throw new Error('proposal_integrity_configuration_required');
444
+ }
445
+ if (!options.consequence) throw new Error('proposal_consequence_configuration_required');
446
+ assertIdentifier(options.consequence.tenant_id, 'proposal_tenant_id');
447
+ assertIdentifier(options.consequence.provider_id, 'proposal_provider_id');
448
+ assertIdentifier(options.consequence.provider_account_id, 'proposal_provider_account_id');
449
+ assertIdentifier(options.consequence.environment, 'proposal_environment');
450
+ assertIdentifier(options.consequence.executor_id, 'proposal_executor_id');
451
+ if (!secureConsequenceStore(options.consequence.store)
452
+ || (options.consequence.create_attempt_id !== undefined
453
+ && typeof options.consequence.create_attempt_id !== 'function')) {
454
+ throw new Error('proposal_consequence_store_required');
455
+ }
257
456
  const now = options.now ?? Date.now;
258
457
  const configDigest = pinnedConfigDigest(options.aeb.config);
458
+ const integrityKey = Buffer.from(options.proposal_integrity.hmac_sha256_key);
459
+ const consequenceContext = Object.freeze({
460
+ tenant_id: options.consequence.tenant_id,
461
+ provider_id: options.consequence.provider_id,
462
+ provider_account_id: options.consequence.provider_account_id,
463
+ environment: options.consequence.environment,
464
+ executor_id: options.consequence.executor_id,
465
+ });
466
+ // Active owner capabilities never become enumerable response/error data.
467
+ // The same-process service can recover a handle from the exact object; after
468
+ // restart it must use the durable store's separately authorized recovery API.
469
+ const reconciliationHandles = new WeakMap<object, ConsequenceAttemptReference>();
470
+
471
+ function rememberReconciliationHandle<T extends object>(
472
+ target: T,
473
+ reference: ConsequenceAttemptReference,
474
+ ): T {
475
+ reconciliationHandles.set(target, reference);
476
+ return target;
477
+ }
478
+
479
+ function getReconciliationHandle(target: object): ConsequenceAttemptReference | null {
480
+ const reference = reconciliationHandles.get(target);
481
+ return reference ? clone(reference) : null;
482
+ }
483
+
484
+ function currentTime(): number {
485
+ const value = now();
486
+ if (!Number.isSafeInteger(value) || !Number.isFinite(new Date(value).getTime())) {
487
+ throw new Error('proposal_time_invalid');
488
+ }
489
+ return value;
490
+ }
491
+
492
+ async function reconcileAebWithRecovery(
493
+ key: string,
494
+ outcome: 'COMMITTED' | 'NOT_COMMITTED',
495
+ authorization?: unknown,
496
+ ) {
497
+ let result = await reconcileAebExecutionDurable(options.aeb.store, key, outcome);
498
+ const recoveryStore = options.aeb.store as AebDurableConsumptionStore & {
499
+ claimReservation?: (operationKey: string, recoveryAuthorization: unknown) => Promise<boolean>;
500
+ };
501
+ if (result.state === 'RECONCILIATION_REQUIRED'
502
+ && authorization !== undefined
503
+ && typeof recoveryStore.claimReservation === 'function'
504
+ && await recoveryStore.claimReservation(key, authorization).catch(() => false)) {
505
+ result = await reconcileAebExecutionDurable(options.aeb.store, key, outcome);
506
+ }
507
+ return result;
508
+ }
509
+
510
+ function requestDigestFor(proposal: JsonObject): AebDigest {
511
+ return digestAeb({
512
+ domain: `${PROPOSAL_TO_EFFECT_VERSION}:REQUEST`,
513
+ ...consequenceContext,
514
+ proposal_id: proposal.proposal_id,
515
+ operation_id: proposal.operation_id,
516
+ initiator_id: proposal.initiator_id,
517
+ profile_id: proposal.profile_id,
518
+ action: proposal.action,
519
+ action_digest: proposal.action_digest,
520
+ aeb_action_digest: proposal.aeb_action_digest,
521
+ caid: proposal.caid,
522
+ created_at: proposal.created_at,
523
+ expires_at: proposal.expires_at,
524
+ challenge: proposal.challenge,
525
+ authorization: proposal.authorization,
526
+ aeb: proposal.aeb,
527
+ });
528
+ }
529
+
530
+ function integrityMac(unsignedProposal: unknown): string {
531
+ return crypto.createHmac('sha256', integrityKey)
532
+ .update(PROPOSAL_INTEGRITY_DOMAIN)
533
+ .update(digestAeb(unsignedProposal))
534
+ .digest('base64url');
535
+ }
536
+
537
+ function proposalIntegrityValid(proposal: JsonObject): boolean {
538
+ if (!exactKeys(proposal.integrity, ['alg', 'value'])
539
+ || proposal.integrity.alg !== 'HMAC-SHA256'
540
+ || typeof proposal.integrity.value !== 'string'
541
+ || !/^[A-Za-z0-9_-]{43}$/.test(proposal.integrity.value)) return false;
542
+ const unsigned = clone(proposal);
543
+ delete unsigned.integrity;
544
+ const actual = Buffer.from(proposal.integrity.value, 'base64url');
545
+ const expected = Buffer.from(integrityMac(unsigned), 'base64url');
546
+ return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
547
+ }
259
548
 
260
549
  function profileFor(id: unknown): ProposalToEffectProfile {
261
550
  if (typeof id !== 'string' || !options.profiles[id]) throw new Error('proposal_profile_not_pinned');
@@ -274,11 +563,10 @@ export function createProposalToEffect(options: ProposalToEffectOptions) {
274
563
  assertIdentifier(input?.initiator_id, 'proposal_initiator_id');
275
564
  const profile = profileFor(input?.profile_id);
276
565
  const normalized = canonicalizeForProfile(profile, input.action);
277
- const createdAtMs = now();
278
- if (!Number.isFinite(createdAtMs)) throw new Error('proposal_time_invalid');
566
+ const createdAtMs = currentTime();
279
567
  const actionDigest = approvalActionHash(normalized.action);
280
- return clone({
281
- '@version': PROPOSAL_TO_EFFECT_VERSION,
568
+ const base = {
569
+ '@version': PROPOSAL_TO_EFFECT_VERSION as typeof PROPOSAL_TO_EFFECT_VERSION,
282
570
  proposal_id: input.proposal_id,
283
571
  operation_id: input.operation_id,
284
572
  initiator_id: input.initiator_id,
@@ -301,6 +589,17 @@ export function createProposalToEffect(options: ProposalToEffectOptions) {
301
589
  pinned_config_digest: configDigest,
302
590
  consumption_nonce: proposalToEffectConsumptionNonce(input.operation_id, configDigest),
303
591
  },
592
+ };
593
+ const unsigned = {
594
+ ...base,
595
+ consequence: {
596
+ ...consequenceContext,
597
+ request_digest: requestDigestFor(base),
598
+ },
599
+ };
600
+ return clone({
601
+ ...unsigned,
602
+ integrity: { alg: 'HMAC-SHA256', value: integrityMac(unsigned) },
304
603
  });
305
604
  }
306
605
 
@@ -312,6 +611,7 @@ export function createProposalToEffect(options: ProposalToEffectOptions) {
312
611
  || input['@version'] !== PROPOSAL_TO_EFFECT_VERSION) {
313
612
  throw new Error('proposal_shape_invalid');
314
613
  }
614
+ if (!proposalIntegrityValid(input)) throw new Error('proposal_integrity_invalid');
315
615
  const proposal = input as ProposalToEffectProposal;
316
616
  assertIdentifier(proposal.proposal_id, 'proposal_id');
317
617
  assertIdentifier(proposal.operation_id, 'proposal_operation_id');
@@ -326,11 +626,13 @@ export function createProposalToEffect(options: ProposalToEffectOptions) {
326
626
  if (digestAeb(normalized.action) !== proposal.aeb_action_digest) {
327
627
  throw new Error('proposal_aeb_action_digest_mismatch');
328
628
  }
329
- if (!Number.isFinite(Date.parse(proposal.created_at)) || !Number.isFinite(Date.parse(proposal.expires_at))
330
- || Date.parse(proposal.expires_at) <= Date.parse(proposal.created_at)) {
331
- throw new Error('proposal_time_invalid');
332
- }
333
- if (!allowExpired && now() >= Date.parse(proposal.expires_at)) throw new Error('proposal_expired');
629
+ const createdAtMs = canonicalInstant(proposal.created_at);
630
+ const expiresAtMs = canonicalInstant(proposal.expires_at);
631
+ if (!Number.isFinite(createdAtMs) || !Number.isFinite(expiresAtMs)) throw new Error('proposal_time_invalid');
632
+ if (expiresAtMs - createdAtMs !== profile.ttl_sec * 1000) throw new Error('proposal_ttl_mismatch');
633
+ const decisionTime = currentTime();
634
+ if (createdAtMs > decisionTime) throw new Error('proposal_created_in_future');
635
+ if (!allowExpired && decisionTime >= expiresAtMs) throw new Error('proposal_expired');
334
636
  assertSameObject(proposal.authorization, profile.authorization, 'proposal_authorization_mismatch');
335
637
  if (!isPlainObject(proposal.aeb)
336
638
  || Object.keys(proposal.aeb).sort().join(',') !== 'consumption_nonce,pinned_config_digest,requirement_ref'
@@ -341,6 +643,16 @@ export function createProposalToEffect(options: ProposalToEffectOptions) {
341
643
  if (proposal.aeb.consumption_nonce !== proposalToEffectConsumptionNonce(proposal.operation_id, configDigest)) {
342
644
  throw new Error('proposal_aeb_nonce_mismatch');
343
645
  }
646
+ if (!exactKeys(proposal.consequence, [
647
+ 'tenant_id', 'provider_id', 'provider_account_id', 'environment', 'executor_id', 'request_digest',
648
+ ]) || proposal.consequence.tenant_id !== consequenceContext.tenant_id
649
+ || proposal.consequence.provider_id !== consequenceContext.provider_id
650
+ || proposal.consequence.provider_account_id !== consequenceContext.provider_account_id
651
+ || proposal.consequence.environment !== consequenceContext.environment
652
+ || proposal.consequence.executor_id !== consequenceContext.executor_id
653
+ || proposal.consequence.request_digest !== requestDigestFor(proposal)) {
654
+ throw new Error('proposal_consequence_binding_mismatch');
655
+ }
344
656
  const expectedChallenge = {
345
657
  action: profile.action_type,
346
658
  action_hash: proposal.action_digest,
@@ -357,29 +669,76 @@ export function createProposalToEffect(options: ProposalToEffectOptions) {
357
669
  if (record.operation_id !== proposal.operation_id
358
670
  || record.consumption_nonce !== proposal.aeb.consumption_nonce
359
671
  || record.initiator_id !== proposal.initiator_id
672
+ || record.executor_id !== proposal.consequence.executor_id
360
673
  || record.requirement_ref !== proposal.aeb.requirement_ref
361
674
  || record.caid !== proposal.caid) {
362
675
  return { valid: false, reason: 'aeb_evaluation_binding_mismatch', record };
363
676
  }
677
+ if (record.verdict === 'SATISFIED'
678
+ && record.composition?.action_digest !== expectedAebCompositionDigest(proposal)) {
679
+ return { valid: false, reason: 'aeb_evaluation_binding_mismatch', record };
680
+ }
364
681
  let artifacts: Record<string, unknown>;
365
682
  try {
366
683
  artifacts = await options.aeb.resolve_artifacts({ proposal: clone(proposal), evaluation: clone(record) });
367
684
  } catch {
368
685
  return { valid: false, reason: 'aeb_artifact_resolution_failed', record };
369
686
  }
370
- const checked = verifyAebEvaluation(record, {
687
+ const decisionNow = new Date(currentTime()).toISOString();
688
+ const currentStatuses: Record<string, AebStatusInput> = {};
689
+ for (const leg of record.legs ?? []) {
690
+ try {
691
+ const statusArtifact = await options.aeb.currentStatusResolver({
692
+ proposal: clone(proposal),
693
+ evaluation: clone(record),
694
+ leg: clone(leg),
695
+ });
696
+ if (statusArtifact === undefined || statusArtifact === null) {
697
+ return { valid: false, reason: 'aeb_current_status_refused', record };
698
+ }
699
+ const status = await options.aeb.statusVerifier({
700
+ status_artifact: clone(statusArtifact),
701
+ expected: {
702
+ tenant_id: proposal.consequence.tenant_id,
703
+ executor_id: proposal.consequence.executor_id,
704
+ operation_id: proposal.operation_id,
705
+ caid: proposal.caid,
706
+ artifact_ref: leg.artifact_ref,
707
+ evidence_digest: leg.evidence_digest,
708
+ replay_unit: leg.replay_unit,
709
+ },
710
+ now: decisionNow,
711
+ });
712
+ if (!status?.valid || status.outcome !== 'current_not_revoked'
713
+ || !normalizedCurrentStatus(status.status)
714
+ || status.status.revocation_checked !== true || status.status.revoked !== false
715
+ || status.status.consumed !== false || status.status.unavailable === true) {
716
+ return { valid: false, reason: 'aeb_current_status_refused', record };
717
+ }
718
+ currentStatuses[leg.artifact_ref] = clone(status.status);
719
+ } catch {
720
+ return { valid: false, reason: 'aeb_current_status_refused', record };
721
+ }
722
+ }
723
+ if (record.legs.length === 0 || Object.keys(currentStatuses).length !== record.legs.length) {
724
+ return { valid: false, reason: 'aeb_current_status_refused', record };
725
+ }
726
+ const verificationOptions: AebVerificationOptions & { executor_id: string } = {
727
+ mode: 'execution',
371
728
  config: options.aeb.config,
372
729
  adapters: options.aeb.adapters,
373
730
  artifacts,
374
- now: new Date(now()).toISOString(),
375
- });
376
- if (!checked.valid || record.verdict !== 'SATISFIED'
731
+ expected_action: clone(proposal.action),
732
+ executor_id: proposal.consequence.executor_id,
733
+ current_statuses: currentStatuses,
734
+ now: decisionNow,
735
+ };
736
+ const checked = verifyAebEvaluation(record, verificationOptions);
737
+ if (!checked.valid || checked.execution_authorizing !== true
738
+ || record.verdict !== 'SATISFIED'
377
739
  || record.authority_constraints?.one_time_consumption !== true) {
378
740
  return { valid: false, reason: 'aeb_evaluation_refused', record, checked };
379
741
  }
380
- if (record.composition?.action_digest !== expectedAebCompositionDigest(proposal)) {
381
- return { valid: false, reason: 'aeb_evaluation_binding_mismatch', record, checked };
382
- }
383
742
  return { valid: true, reason: null, record, checked };
384
743
  }
385
744
 
@@ -390,6 +749,12 @@ export function createProposalToEffect(options: ProposalToEffectOptions) {
390
749
  operation_id: proposal.operation_id,
391
750
  initiator_id: proposal.initiator_id,
392
751
  aeb_requirement_ref: proposal.aeb.requirement_ref,
752
+ tenant_id: proposal.consequence.tenant_id,
753
+ provider_id: proposal.consequence.provider_id,
754
+ provider_account_id: proposal.consequence.provider_account_id,
755
+ environment: proposal.consequence.environment,
756
+ executor_id: proposal.consequence.executor_id,
757
+ request_digest: proposal.consequence.request_digest,
393
758
  },
394
759
  receipt,
395
760
  observedAction: clone(proposal.action),
@@ -399,7 +764,13 @@ export function createProposalToEffect(options: ProposalToEffectOptions) {
399
764
 
400
765
  async function execute(
401
766
  input: { proposal: unknown; receipt: unknown; evaluation: unknown },
402
- effect: (input: { action: JsonObject; proposal: ProposalToEffectProposal; authorization: JsonObject }) => unknown | Promise<unknown>,
767
+ effect: (input: {
768
+ action: JsonObject;
769
+ proposal: ProposalToEffectProposal;
770
+ authorization: JsonObject;
771
+ /** Provider request binding; the opaque store owner never crosses the effect boundary. */
772
+ attempt: ConsequenceAttemptBinding;
773
+ }) => unknown | Promise<unknown>,
403
774
  ): Promise<JsonObject> {
404
775
  if (typeof effect !== 'function') throw new Error('proposal_effect_required');
405
776
  const { proposal, profile } = verifyProposal(input?.proposal);
@@ -407,6 +778,9 @@ export function createProposalToEffect(options: ProposalToEffectOptions) {
407
778
  if (!evaluation.valid || !evaluation.record) {
408
779
  return refusal(evaluation.reason || 'aeb_evaluation_refused', { aeb: evaluation.checked ?? null });
409
780
  }
781
+ if (!evaluation.checked?.valid || evaluation.checked.execution_authorizing !== true) {
782
+ return refusal('aeb_execution_verification_required', { aeb: evaluation.checked ?? null });
783
+ }
410
784
  const preparedGateInput = gateInput(proposal, profile, input.receipt, evaluation.record);
411
785
  const preflight = await options.gate.check({ ...preparedGateInput, consumptionMode: 'none' });
412
786
  if (preflight.allow !== true) {
@@ -416,7 +790,7 @@ export function createProposalToEffect(options: ProposalToEffectOptions) {
416
790
  return refusal('gate_profile_not_receipt_guarded', { authorization: preflight });
417
791
  }
418
792
  const reservation = await authorizeAebExecutionDurable(evaluation.record, {
419
- verified: true,
793
+ verification: evaluation.checked,
420
794
  local_authorization: true,
421
795
  store: options.aeb.store,
422
796
  });
@@ -427,41 +801,164 @@ export function createProposalToEffect(options: ProposalToEffectOptions) {
427
801
  );
428
802
  }
429
803
  const key = reservation.reservation_key;
804
+ let attemptId: string;
430
805
  try {
431
- const result = await options.gate.run(preparedGateInput, async (authorization) => effect({
432
- action: clone(proposal.action),
433
- proposal: clone(proposal),
434
- authorization: clone(authorization),
435
- }));
806
+ attemptId = options.consequence.create_attempt_id
807
+ ? await options.consequence.create_attempt_id({
808
+ tenant_id: proposal.consequence.tenant_id,
809
+ request_digest: proposal.consequence.request_digest,
810
+ })
811
+ : `attempt:${crypto.randomUUID()}`;
812
+ assertIdentifier(attemptId, 'proposal_attempt_id');
813
+ } catch {
814
+ await options.aeb.store.release(key).catch(() => false);
815
+ return refusal('consequence_attempt_allocation_failed');
816
+ }
817
+ const binding: ConsequenceAttemptBinding = {
818
+ tenant_id: proposal.consequence.tenant_id,
819
+ provider_id: proposal.consequence.provider_id,
820
+ provider_account_id: proposal.consequence.provider_account_id,
821
+ environment: proposal.consequence.environment,
822
+ attempt_id: attemptId,
823
+ request_digest: proposal.consequence.request_digest,
824
+ };
825
+ let reservedAttempt: Awaited<ReturnType<ProposalToEffectConsequenceAttemptStore['reserve']>>;
826
+ try {
827
+ reservedAttempt = await options.consequence.store.reserve(clone(binding));
828
+ } catch {
829
+ await options.aeb.store.release(key).catch(() => false);
830
+ return refusal('consequence_attempt_store_unavailable');
831
+ }
832
+ if (!reservedAttempt?.reserved || typeof reservedAttempt.owner !== 'string'
833
+ || reservedAttempt.owner.length < 1 || reservedAttempt.owner.length > 1024) {
834
+ await options.aeb.store.release(key).catch(() => false);
835
+ const reason = reservedAttempt.reserved === false
836
+ ? reservedAttempt.reason : 'consequence_attempt_conflict';
837
+ return refusal(reason);
838
+ }
839
+ const attempt = {
840
+ ...binding,
841
+ owner: reservedAttempt.owner,
842
+ };
843
+ const attemptRef: ConsequenceAttemptReference = {
844
+ tenant_id: attempt.tenant_id,
845
+ attempt_id: attempt.attempt_id,
846
+ owner: attempt.owner,
847
+ };
848
+ const attemptCustody = { state: 'RESERVED' as ConsequenceAttemptState };
849
+ const transition = async (change: ConsequenceAttemptTransition): Promise<boolean> => {
850
+ try {
851
+ const changed = await options.consequence.store.transition({
852
+ ...attemptRef,
853
+ ...change,
854
+ });
855
+ if (changed === true) attemptCustody.state = change.next_state;
856
+ return changed === true;
857
+ } catch {
858
+ return false;
859
+ }
860
+ };
861
+ if (!await transition({ expected_state: 'RESERVED', next_state: 'INVOKING' })) {
862
+ await options.aeb.store.release(key).catch(() => false);
863
+ return refusal('consequence_attempt_transition_conflict');
864
+ }
865
+ let callbackEntered = false;
866
+ const freeze = async (): Promise<boolean> => (
867
+ attemptCustody.state === 'INDETERMINATE'
868
+ || (attemptCustody.state === 'INVOKING'
869
+ && await transition({ expected_state: 'INVOKING', next_state: 'INDETERMINATE' }))
870
+ );
871
+ const attachAttempt = (thrown: unknown, outcome?: string): any => {
872
+ const error: any = thrown && (typeof thrown === 'object' || typeof thrown === 'function')
873
+ ? thrown : new Error(String(thrown));
874
+ error.proposalToEffect = {
875
+ ...(isPlainObject(error.proposalToEffect) ? error.proposalToEffect : {}),
876
+ ...(outcome ? { outcome } : {}),
877
+ reservation_key: key,
878
+ attempt: clone(binding),
879
+ attempt_state: attemptCustody.state,
880
+ };
881
+ return rememberReconciliationHandle(error, attemptRef);
882
+ };
883
+ try {
884
+ const result = await options.gate.run(preparedGateInput, async (authorization) => {
885
+ callbackEntered = true;
886
+ return effect({
887
+ action: clone(proposal.action),
888
+ proposal: clone(proposal),
889
+ authorization: clone(authorization),
890
+ attempt: clone(binding),
891
+ });
892
+ });
893
+ if (!await freeze()) {
894
+ throw attachAttempt(new Error('consequence_attempt_freeze_failed'), 'indeterminate');
895
+ }
436
896
  if (result?.ok !== true) {
437
- await options.aeb.store.release(key);
897
+ if (callbackEntered) {
898
+ return rememberReconciliationHandle(refusal(result?.authorization?.reason || result?.reason || 'gate_refused', {
899
+ authorization: result?.authorization ?? null,
900
+ consequence: { state: 'INDETERMINATE', attempt: clone(binding) },
901
+ }), attemptRef);
902
+ }
903
+ if (!await transition({ expected_state: 'INDETERMINATE', next_state: 'RELEASED' })) {
904
+ return rememberReconciliationHandle(refusal('consequence_attempt_transition_conflict', {
905
+ consequence: { state: 'INDETERMINATE', attempt: clone(binding) },
906
+ }), attemptRef);
907
+ }
908
+ await options.aeb.store.release(key).catch(() => false);
438
909
  return refusal(result?.authorization?.reason || result?.reason || 'gate_refused', {
439
910
  authorization: result?.authorization ?? null,
911
+ consequence: { state: 'RELEASED', attempt: clone(binding) },
440
912
  });
441
913
  }
442
- const committed = await reconcileAebExecutionDurable(options.aeb.store, key, 'COMMITTED');
914
+ if (!callbackEntered) {
915
+ if (await transition({ expected_state: 'INDETERMINATE', next_state: 'RELEASED' })) {
916
+ await options.aeb.store.release(key).catch(() => false);
917
+ }
918
+ const response = refusal('gate_effect_not_invoked', {
919
+ consequence: { state: attemptCustody.state, attempt: clone(binding) },
920
+ });
921
+ return attemptCustody.state === 'INDETERMINATE'
922
+ ? rememberReconciliationHandle(response, attemptRef) : response;
923
+ }
924
+ const committed = await reconcileAebWithRecovery(key, 'COMMITTED');
443
925
  if (committed.state !== 'CONSUMED') {
444
926
  const error: any = new Error('aeb_consumption_commit_failed');
445
927
  error.code = 'EMILIA_PROPOSAL_TO_EFFECT_COMMIT_FAILED';
446
- error.proposalToEffect = { outcome: 'executed', reservation_key: key };
447
- throw error;
928
+ throw attachAttempt(error, 'indeterminate');
929
+ }
930
+ if (!await transition({ expected_state: 'INDETERMINATE', next_state: 'COMMITTED' })) {
931
+ throw attachAttempt(new Error('consequence_attempt_commit_failed'), 'executed');
448
932
  }
449
- return { ...result, proposal: clone(proposal), aeb: committed };
933
+ return {
934
+ ...result,
935
+ proposal: clone(proposal),
936
+ aeb: committed,
937
+ consequence: { state: 'COMMITTED', attempt: clone(binding) },
938
+ };
450
939
  } catch (error: any) {
451
940
  const outcome = error?.proposalToEffect?.outcome ?? error?.emiliaGateOutcome?.outcome;
452
- if (outcome === 'executed') {
453
- await reconcileAebExecutionDurable(options.aeb.store, key, 'COMMITTED');
454
- } else if (outcome !== 'indeterminate') {
455
- await options.aeb.store.release(key);
941
+ if (attemptCustody.state === 'INVOKING') await freeze();
942
+ if (attemptCustody.state === 'INDETERMINATE' && outcome === 'executed') {
943
+ const committed = await reconcileAebWithRecovery(key, 'COMMITTED');
944
+ if (committed.state === 'CONSUMED') {
945
+ await transition({ expected_state: 'INDETERMINATE', next_state: 'COMMITTED' });
946
+ }
947
+ } else if (attemptCustody.state === 'INDETERMINATE' && !callbackEntered && outcome !== 'indeterminate') {
948
+ if (await transition({ expected_state: 'INDETERMINATE', next_state: 'RELEASED' })) {
949
+ await options.aeb.store.release(key).catch(() => false);
950
+ }
456
951
  }
457
- throw error;
952
+ throw attachAttempt(error, callbackEntered ? (outcome || 'indeterminate') : outcome);
458
953
  }
459
954
  }
460
955
 
461
956
  async function reconcile(input: {
462
957
  proposal: unknown;
463
958
  evaluation: unknown;
959
+ attempt: ConsequenceAttemptReference | (ConsequenceAttemptBinding & { owner: ConsequenceAttemptOwnerHandle });
464
960
  provider_evidence: unknown;
961
+ aeb_recovery_authorization?: unknown;
465
962
  }): Promise<JsonObject> {
466
963
  const { proposal } = verifyProposal(input?.proposal, { allowExpired: true });
467
964
  if (!isPlainObject(input?.evaluation)) return refusal('aeb_evaluation_missing');
@@ -469,25 +966,55 @@ export function createProposalToEffect(options: ProposalToEffectOptions) {
469
966
  if (record.operation_id !== proposal.operation_id
470
967
  || record.consumption_nonce !== proposal.aeb.consumption_nonce
471
968
  || record.initiator_id !== proposal.initiator_id
969
+ || record.executor_id !== proposal.consequence.executor_id
472
970
  || record.requirement_ref !== proposal.aeb.requirement_ref
473
971
  || record.caid !== proposal.caid) {
474
972
  return refusal('aeb_evaluation_binding_mismatch');
475
973
  }
476
- const artifacts = await options.aeb.resolve_artifacts({ proposal: clone(proposal), evaluation: clone(record) });
477
- const historical = verifyAebEvaluation(record, {
974
+ if (record.composition?.action_digest !== expectedAebCompositionDigest(proposal)) {
975
+ return refusal('aeb_evaluation_binding_mismatch');
976
+ }
977
+ let artifacts: Record<string, unknown>;
978
+ try {
979
+ artifacts = await options.aeb.resolve_artifacts({ proposal: clone(proposal), evaluation: clone(record) });
980
+ } catch {
981
+ return refusal('aeb_artifact_resolution_failed');
982
+ }
983
+ const historicalOptions: AebVerificationOptions & { executor_id: string } = {
984
+ mode: 'historical',
478
985
  config: options.aeb.config,
479
986
  adapters: options.aeb.adapters,
480
987
  artifacts,
481
- now: record.evaluated_at,
482
- });
988
+ expected_action: clone(proposal.action),
989
+ executor_id: proposal.consequence.executor_id,
990
+ };
991
+ const historical = verifyAebEvaluation(record, historicalOptions);
483
992
  if (!historical.valid) return refusal('aeb_evaluation_refused', { aeb: historical });
484
993
  if (record.verdict !== 'SATISFIED'
485
994
  || record.authority_constraints?.one_time_consumption !== true) {
486
995
  return refusal('aeb_evaluation_refused', { aeb: historical });
487
996
  }
488
- if (record.composition?.action_digest !== expectedAebCompositionDigest(proposal)) {
489
- return refusal('aeb_evaluation_binding_mismatch');
997
+ if (!isPlainObject(input?.attempt)
998
+ || input.attempt.tenant_id !== proposal.consequence.tenant_id
999
+ || typeof input.attempt.attempt_id !== 'string'
1000
+ || !IDENTIFIER_PATTERN.test(input.attempt.attempt_id)
1001
+ || typeof input.attempt.owner !== 'string'
1002
+ || input.attempt.owner.length < 1 || input.attempt.owner.length > 1024) {
1003
+ return refusal('consequence_attempt_binding_mismatch');
490
1004
  }
1005
+ const attemptRef: ConsequenceAttemptReference = {
1006
+ tenant_id: proposal.consequence.tenant_id,
1007
+ attempt_id: input.attempt.attempt_id,
1008
+ owner: input.attempt.owner,
1009
+ };
1010
+ const publicAttempt: ConsequenceAttemptBinding = {
1011
+ tenant_id: proposal.consequence.tenant_id,
1012
+ provider_id: proposal.consequence.provider_id,
1013
+ provider_account_id: proposal.consequence.provider_account_id,
1014
+ environment: proposal.consequence.environment,
1015
+ attempt_id: attemptRef.attempt_id,
1016
+ request_digest: proposal.consequence.request_digest,
1017
+ };
491
1018
  let provider: ProposalToEffectProviderVerification;
492
1019
  try {
493
1020
  provider = await options.aeb.verify_provider_evidence({
@@ -496,25 +1023,201 @@ export function createProposalToEffect(options: ProposalToEffectOptions) {
496
1023
  operation_id: proposal.operation_id,
497
1024
  caid: proposal.caid,
498
1025
  action_digest: proposal.aeb_action_digest,
1026
+ tenant_id: proposal.consequence.tenant_id,
1027
+ request_digest: proposal.consequence.request_digest,
1028
+ provider_id: proposal.consequence.provider_id,
1029
+ provider_account_id: proposal.consequence.provider_account_id,
1030
+ environment: proposal.consequence.environment,
1031
+ attempt_id: attemptRef.attempt_id,
499
1032
  },
500
1033
  });
501
1034
  } catch {
502
1035
  return refusal('provider_evidence_unverified');
503
1036
  }
504
- if (!provider?.valid || (provider.outcome !== 'COMMITTED' && provider.outcome !== 'NOT_COMMITTED')) {
1037
+ const observedAtMs = typeof provider?.observed_at === 'string'
1038
+ ? Date.parse(provider.observed_at) : NaN;
1039
+ const proposalCreatedAtMs = Date.parse(proposal.created_at);
1040
+ if (!provider?.valid || !['COMMITTED', 'NOT_COMMITTED', 'ESCALATED'].includes(provider.outcome ?? '')
1041
+ || typeof provider.evidence_id !== 'string' || !IDENTIFIER_PATTERN.test(provider.evidence_id)
1042
+ || !Number.isFinite(observedAtMs) || observedAtMs < proposalCreatedAtMs
1043
+ || observedAtMs > currentTime()
1044
+ || !validDigest(provider.evidence_digest)) {
505
1045
  return refusal(provider?.reason || 'provider_evidence_unverified');
506
1046
  }
1047
+ if (provider.tenant_id !== proposal.consequence.tenant_id
1048
+ || provider.request_digest !== proposal.consequence.request_digest
1049
+ || provider.provider_id !== proposal.consequence.provider_id
1050
+ || provider.provider_account_id !== proposal.consequence.provider_account_id
1051
+ || provider.environment !== proposal.consequence.environment
1052
+ || provider.attempt_id !== attemptRef.attempt_id
1053
+ || provider.operation_id !== proposal.operation_id
1054
+ || provider.caid !== proposal.caid
1055
+ || provider.action_digest !== proposal.aeb_action_digest) {
1056
+ return refusal('provider_evidence_binding_mismatch');
1057
+ }
1058
+ const providerOutcome = provider.outcome as ProposalToEffectProviderOutcome;
1059
+ const evidence: AuthenticatedProviderEvidenceBinding = {
1060
+ operation_id: proposal.operation_id,
1061
+ caid: proposal.caid,
1062
+ action_digest: proposal.aeb_action_digest,
1063
+ tenant_id: proposal.consequence.tenant_id,
1064
+ provider_id: proposal.consequence.provider_id,
1065
+ provider_account_id: proposal.consequence.provider_account_id,
1066
+ environment: proposal.consequence.environment,
1067
+ attempt_id: attemptRef.attempt_id,
1068
+ request_digest: proposal.consequence.request_digest,
1069
+ evidence_id: provider.evidence_id as string,
1070
+ observed_at: provider.observed_at as string,
1071
+ outcome: providerOutcome,
1072
+ evidence_digest: provider.evidence_digest,
1073
+ };
1074
+ const terminalState = providerOutcome === 'COMMITTED'
1075
+ ? 'COMMITTED' : providerOutcome === 'NOT_COMMITTED' ? 'RELEASED' : 'ESCALATED';
507
1076
  const key = aebReservationKey(record);
508
- const reconciled = await reconcileAebExecutionDurable(options.aeb.store, key, provider.outcome);
509
- if (reconciled.state === 'RECONCILIATION_REQUIRED') {
510
- return refusal(reconciled.reason, { state: reconciled.state });
1077
+ // For a committed real-world effect, consume replay authority before the
1078
+ // attempt becomes terminal. A failed AEB commit leaves the attempt
1079
+ // INDETERMINATE and therefore repairable, never terminal split-brain.
1080
+ const preTerminalAeb = providerOutcome === 'COMMITTED'
1081
+ ? await reconcileAebWithRecovery(key, 'COMMITTED', input.aeb_recovery_authorization)
1082
+ : null;
1083
+ if (providerOutcome === 'COMMITTED' && preTerminalAeb?.state !== 'CONSUMED') {
1084
+ return refusal('aeb_consumption_reconciliation_failed', {
1085
+ state: 'INDETERMINATE',
1086
+ consequence: { state: 'INDETERMINATE', attempt: clone(publicAttempt) },
1087
+ aeb: preTerminalAeb,
1088
+ });
1089
+ }
1090
+ let attemptReconciled = false;
1091
+ try {
1092
+ attemptReconciled = await options.consequence.store.reconcile({
1093
+ ...attemptRef,
1094
+ expected_state: 'INDETERMINATE',
1095
+ next_state: terminalState,
1096
+ evidence: clone(evidence),
1097
+ });
1098
+ } catch {
1099
+ return refusal('consequence_attempt_store_unavailable');
1100
+ }
1101
+ if (!attemptReconciled) return refusal('consequence_attempt_not_indeterminate');
1102
+ const reconciled = providerOutcome === 'ESCALATED'
1103
+ ? { state: 'RECONCILIATION_REQUIRED', retry_allowed: false, reason: 'execution_escalated' }
1104
+ : providerOutcome === 'COMMITTED'
1105
+ ? preTerminalAeb!
1106
+ : await reconcileAebWithRecovery(key, providerOutcome, input.aeb_recovery_authorization);
1107
+ if (providerOutcome !== 'ESCALATED' && reconciled.state === 'RECONCILIATION_REQUIRED') {
1108
+ return refusal('aeb_consumption_reconciliation_failed', {
1109
+ state: terminalState,
1110
+ consequence: { state: terminalState, attempt: clone(publicAttempt) },
1111
+ aeb: reconciled,
1112
+ });
1113
+ }
1114
+ return {
1115
+ ok: true,
1116
+ state: terminalState,
1117
+ outcome: providerOutcome,
1118
+ evidence_digest: provider.evidence_digest,
1119
+ reservation_key: key,
1120
+ consequence: { state: terminalState, attempt: clone(publicAttempt) },
1121
+ aeb: reconciled,
1122
+ };
1123
+ }
1124
+
1125
+ /**
1126
+ * Converge legacy or crash-window terminal consequence state with the AEB
1127
+ * reservation. This never invokes an effect and trusts only the signed
1128
+ * proposal/evaluation plus the durable consequence store's terminal state.
1129
+ */
1130
+ async function repairAeb(input: {
1131
+ proposal: unknown;
1132
+ evaluation: unknown;
1133
+ attempt: unknown;
1134
+ aeb_recovery_authorization?: unknown;
1135
+ }): Promise<JsonObject> {
1136
+ const { proposal } = verifyProposal(input?.proposal, { allowExpired: true });
1137
+ if (!isPlainObject(input?.evaluation)) return refusal('aeb_evaluation_missing');
1138
+ const record = input.evaluation as unknown as AebEvaluationRecord;
1139
+ if (record.operation_id !== proposal.operation_id
1140
+ || record.consumption_nonce !== proposal.aeb.consumption_nonce
1141
+ || record.initiator_id !== proposal.initiator_id
1142
+ || record.executor_id !== proposal.consequence.executor_id
1143
+ || record.requirement_ref !== proposal.aeb.requirement_ref
1144
+ || record.caid !== proposal.caid
1145
+ || record.composition?.action_digest !== expectedAebCompositionDigest(proposal)) {
1146
+ return refusal('aeb_evaluation_binding_mismatch');
1147
+ }
1148
+ let artifacts: Record<string, unknown>;
1149
+ try {
1150
+ artifacts = await options.aeb.resolve_artifacts({ proposal: clone(proposal), evaluation: clone(record) });
1151
+ } catch {
1152
+ return refusal('aeb_artifact_resolution_failed');
1153
+ }
1154
+ const historicalOptions: AebVerificationOptions & { executor_id: string } = {
1155
+ mode: 'historical',
1156
+ config: options.aeb.config,
1157
+ adapters: options.aeb.adapters,
1158
+ artifacts,
1159
+ expected_action: clone(proposal.action),
1160
+ executor_id: proposal.consequence.executor_id,
1161
+ };
1162
+ const historical = verifyAebEvaluation(record, historicalOptions);
1163
+ if (!historical.valid || record.verdict !== 'SATISFIED'
1164
+ || record.authority_constraints?.one_time_consumption !== true) {
1165
+ return refusal('aeb_evaluation_refused', { aeb: historical });
1166
+ }
1167
+ if (!exactKeys(input?.attempt, [
1168
+ 'tenant_id', 'provider_id', 'provider_account_id', 'environment',
1169
+ 'attempt_id', 'request_digest',
1170
+ ])) {
1171
+ return refusal('consequence_attempt_binding_mismatch');
1172
+ }
1173
+ const attempt = input.attempt as ConsequenceAttemptBinding;
1174
+ if (attempt.tenant_id !== proposal.consequence.tenant_id
1175
+ || attempt.provider_id !== proposal.consequence.provider_id
1176
+ || attempt.provider_account_id !== proposal.consequence.provider_account_id
1177
+ || attempt.environment !== proposal.consequence.environment
1178
+ || attempt.request_digest !== proposal.consequence.request_digest
1179
+ || typeof attempt.attempt_id !== 'string' || !IDENTIFIER_PATTERN.test(attempt.attempt_id)) {
1180
+ return refusal('consequence_attempt_binding_mismatch');
1181
+ }
1182
+ let snapshot: Awaited<ReturnType<NonNullable<ProposalToEffectConsequenceAttemptStore['read']>>>;
1183
+ try {
1184
+ snapshot = await options.consequence.store.read!(clone(attempt));
1185
+ } catch {
1186
+ return refusal('consequence_attempt_store_unavailable');
1187
+ }
1188
+ if (!snapshot) return refusal('consequence_attempt_not_found');
1189
+ if (!['COMMITTED', 'RELEASED', 'ESCALATED'].includes(snapshot.state)) {
1190
+ return refusal('consequence_attempt_not_terminal', { state: snapshot.state });
1191
+ }
1192
+ if (snapshot.state === 'ESCALATED') {
1193
+ return {
1194
+ ok: true,
1195
+ state: 'ESCALATED',
1196
+ consequence: { state: 'ESCALATED', attempt: clone(attempt) },
1197
+ aeb: { state: 'RECONCILIATION_REQUIRED', retry_allowed: false, reason: 'execution_escalated' },
1198
+ };
1199
+ }
1200
+ const key = aebReservationKey(record);
1201
+ const outcome = snapshot.state === 'COMMITTED' ? 'COMMITTED' : 'NOT_COMMITTED';
1202
+ const repaired = await reconcileAebWithRecovery(
1203
+ key,
1204
+ outcome,
1205
+ input.aeb_recovery_authorization,
1206
+ );
1207
+ const expectedState = outcome === 'COMMITTED' ? 'CONSUMED' : 'AVAILABLE';
1208
+ if (repaired.state !== expectedState) {
1209
+ return refusal('aeb_consumption_repair_failed', {
1210
+ state: snapshot.state,
1211
+ consequence: { state: snapshot.state, attempt: clone(attempt) },
1212
+ aeb: repaired,
1213
+ });
511
1214
  }
512
1215
  return {
513
1216
  ok: true,
514
- state: reconciled.state,
515
- outcome: provider.outcome,
516
- evidence_digest: provider.evidence_digest ?? null,
1217
+ state: snapshot.state,
1218
+ consequence: { state: snapshot.state, attempt: clone(attempt) },
517
1219
  reservation_key: key,
1220
+ aeb: repaired,
518
1221
  };
519
1222
  }
520
1223
 
@@ -561,6 +1264,8 @@ export function createProposalToEffect(options: ProposalToEffectOptions) {
561
1264
  pollApproval,
562
1265
  execute,
563
1266
  reconcile,
1267
+ repairAeb,
1268
+ getReconciliationHandle,
564
1269
  });
565
1270
  }
566
1271