@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,12 +6,15 @@
6
6
  * Authority remains in EP-RECEIPT-v1 and the relying party's pinned AEB
7
7
  * requirement; consequence custody remains in Gate and its durable stores.
8
8
  */
9
+ import crypto from 'node:crypto';
9
10
  import { beginReceiptApproval, pollReceiptApproval, approvalActionHash, validateApprovalAuthorization, validateCaidSelector, validateRequiredFields, } from '@emilia-protocol/require-receipt/acquisition';
10
11
  import { aebReservationKey, authorizeAebExecutionDurable, digestAeb, pinnedConfigDigest, reconcileAebExecutionDurable, verifyAebEvaluation, } from '@emilia-protocol/verify/aeb-adapter-contract';
11
12
  import { actionDigest as aecActionDigest } from '@emilia-protocol/verify/evidence-chain';
12
13
  export const PROPOSAL_TO_EFFECT_VERSION = 'EMILIA-PROPOSAL-TO-EFFECT-v1';
13
14
  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}$/;
14
15
  const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{2,255}$/;
16
+ const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
17
+ const PROPOSAL_INTEGRITY_DOMAIN = `${PROPOSAL_TO_EFFECT_VERSION}:INTEGRITY\0`;
15
18
  function isPlainObject(value) {
16
19
  if (!value || typeof value !== 'object' || Array.isArray(value))
17
20
  return false;
@@ -26,6 +29,41 @@ function assertIdentifier(value, name) {
26
29
  throw new Error(`${name}_invalid`);
27
30
  }
28
31
  }
