@emilia-protocol/gate 0.14.0 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -1
- package/README.md +32 -1
- package/aeb-consumption-store.js +3 -0
- package/dist/aeb-consumption-store.d.ts +88 -0
- package/dist/aeb-consumption-store.d.ts.map +1 -0
- package/dist/aeb-consumption-store.js +471 -0
- package/dist/aeb-consumption-store.js.map +1 -0
- package/dist/capability-receipt.d.ts +8 -0
- package/dist/capability-receipt.d.ts.map +1 -1
- package/dist/capability-receipt.js +17 -0
- package/dist/capability-receipt.js.map +1 -1
- package/dist/index.d.ts +13 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -4
- package/dist/index.js.map +1 -1
- package/dist/proposal-to-effect-postgres.d.ts +144 -0
- package/dist/proposal-to-effect-postgres.d.ts.map +1 -0
- package/dist/proposal-to-effect-postgres.js +1578 -0
- package/dist/proposal-to-effect-postgres.js.map +1 -0
- package/dist/proposal-to-effect-status-head-store.d.ts +72 -0
- package/dist/proposal-to-effect-status-head-store.d.ts.map +1 -0
- package/dist/proposal-to-effect-status-head-store.js +355 -0
- package/dist/proposal-to-effect-status-head-store.js.map +1 -0
- package/dist/proposal-to-effect-status.d.ts +55 -0
- package/dist/proposal-to-effect-status.d.ts.map +1 -0
- package/dist/proposal-to-effect-status.js +294 -0
- package/dist/proposal-to-effect-status.js.map +1 -0
- package/dist/proposal-to-effect.d.ts +151 -3
- package/dist/proposal-to-effect.d.ts.map +1 -1
- package/dist/proposal-to-effect.js +589 -46
- package/dist/proposal-to-effect.js.map +1 -1
- package/dist/remedy-case-set-postgres.d.ts +73 -0
- package/dist/remedy-case-set-postgres.d.ts.map +1 -0
- package/dist/remedy-case-set-postgres.js +846 -0
- package/dist/remedy-case-set-postgres.js.map +1 -0
- package/dist/remedy-case-set.d.ts +53 -0
- package/dist/remedy-case-set.d.ts.map +1 -0
- package/dist/remedy-case-set.js +571 -0
- package/dist/remedy-case-set.js.map +1 -0
- package/dist/remedy-program-adapters.d.ts +137 -0
- package/dist/remedy-program-adapters.d.ts.map +1 -0
- package/dist/remedy-program-adapters.js +590 -0
- package/dist/remedy-program-adapters.js.map +1 -0
- package/dist/trust-program-revocation.js.map +1 -1
- package/package.json +283 -63
- package/proposal-to-effect-postgres.js +3 -0
- package/proposal-to-effect-status-head-store.js +2 -0
- package/proposal-to-effect-status.js +2 -0
- package/remedy-case-set-postgres.js +3 -0
- package/remedy-case-set.js +3 -0
- package/remedy-program-adapters.js +3 -0
- package/src/aeb-consumption-store.ts +568 -0
- package/src/capability-receipt.ts +17 -0
- package/src/index.ts +66 -3
- package/src/proposal-to-effect-postgres.ts +1833 -0
- package/src/proposal-to-effect-status-head-store.ts +449 -0
- package/src/proposal-to-effect-status.ts +388 -0
- package/src/proposal-to-effect.ts +755 -50
- package/src/remedy-case-set-postgres.ts +1008 -0
- package/src/remedy-case-set.ts +603 -0
- package/src/remedy-program-adapters.ts +657 -0
- 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', '
|
|
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 =
|
|
155
|
-
if (!Number.isFinite(createdAtMs))
|
|
156
|
-
throw new Error('proposal_time_invalid');
|
|
285
|
+
const createdAtMs = currentTime();
|
|
157
286
|
const actionDigest = approvalActionHash(normalized.action);
|
|
158
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
317
|
-
|
|
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 {
|
|
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 (
|
|
324
|
-
await
|
|
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
|
|
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
|
-
|
|
345
|
-
|
|
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
|
-
|
|
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 (
|
|
358
|
-
|
|
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
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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:
|
|
385
|
-
|
|
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 {
|