32
+ function validDigest(value) {
33
+ return typeof value === 'string' && DIGEST_PATTERN.test(value);
34
+ }
35
+ function canonicalInstant(value) {
36
+ if (typeof value !== 'string')
37
+ return NaN;
38
+ const parsed = Date.parse(value);
39
+ if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== value)
40
+ return NaN;
41
+ return parsed;
42
+ }
43
+ function exactKeys(value, keys) {
44
+ if (!isPlainObject(value))
45
+ return false;
46
+ const actual = Object.keys(value).sort();
47
+ const expected = [...keys].sort();
48
+ return actual.length === expected.length && expected.every((key, index) => key === actual[index]);
49
+ }
50
+ function secureConsequenceStore(store) {
51
+ return store !== null && typeof store === 'object'
52
+ && store.durable === true && store.ownershipFenced === true
53
+ && store.compareAndSwap === true && store.atomicEvidenceBinding === true
54
+ && typeof store.reserve === 'function' && typeof store.transition === 'function'
55
+ && typeof store.reconcile === 'function' && typeof store.read === 'function';
56
+ }
57
+ function normalizedCurrentStatus(value) {
58
+ return exactKeys(value, [
59
+ 'checked_at', 'expires_at', 'revocation_checked', 'revoked', 'consumed',
60
+ ...(isPlainObject(value) && Object.hasOwn(value, 'unavailable') ? ['unavailable'] : []),
61
+ ]) && typeof value.checked_at === 'string' && Number.isFinite(Date.parse(value.checked_at))
62
+ && typeof value.expires_at === 'string' && Number.isFinite(Date.parse(value.expires_at))
63
+ && typeof value.revocation_checked === 'boolean' && typeof value.revoked === 'boolean'
64
+ && typeof value.consumed === 'boolean'
65
+ && (value.unavailable === undefined || typeof value.unavailable === 'boolean');
66
+ }
29
67
  function assertProfile(profile) {
30
68
  if (!isPlainObject(profile))
31
69
  throw new Error('proposal_profile_invalid');
@@ -85,8 +123,8 @@ function assertSameObject(left, right, reason) {
85
123
  function exactProposalKeys(proposal) {
86
124
  const expected = [
87
125
  '@version', 'action', 'action_digest', 'aeb', 'aeb_action_digest', 'authorization',
88
- 'caid', 'challenge', 'created_at', 'expires_at', 'initiator_id', 'operation_id',
89
- 'profile_id', 'proposal_id',
126
+ 'caid', 'challenge', 'consequence', 'created_at', 'expires_at', 'initiator_id',
127
+ 'integrity', 'operation_id', 'profile_id', 'proposal_id',
90
128
  ].sort();
91
129
  const actual = Object.keys(proposal).sort();
92
130
  return expected.length === actual.length && expected.every((key, index) => key === actual[index]);
@@ -135,11 +173,104 @@ export function createProposalToEffect(options) {
135
173
  }
136
174
  if (!options.aeb?.config || !options.aeb.adapters || !options.aeb.store
137
175
  || typeof options.aeb.resolve_artifacts !== 'function'
176
+ || typeof options.aeb.currentStatusResolver !== 'function'
177
+ || typeof options.aeb.statusVerifier !== 'function'
138
178
  || typeof options.aeb.verify_provider_evidence !== 'function') {
139
179
  throw new Error('proposal_aeb_configuration_required');
140
180
  }
181
+ if (!(options.proposal_integrity?.hmac_sha256_key instanceof Uint8Array)
182
+ || options.proposal_integrity.hmac_sha256_key.byteLength < 32) {
183
+ throw new Error('proposal_integrity_configuration_required');
184
+ }
185
+ if (!options.consequence)
186
+ throw new Error('proposal_consequence_configuration_required');
187
+ assertIdentifier(options.consequence.tenant_id, 'proposal_tenant_id');
188
+ assertIdentifier(options.consequence.provider_id, 'proposal_provider_id');
189
+ assertIdentifier(options.consequence.provider_account_id, 'proposal_provider_account_id');
190
+ assertIdentifier(options.consequence.environment, 'proposal_environment');
191
+ assertIdentifier(options.consequence.executor_id, 'proposal_executor_id');
192
+ if (!secureConsequenceStore(options.consequence.store)
193
+ || (options.consequence.create_attempt_id !== undefined
194
+ && typeof options.consequence.create_attempt_id !== 'function')) {
195
+ throw new Error('proposal_consequence_store_required');
196
+ }
141
197
  const now = options.now ?? Date.now;
142
198
  const configDigest = pinnedConfigDigest(options.aeb.config);
199
+ const integrityKey = Buffer.from(options.proposal_integrity.hmac_sha256_key);
200
+ const consequenceContext = Object.freeze({
201
+ tenant_id: options.consequence.tenant_id,
202
+ provider_id: options.consequence.provider_id,
203
+ provider_account_id: options.consequence.provider_account_id,
204
+ environment: options.consequence.environment,
205
+ executor_id: options.consequence.executor_id,
206
+ });
207
+ // Active owner capabilities never become enumerable response/error data.
208
+ // The same-process service can recover a handle from the exact object; after
209
+ // restart it must use the durable store's separately authorized recovery API.
210
+ const reconciliationHandles = new WeakMap();
211
+ function rememberReconciliationHandle(target, reference) {
212
+ reconciliationHandles.set(target, reference);
213
+ return target;
214
+ }
215
+ function getReconciliationHandle(target) {
216
+ const reference = reconciliationHandles.get(target);
217
+ return reference ? clone(reference) : null;
218
+ }
219
+ function currentTime() {
220
+ const value = now();
221
+ if (!Number.isSafeInteger(value) || !Number.isFinite(new Date(value).getTime())) {
222
+ throw new Error('proposal_time_invalid');
223
+ }
224
+ return value;
225
+ }
226
+ async function reconcileAebWithRecovery(key, outcome, authorization) {
227
+ let result = await reconcileAebExecutionDurable(options.aeb.store, key, outcome);
228
+ const recoveryStore = options.aeb.store;
229
+ if (result.state === 'RECONCILIATION_REQUIRED'
230
+ && authorization !== undefined
231
+ && typeof recoveryStore.claimReservation === 'function'
232
+ && await recoveryStore.claimReservation(key, authorization).catch(() => false)) {
233
+ result = await reconcileAebExecutionDurable(options.aeb.store, key, outcome);
234
+ }
235
+ return result;
236
+ }
237
+ function requestDigestFor(proposal) {
238
+ return digestAeb({
239
+ domain: `${PROPOSAL_TO_EFFECT_VERSION}:REQUEST`,
240
+ ...consequenceContext,
241
+ proposal_id: proposal.proposal_id,
242
+ operation_id: proposal.operation_id,
243
+ initiator_id: proposal.initiator_id,
244
+ profile_id: proposal.profile_id,
245
+ action: proposal.action,
246
+ action_digest: proposal.action_digest,
247
+ aeb_action_digest: proposal.aeb_action_digest,
248
+ caid: proposal.caid,
249
+ created_at: proposal.created_at,
250
+ expires_at: proposal.expires_at,
251
+ challenge: proposal.challenge,
252
+ authorization: proposal.authorization,
253
+ aeb: proposal.aeb,
254
+ });
255
+ }
256
+ function integrityMac(unsignedProposal) {
257
+ return crypto.createHmac('sha256', integrityKey)
258
+ .update(PROPOSAL_INTEGRITY_DOMAIN)
259
+ .update(digestAeb(unsignedProposal))
260
+ .digest('base64url');
261
+ }
262
+ function proposalIntegrityValid(proposal) {
263
+ if (!exactKeys(proposal.integrity, ['alg', 'value'])
264
+ || proposal.integrity.alg !== 'HMAC-SHA256'
265
+ || typeof proposal.integrity.value !== 'string'
266
+ || !/^[A-Za-z0-9_-]{43}$/.test(proposal.integrity.value))
267
+ return false;
268
+ const unsigned = clone(proposal);
269
+ delete unsigned.integrity;
270
+ const actual = Buffer.from(proposal.integrity.value, 'base64url');
271
+ const expected = Buffer.from(integrityMac(unsigned), 'base64url');
272
+ return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
273
+ }
143
274
  function profileFor(id) {
144
275
  if (typeof id !== 'string' || !options.profiles[id])
145
276
  throw new Error('proposal_profile_not_pinned');
@@ -151,11 +282,9 @@ export function createProposalToEffect(options) {
151
282
  assertIdentifier(input?.initiator_id, 'proposal_initiator_id');
152
283
  const profile = profileFor(input?.profile_id);
153
284
  const normalized = canonicalizeForProfile(profile, input.action);
154
- const createdAtMs = now();
155
- if (!Number.isFinite(createdAtMs))
156
- throw new Error('proposal_time_invalid');
285
+ const createdAtMs = currentTime();
157
286
  const actionDigest = approvalActionHash(normalized.action);
158
- return clone({
287
+ const base = {
159
288
  '@version': PROPOSAL_TO_EFFECT_VERSION,
160
289
  proposal_id: input.proposal_id,
161
290
  operation_id: input.operation_id,
@@ -179,6 +308,17 @@ export function createProposalToEffect(options) {
179
308
  pinned_config_digest: configDigest,
180
309
  consumption_nonce: proposalToEffectConsumptionNonce(input.operation_id, configDigest),
181
310
  },
311
+ };
312
+ const unsigned = {
313
+ ...base,
314
+ consequence: {
315
+ ...consequenceContext,
316
+ request_digest: requestDigestFor(base),
317
+ },
318
+ };
319
+ return clone({
320
+ ...unsigned,
321
+ integrity: { alg: 'HMAC-SHA256', value: integrityMac(unsigned) },
182
322
  });
183
323
  }
184
324
  function verifyProposal(input, { allowExpired = false } = {}) {
@@ -186,6 +326,8 @@ export function createProposalToEffect(options) {
186
326
  || input['@version'] !== PROPOSAL_TO_EFFECT_VERSION) {
187
327
  throw new Error('proposal_shape_invalid');
188
328
  }
329
+ if (!proposalIntegrityValid(input))
330
+ throw new Error('proposal_integrity_invalid');
189
331
  const proposal = input;
190
332
  assertIdentifier(proposal.proposal_id, 'proposal_id');
191
333
  assertIdentifier(proposal.operation_id, 'proposal_operation_id');
@@ -201,11 +343,16 @@ export function createProposalToEffect(options) {
201
343
  if (digestAeb(normalized.action) !== proposal.aeb_action_digest) {
202
344
  throw new Error('proposal_aeb_action_digest_mismatch');
203
345
  }
204
- if (!Number.isFinite(Date.parse(proposal.created_at)) || !Number.isFinite(Date.parse(proposal.expires_at))
205
- || Date.parse(proposal.expires_at) <= Date.parse(proposal.created_at)) {
346
+ const createdAtMs = canonicalInstant(proposal.created_at);
347
+ const expiresAtMs = canonicalInstant(proposal.expires_at);
348
+ if (!Number.isFinite(createdAtMs) || !Number.isFinite(expiresAtMs))
206
349
  throw new Error('proposal_time_invalid');
207
- }
208
- if (!allowExpired && now() >= Date.parse(proposal.expires_at))
350
+ if (expiresAtMs - createdAtMs !== profile.ttl_sec * 1000)
351
+ throw new Error('proposal_ttl_mismatch');
352
+ const decisionTime = currentTime();
353
+ if (createdAtMs > decisionTime)
354
+ throw new Error('proposal_created_in_future');
355
+ if (!allowExpired && decisionTime >= expiresAtMs)
209
356
  throw new Error('proposal_expired');
210
357
  assertSameObject(proposal.authorization, profile.authorization, 'proposal_authorization_mismatch');
211
358
  if (!isPlainObject(proposal.aeb)
@@ -217,6 +364,16 @@ export function createProposalToEffect(options) {
217
364
  if (proposal.aeb.consumption_nonce !== proposalToEffectConsumptionNonce(proposal.operation_id, configDigest)) {
218
365
  throw new Error('proposal_aeb_nonce_mismatch');
219
366
  }
367
+ if (!exactKeys(proposal.consequence, [
368
+ 'tenant_id', 'provider_id', 'provider_account_id', 'environment', 'executor_id', 'request_digest',
369
+ ]) || proposal.consequence.tenant_id !== consequenceContext.tenant_id
370
+ || proposal.consequence.provider_id !== consequenceContext.provider_id
371
+ || proposal.consequence.provider_account_id !== consequenceContext.provider_account_id
372
+ || proposal.consequence.environment !== consequenceContext.environment
373
+ || proposal.consequence.executor_id !== consequenceContext.executor_id
374
+ || proposal.consequence.request_digest !== requestDigestFor(proposal)) {
375
+ throw new Error('proposal_consequence_binding_mismatch');
376
+ }
220
377
  const expectedChallenge = {
221
378
  action: profile.action_type,
222
379
  action_hash: proposal.action_digest,
@@ -233,10 +390,15 @@ export function createProposalToEffect(options) {
233
390
  if (record.operation_id !== proposal.operation_id
234
391
  || record.consumption_nonce !== proposal.aeb.consumption_nonce
235
392
  || record.initiator_id !== proposal.initiator_id
393
+ || record.executor_id !== proposal.consequence.executor_id
236
394
  || record.requirement_ref !== proposal.aeb.requirement_ref
237
395
  || record.caid !== proposal.caid) {
238
396
  return { valid: false, reason: 'aeb_evaluation_binding_mismatch', record };
239
397
  }
398
+ if (record.verdict === 'SATISFIED'
399
+ && record.composition?.action_digest !== expectedAebCompositionDigest(proposal)) {
400
+ return { valid: false, reason: 'aeb_evaluation_binding_mismatch', record };
401
+ }
240
402
  let artifacts;
241
403
  try {
242
404
  artifacts = await options.aeb.resolve_artifacts({ proposal: clone(proposal), evaluation: clone(record) });
@@ -244,19 +406,62 @@ export function createProposalToEffect(options) {
244
406
  catch {
245
407
  return { valid: false, reason: 'aeb_artifact_resolution_failed', record };
246
408
  }
247
- const checked = verifyAebEvaluation(record, {
409
+ const decisionNow = new Date(currentTime()).toISOString();
410
+ const currentStatuses = {};
411
+ for (const leg of record.legs ?? []) {
412
+ try {
413
+ const statusArtifact = await options.aeb.currentStatusResolver({
414
+ proposal: clone(proposal),
415
+ evaluation: clone(record),
416
+ leg: clone(leg),
417
+ });
418
+ if (statusArtifact === undefined || statusArtifact === null) {
419
+ return { valid: false, reason: 'aeb_current_status_refused', record };
420
+ }
421
+ const status = await options.aeb.statusVerifier({
422
+ status_artifact: clone(statusArtifact),
423
+ expected: {
424
+ tenant_id: proposal.consequence.tenant_id,
425
+ executor_id: proposal.consequence.executor_id,
426
+ operation_id: proposal.operation_id,
427
+ caid: proposal.caid,
428
+ artifact_ref: leg.artifact_ref,
429
+ evidence_digest: leg.evidence_digest,
430
+ replay_unit: leg.replay_unit,
431
+ },
432
+ now: decisionNow,
433
+ });
434
+ if (!status?.valid || status.outcome !== 'current_not_revoked'
435
+ || !normalizedCurrentStatus(status.status)
436
+ || status.status.revocation_checked !== true || status.status.revoked !== false
437
+ || status.status.consumed !== false || status.status.unavailable === true) {
438
+ return { valid: false, reason: 'aeb_current_status_refused', record };
439
+ }
440
+ currentStatuses[leg.artifact_ref] = clone(status.status);
441
+ }
442
+ catch {
443
+ return { valid: false, reason: 'aeb_current_status_refused', record };
444
+ }
445
+ }
446
+ if (record.legs.length === 0 || Object.keys(currentStatuses).length !== record.legs.length) {
447
+ return { valid: false, reason: 'aeb_current_status_refused', record };
448
+ }
449
+ const verificationOptions = {
450
+ mode: 'execution',
248
451
  config: options.aeb.config,
249
452
  adapters: options.aeb.adapters,
250
453
  artifacts,
251
- now: new Date(now()).toISOString(),
252
- });
253
- if (!checked.valid || record.verdict !== 'SATISFIED'
454
+ expected_action: clone(proposal.action),
455
+ executor_id: proposal.consequence.executor_id,
456
+ current_statuses: currentStatuses,
457
+ now: decisionNow,
458
+ };
459
+ const checked = verifyAebEvaluation(record, verificationOptions);
460
+ if (!checked.valid || checked.execution_authorizing !== true
461
+ || record.verdict !== 'SATISFIED'
254
462
  || record.authority_constraints?.one_time_consumption !== true) {
255
463
  return { valid: false, reason: 'aeb_evaluation_refused', record, checked };
256
464
  }
257
- if (record.composition?.action_digest !== expectedAebCompositionDigest(proposal)) {
258
- return { valid: false, reason: 'aeb_evaluation_binding_mismatch', record, checked };
259
- }
260
465
  return { valid: true, reason: null, record, checked };
261
466
  }
262
467
  function gateInput(proposal, profile, receipt, record) {
@@ -266,6 +471,12 @@ export function createProposalToEffect(options) {
266
471
  operation_id: proposal.operation_id,
267
472
  initiator_id: proposal.initiator_id,
268
473
  aeb_requirement_ref: proposal.aeb.requirement_ref,
474
+ tenant_id: proposal.consequence.tenant_id,
475
+ provider_id: proposal.consequence.provider_id,
476
+ provider_account_id: proposal.consequence.provider_account_id,
477
+ environment: proposal.consequence.environment,
478
+ executor_id: proposal.consequence.executor_id,
479
+ request_digest: proposal.consequence.request_digest,
269
480
  },
270
481
  receipt,
271
482
  observedAction: clone(proposal.action),
@@ -280,6 +491,9 @@ export function createProposalToEffect(options) {
280
491
  if (!evaluation.valid || !evaluation.record) {
281
492
  return refusal(evaluation.reason || 'aeb_evaluation_refused', { aeb: evaluation.checked ?? null });
282
493
  }
494
+ if (!evaluation.checked?.valid || evaluation.checked.execution_authorizing !== true) {
495
+ return refusal('aeb_execution_verification_required', { aeb: evaluation.checked ?? null });
496
+ }
283
497
  const preparedGateInput = gateInput(proposal, profile, input.receipt, evaluation.record);
284
498
  const preflight = await options.gate.check({ ...preparedGateInput, consumptionMode: 'none' });
285
499
  if (preflight.allow !== true) {
@@ -289,7 +503,7 @@ export function createProposalToEffect(options) {
289
503
  return refusal('gate_profile_not_receipt_guarded', { authorization: preflight });
290
504
  }
291
505
  const reservation = await authorizeAebExecutionDurable(evaluation.record, {
292
- verified: true,
506
+ verification: evaluation.checked,
293
507
  local_authorization: true,
294
508
  store: options.aeb.store,
295
509
  });
@@ -297,36 +511,160 @@ export function createProposalToEffect(options) {
297
511
  return refusal(reservation.reason === 'consumption_conflict' ? 'aeb_consumption_conflict' : reservation.reason, { aeb: reservation });
298
512
  }
299
513
  const key = reservation.reservation_key;
514
+ let attemptId;
300
515
  try {
301
- const result = await options.gate.run(preparedGateInput, async (authorization) => effect({
302
- action: clone(proposal.action),
303
- proposal: clone(proposal),
304
- authorization: clone(authorization),
305
- }));
516
+ attemptId = options.consequence.create_attempt_id
517
+ ? await options.consequence.create_attempt_id({
518
+ tenant_id: proposal.consequence.tenant_id,
519
+ request_digest: proposal.consequence.request_digest,
520
+ })
521
+ : `attempt:${crypto.randomUUID()}`;
522
+ assertIdentifier(attemptId, 'proposal_attempt_id');
523
+ }
524
+ catch {
525
+ await options.aeb.store.release(key).catch(() => false);
526
+ return refusal('consequence_attempt_allocation_failed');
527
+ }
528
+ const binding = {
529
+ tenant_id: proposal.consequence.tenant_id,
530
+ provider_id: proposal.consequence.provider_id,
531
+ provider_account_id: proposal.consequence.provider_account_id,
532
+ environment: proposal.consequence.environment,
533
+ attempt_id: attemptId,
534
+ request_digest: proposal.consequence.request_digest,
535
+ };
536
+ let reservedAttempt;
537
+ try {
538
+ reservedAttempt = await options.consequence.store.reserve(clone(binding));
539
+ }
540
+ catch {
541
+ await options.aeb.store.release(key).catch(() => false);
542
+ return refusal('consequence_attempt_store_unavailable');
543
+ }
544
+ if (!reservedAttempt?.reserved || typeof reservedAttempt.owner !== 'string'
545
+ || reservedAttempt.owner.length < 1 || reservedAttempt.owner.length > 1024) {
546
+ await options.aeb.store.release(key).catch(() => false);
547
+ const reason = reservedAttempt.reserved === false
548
+ ? reservedAttempt.reason : 'consequence_attempt_conflict';
549
+ return refusal(reason);
550
+ }
551
+ const attempt = {
552
+ ...binding,
553
+ owner: reservedAttempt.owner,
554
+ };
555
+ const attemptRef = {
556
+ tenant_id: attempt.tenant_id,
557
+ attempt_id: attempt.attempt_id,
558
+ owner: attempt.owner,
559
+ };
560
+ const attemptCustody = { state: 'RESERVED' };
561
+ const transition = async (change) => {
562
+ try {
563
+ const changed = await options.consequence.store.transition({
564
+ ...attemptRef,
565
+ ...change,
566
+ });
567
+ if (changed === true)
568
+ attemptCustody.state = change.next_state;
569
+ return changed === true;
570
+ }
571
+ catch {
572
+ return false;
573
+ }
574
+ };
575
+ if (!await transition({ expected_state: 'RESERVED', next_state: 'INVOKING' })) {
576
+ await options.aeb.store.release(key).catch(() => false);
577
+ return refusal('consequence_attempt_transition_conflict');
578
+ }
579
+ let callbackEntered = false;
580
+ const freeze = async () => (attemptCustody.state === 'INDETERMINATE'
581
+ || (attemptCustody.state === 'INVOKING'
582
+ && await transition({ expected_state: 'INVOKING', next_state: 'INDETERMINATE' })));
583
+ const attachAttempt = (thrown, outcome) => {
584
+ const error = thrown && (typeof thrown === 'object' || typeof thrown === 'function')
585
+ ? thrown : new Error(String(thrown));
586
+ error.proposalToEffect = {
587
+ ...(isPlainObject(error.proposalToEffect) ? error.proposalToEffect : {}),
588
+ ...(outcome ? { outcome } : {}),
589
+ reservation_key: key,
590
+ attempt: clone(binding),
591
+ attempt_state: attemptCustody.state,
592
+ };
593
+ return rememberReconciliationHandle(error, attemptRef);
594
+ };
595
+ try {
596
+ const result = await options.gate.run(preparedGateInput, async (authorization) => {
597
+ callbackEntered = true;
598
+ return effect({
599
+ action: clone(proposal.action),
600
+ proposal: clone(proposal),
601
+ authorization: clone(authorization),
602
+ attempt: clone(binding),
603
+ });
604
+ });
605
+ if (!await freeze()) {
606
+ throw attachAttempt(new Error('consequence_attempt_freeze_failed'), 'indeterminate');
607
+ }
306
608
  if (result?.ok !== true) {
307
- await options.aeb.store.release(key);
609
+ if (callbackEntered) {
610
+ return rememberReconciliationHandle(refusal(result?.authorization?.reason || result?.reason || 'gate_refused', {
611
+ authorization: result?.authorization ?? null,
612
+ consequence: { state: 'INDETERMINATE', attempt: clone(binding) },
613
+ }), attemptRef);
614
+ }
615
+ if (!await transition({ expected_state: 'INDETERMINATE', next_state: 'RELEASED' })) {
616
+ return rememberReconciliationHandle(refusal('consequence_attempt_transition_conflict', {
617
+ consequence: { state: 'INDETERMINATE', attempt: clone(binding) },
618
+ }), attemptRef);
619
+ }
620
+ await options.aeb.store.release(key).catch(() => false);
308
621
  return refusal(result?.authorization?.reason || result?.reason || 'gate_refused', {
309
622
  authorization: result?.authorization ?? null,
623
+ consequence: { state: 'RELEASED', attempt: clone(binding) },
624
+ });
625
+ }
626
+ if (!callbackEntered) {
627
+ if (await transition({ expected_state: 'INDETERMINATE', next_state: 'RELEASED' })) {
628
+ await options.aeb.store.release(key).catch(() => false);
629
+ }
630
+ const response = refusal('gate_effect_not_invoked', {
631
+ consequence: { state: attemptCustody.state, attempt: clone(binding) },
310
632
  });
633
+ return attemptCustody.state === 'INDETERMINATE'
634
+ ? rememberReconciliationHandle(response, attemptRef) : response;
311
635
  }
312
- const committed = await reconcileAebExecutionDurable(options.aeb.store, key, 'COMMITTED');
636
+ const committed = await reconcileAebWithRecovery(key, 'COMMITTED');
313
637
  if (committed.state !== 'CONSUMED') {
314
638
  const error = new Error('aeb_consumption_commit_failed');
315
639
  error.code = 'EMILIA_PROPOSAL_TO_EFFECT_COMMIT_FAILED';
316
- error.proposalToEffect = { outcome: 'executed', reservation_key: key };
317
- throw error;
640
+ throw attachAttempt(error, 'indeterminate');
641
+ }
642
+ if (!await transition({ expected_state: 'INDETERMINATE', next_state: 'COMMITTED' })) {
643
+ throw attachAttempt(new Error('consequence_attempt_commit_failed'), 'executed');
318
644
  }
319
- return { ...result, proposal: clone(proposal), aeb: committed };
645
+ return {
646
+ ...result,
647
+ proposal: clone(proposal),
648
+ aeb: committed,
649
+ consequence: { state: 'COMMITTED', attempt: clone(binding) },
650
+ };
320
651
  }
321
652
  catch (error) {
322
653
  const outcome = error?.proposalToEffect?.outcome ?? error?.emiliaGateOutcome?.outcome;
323
- if (outcome === 'executed') {
324
- await reconcileAebExecutionDurable(options.aeb.store, key, 'COMMITTED');
654
+ if (attemptCustody.state === 'INVOKING')
655
+ await freeze();
656
+ if (attemptCustody.state === 'INDETERMINATE' && outcome === 'executed') {
657
+ const committed = await reconcileAebWithRecovery(key, 'COMMITTED');
658
+ if (committed.state === 'CONSUMED') {
659
+ await transition({ expected_state: 'INDETERMINATE', next_state: 'COMMITTED' });
660
+ }
325
661
  }
326
- else if (outcome !== 'indeterminate') {
327
- await options.aeb.store.release(key);
662
+ else if (attemptCustody.state === 'INDETERMINATE' && !callbackEntered && outcome !== 'indeterminate') {
663
+ if (await transition({ expected_state: 'INDETERMINATE', next_state: 'RELEASED' })) {
664
+ await options.aeb.store.release(key).catch(() => false);
665
+ }
328
666
  }
329
- throw error;
667
+ throw attachAttempt(error, callbackEntered ? (outcome || 'indeterminate') : outcome);
330
668
  }
331
669
  }
332
670
  async function reconcile(input) {
@@ -337,26 +675,57 @@ export function createProposalToEffect(options) {
337
675
  if (record.operation_id !== proposal.operation_id
338
676
  || record.consumption_nonce !== proposal.aeb.consumption_nonce
339
677
  || record.initiator_id !== proposal.initiator_id
678
+ || record.executor_id !== proposal.consequence.executor_id
340
679
  || record.requirement_ref !== proposal.aeb.requirement_ref
341
680
  || record.caid !== proposal.caid) {
342
681
  return refusal('aeb_evaluation_binding_mismatch');
343
682
  }
344
- const artifacts = await options.aeb.resolve_artifacts({ proposal: clone(proposal), evaluation: clone(record) });
345
- const historical = verifyAebEvaluation(record, {
683
+ if (record.composition?.action_digest !== expectedAebCompositionDigest(proposal)) {
684
+ return refusal('aeb_evaluation_binding_mismatch');
685
+ }
686
+ let artifacts;
687
+ try {
688
+ artifacts = await options.aeb.resolve_artifacts({ proposal: clone(proposal), evaluation: clone(record) });
689
+ }
690
+ catch {
691
+ return refusal('aeb_artifact_resolution_failed');
692
+ }
693
+ const historicalOptions = {
694
+ mode: 'historical',
346
695
  config: options.aeb.config,
347
696
  adapters: options.aeb.adapters,
348
697
  artifacts,
349
- now: record.evaluated_at,
350
- });
698
+ expected_action: clone(proposal.action),
699
+ executor_id: proposal.consequence.executor_id,
700
+ };
701
+ const historical = verifyAebEvaluation(record, historicalOptions);
351
702
  if (!historical.valid)
352
703
  return refusal('aeb_evaluation_refused', { aeb: historical });
353
704
  if (record.verdict !== 'SATISFIED'
354
705
  || record.authority_constraints?.one_time_consumption !== true) {
355
706
  return refusal('aeb_evaluation_refused', { aeb: historical });
356
707
  }
357
- if (record.composition?.action_digest !== expectedAebCompositionDigest(proposal)) {
358
- return refusal('aeb_evaluation_binding_mismatch');
708
+ if (!isPlainObject(input?.attempt)
709
+ || input.attempt.tenant_id !== proposal.consequence.tenant_id
710
+ || typeof input.attempt.attempt_id !== 'string'
711
+ || !IDENTIFIER_PATTERN.test(input.attempt.attempt_id)
712
+ || typeof input.attempt.owner !== 'string'
713
+ || input.attempt.owner.length < 1 || input.attempt.owner.length > 1024) {
714
+ return refusal('consequence_attempt_binding_mismatch');
359
715
  }
716
+ const attemptRef = {
717
+ tenant_id: proposal.consequence.tenant_id,
718
+ attempt_id: input.attempt.attempt_id,
719
+ owner: input.attempt.owner,
720
+ };
721
+ const publicAttempt = {
722
+ tenant_id: proposal.consequence.tenant_id,
723
+ provider_id: proposal.consequence.provider_id,
724
+ provider_account_id: proposal.consequence.provider_account_id,
725
+ environment: proposal.consequence.environment,
726
+ attempt_id: attemptRef.attempt_id,
727
+ request_digest: proposal.consequence.request_digest,
728
+ };
360
729
  let provider;
361
730
  try {
362
731
  provider = await options.aeb.verify_provider_evidence({
@@ -365,26 +734,198 @@ export function createProposalToEffect(options) {
365
734
  operation_id: proposal.operation_id,
366
735
  caid: proposal.caid,
367
736
  action_digest: proposal.aeb_action_digest,
737
+ tenant_id: proposal.consequence.tenant_id,
738
+ request_digest: proposal.consequence.request_digest,
739
+ provider_id: proposal.consequence.provider_id,
740
+ provider_account_id: proposal.consequence.provider_account_id,
741
+ environment: proposal.consequence.environment,
742
+ attempt_id: attemptRef.attempt_id,
368
743
  },
369
744
  });
370
745
  }
371
746
  catch {
372
747
  return refusal('provider_evidence_unverified');
373
748
  }
374
- if (!provider?.valid || (provider.outcome !== 'COMMITTED' && provider.outcome !== 'NOT_COMMITTED')) {
749
+ const observedAtMs = typeof provider?.observed_at === 'string'
750
+ ? Date.parse(provider.observed_at) : NaN;
751
+ const proposalCreatedAtMs = Date.parse(proposal.created_at);
752
+ if (!provider?.valid || !['COMMITTED', 'NOT_COMMITTED', 'ESCALATED'].includes(provider.outcome ?? '')
753
+ || typeof provider.evidence_id !== 'string' || !IDENTIFIER_PATTERN.test(provider.evidence_id)
754
+ || !Number.isFinite(observedAtMs) || observedAtMs < proposalCreatedAtMs
755
+ || observedAtMs > currentTime()
756
+ || !validDigest(provider.evidence_digest)) {
375
757
  return refusal(provider?.reason || 'provider_evidence_unverified');
376
758
  }
759
+ if (provider.tenant_id !== proposal.consequence.tenant_id
760
+ || provider.request_digest !== proposal.consequence.request_digest
761
+ || provider.provider_id !== proposal.consequence.provider_id
762
+ || provider.provider_account_id !== proposal.consequence.provider_account_id
763
+ || provider.environment !== proposal.consequence.environment
764
+ || provider.attempt_id !== attemptRef.attempt_id
765
+ || provider.operation_id !== proposal.operation_id
766
+ || provider.caid !== proposal.caid
767
+ || provider.action_digest !== proposal.aeb_action_digest) {
768
+ return refusal('provider_evidence_binding_mismatch');
769
+ }
770
+ const providerOutcome = provider.outcome;
771
+ const evidence = {
772
+ operation_id: proposal.operation_id,
773
+ caid: proposal.caid,
774
+ action_digest: proposal.aeb_action_digest,
775
+ tenant_id: proposal.consequence.tenant_id,
776
+ provider_id: proposal.consequence.provider_id,
777
+ provider_account_id: proposal.consequence.provider_account_id,
778
+ environment: proposal.consequence.environment,
779
+ attempt_id: attemptRef.attempt_id,
780
+ request_digest: proposal.consequence.request_digest,
781
+ evidence_id: provider.evidence_id,
782
+ observed_at: provider.observed_at,
783
+ outcome: providerOutcome,
784
+ evidence_digest: provider.evidence_digest,
785
+ };
786
+ const terminalState = providerOutcome === 'COMMITTED'
787
+ ? 'COMMITTED' : providerOutcome === 'NOT_COMMITTED' ? 'RELEASED' : 'ESCALATED';
377
788
  const key = aebReservationKey(record);
378
- const reconciled = await reconcileAebExecutionDurable(options.aeb.store, key, provider.outcome);
379
- if (reconciled.state === 'RECONCILIATION_REQUIRED') {
380
- return refusal(reconciled.reason, { state: reconciled.state });
789
+ // For a committed real-world effect, consume replay authority before the
790
+ // attempt becomes terminal. A failed AEB commit leaves the attempt
791
+ // INDETERMINATE and therefore repairable, never terminal split-brain.
792
+ const preTerminalAeb = providerOutcome === 'COMMITTED'
793
+ ? await reconcileAebWithRecovery(key, 'COMMITTED', input.aeb_recovery_authorization)
794
+ : null;
795
+ if (providerOutcome === 'COMMITTED' && preTerminalAeb?.state !== 'CONSUMED') {
796
+ return refusal('aeb_consumption_reconciliation_failed', {
797
+ state: 'INDETERMINATE',
798
+ consequence: { state: 'INDETERMINATE', attempt: clone(publicAttempt) },
799
+ aeb: preTerminalAeb,
800
+ });
801
+ }
802
+ let attemptReconciled = false;
803
+ try {
804
+ attemptReconciled = await options.consequence.store.reconcile({
805
+ ...attemptRef,
806
+ expected_state: 'INDETERMINATE',
807
+ next_state: terminalState,
808
+ evidence: clone(evidence),
809
+ });
810
+ }
811
+ catch {
812
+ return refusal('consequence_attempt_store_unavailable');
813
+ }
814
+ if (!attemptReconciled)
815
+ return refusal('consequence_attempt_not_indeterminate');
816
+ const reconciled = providerOutcome === 'ESCALATED'
817
+ ? { state: 'RECONCILIATION_REQUIRED', retry_allowed: false, reason: 'execution_escalated' }
818
+ : providerOutcome === 'COMMITTED'
819
+ ? preTerminalAeb
820
+ : await reconcileAebWithRecovery(key, providerOutcome, input.aeb_recovery_authorization);
821
+ if (providerOutcome !== 'ESCALATED' && reconciled.state === 'RECONCILIATION_REQUIRED') {
822
+ return refusal('aeb_consumption_reconciliation_failed', {
823
+ state: terminalState,
824
+ consequence: { state: terminalState, attempt: clone(publicAttempt) },
825
+ aeb: reconciled,
826
+ });
827
+ }
828
+ return {
829
+ ok: true,
830
+ state: terminalState,
831
+ outcome: providerOutcome,
832
+ evidence_digest: provider.evidence_digest,
833
+ reservation_key: key,
834
+ consequence: { state: terminalState, attempt: clone(publicAttempt) },
835
+ aeb: reconciled,
836
+ };
837
+ }
838
+ /**
839
+ * Converge legacy or crash-window terminal consequence state with the AEB
840
+ * reservation. This never invokes an effect and trusts only the signed
841
+ * proposal/evaluation plus the durable consequence store's terminal state.
842
+ */
843
+ async function repairAeb(input) {
844
+ const { proposal } = verifyProposal(input?.proposal, { allowExpired: true });
845
+ if (!isPlainObject(input?.evaluation))
846
+ return refusal('aeb_evaluation_missing');
847
+ const record = input.evaluation;
848
+ if (record.operation_id !== proposal.operation_id
849
+ || record.consumption_nonce !== proposal.aeb.consumption_nonce
850
+ || record.initiator_id !== proposal.initiator_id
851
+ || record.executor_id !== proposal.consequence.executor_id
852
+ || record.requirement_ref !== proposal.aeb.requirement_ref
853
+ || record.caid !== proposal.caid
854
+ || record.composition?.action_digest !== expectedAebCompositionDigest(proposal)) {
855
+ return refusal('aeb_evaluation_binding_mismatch');
856
+ }
857
+ let artifacts;
858
+ try {
859
+ artifacts = await options.aeb.resolve_artifacts({ proposal: clone(proposal), evaluation: clone(record) });
860
+ }
861
+ catch {
862
+ return refusal('aeb_artifact_resolution_failed');
863
+ }
864
+ const historicalOptions = {
865
+ mode: 'historical',
866
+ config: options.aeb.config,
867
+ adapters: options.aeb.adapters,
868
+ artifacts,
869
+ expected_action: clone(proposal.action),
870
+ executor_id: proposal.consequence.executor_id,
871
+ };
872
+ const historical = verifyAebEvaluation(record, historicalOptions);
873
+ if (!historical.valid || record.verdict !== 'SATISFIED'
874
+ || record.authority_constraints?.one_time_consumption !== true) {
875
+ return refusal('aeb_evaluation_refused', { aeb: historical });
876
+ }
877
+ if (!exactKeys(input?.attempt, [
878
+ 'tenant_id', 'provider_id', 'provider_account_id', 'environment',
879
+ 'attempt_id', 'request_digest',
880
+ ])) {
881
+ return refusal('consequence_attempt_binding_mismatch');
882
+ }
883
+ const attempt = input.attempt;
884
+ if (attempt.tenant_id !== proposal.consequence.tenant_id
885
+ || attempt.provider_id !== proposal.consequence.provider_id
886
+ || attempt.provider_account_id !== proposal.consequence.provider_account_id
887
+ || attempt.environment !== proposal.consequence.environment
888
+ || attempt.request_digest !== proposal.consequence.request_digest
889
+ || typeof attempt.attempt_id !== 'string' || !IDENTIFIER_PATTERN.test(attempt.attempt_id)) {
890
+ return refusal('consequence_attempt_binding_mismatch');
891
+ }
892
+ let snapshot;
893
+ try {
894
+ snapshot = await options.consequence.store.read(clone(attempt));
895
+ }
896
+ catch {
897
+ return refusal('consequence_attempt_store_unavailable');
898
+ }
899
+ if (!snapshot)
900
+ return refusal('consequence_attempt_not_found');
901
+ if (!['COMMITTED', 'RELEASED', 'ESCALATED'].includes(snapshot.state)) {
902
+ return refusal('consequence_attempt_not_terminal', { state: snapshot.state });
903
+ }
904
+ if (snapshot.state === 'ESCALATED') {
905
+ return {
906
+ ok: true,
907
+ state: 'ESCALATED',
908
+ consequence: { state: 'ESCALATED', attempt: clone(attempt) },
909
+ aeb: { state: 'RECONCILIATION_REQUIRED', retry_allowed: false, reason: 'execution_escalated' },
910
+ };
911
+ }
912
+ const key = aebReservationKey(record);
913
+ const outcome = snapshot.state === 'COMMITTED' ? 'COMMITTED' : 'NOT_COMMITTED';
914
+ const repaired = await reconcileAebWithRecovery(key, outcome, input.aeb_recovery_authorization);
915
+ const expectedState = outcome === 'COMMITTED' ? 'CONSUMED' : 'AVAILABLE';
916
+ if (repaired.state !== expectedState) {
917
+ return refusal('aeb_consumption_repair_failed', {
918
+ state: snapshot.state,
919
+ consequence: { state: snapshot.state, attempt: clone(attempt) },
920
+ aeb: repaired,
921
+ });
381
922
  }
382
923
  return {
383
924
  ok: true,
384
- state: reconciled.state,
385
- outcome: provider.outcome,
386
- evidence_digest: provider.evidence_digest ?? null,
925
+ state: snapshot.state,
926
+ consequence: { state: snapshot.state, attempt: clone(attempt) },
387
927
  reservation_key: key,
928
+ aeb: repaired,
388
929
  };
389
930
  }
390
931
  async function beginApproval(input) {
@@ -417,6 +958,8 @@ export function createProposalToEffect(options) {
417
958
  pollApproval,
418
959
  execute,
419
960
  reconcile,
961
+ repairAeb,
962
+ getReconciliationHandle,
420
963
  });
421
964
  }
422
965
  export default {