@emilia-protocol/gate 0.13.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.
- package/CHANGELOG.md +53 -0
- package/README.md +89 -0
- package/aeb-consumption-store.js +3 -0
- package/dist/aeb-consumption-store.d.ts +80 -0
- package/dist/aeb-consumption-store.d.ts.map +1 -0
- package/dist/aeb-consumption-store.js +430 -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 +17 -4
- package/dist/index.js.map +1 -1
- package/dist/proposal-to-effect-postgres.d.ts +129 -0
- package/dist/proposal-to-effect-postgres.d.ts.map +1 -0
- package/dist/proposal-to-effect-postgres.js +1511 -0
- package/dist/proposal-to-effect-postgres.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 +280 -0
- package/dist/proposal-to-effect-status.js.map +1 -0
- package/dist/proposal-to-effect.d.ts +294 -0
- package/dist/proposal-to-effect.d.ts.map +1 -0
- package/dist/proposal-to-effect.js +970 -0
- package/dist/proposal-to-effect.js.map +1 -0
- 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 +280 -63
- package/proposal-to-effect-postgres.js +3 -0
- package/proposal-to-effect-status.js +2 -0
- package/proposal-to-effect.js +4 -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 +521 -0
- package/src/capability-receipt.ts +17 -0
- package/src/index.ts +57 -3
- package/src/proposal-to-effect-postgres.ts +1740 -0
- package/src/proposal-to-effect-status.ts +357 -0
- package/src/proposal-to-effect.ts +1276 -0
- 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
|
@@ -0,0 +1,1276 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Proposal-to-Effect is a product orchestration profile over existing EMILIA
|
|
4
|
+
* artifacts. A proposal is deliberately NOT a bearer authorization object.
|
|
5
|
+
* Authority remains in EP-RECEIPT-v1 and the relying party's pinned AEB
|
|
6
|
+
* requirement; consequence custody remains in Gate and its durable stores.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import {
|
|
11
|
+
beginReceiptApproval,
|
|
12
|
+
pollReceiptApproval,
|
|
13
|
+
approvalActionHash,
|
|
14
|
+
EP_APPROVAL_FLOW,
|
|
15
|
+
validateApprovalAuthorization,
|
|
16
|
+
validateCaidSelector,
|
|
17
|
+
validateRequiredFields,
|
|
18
|
+
} from '@emilia-protocol/require-receipt/acquisition';
|
|
19
|
+
import {
|
|
20
|
+
aebReservationKey,
|
|
21
|
+
authorizeAebExecutionDurable,
|
|
22
|
+
digestAeb,
|
|
23
|
+
pinnedConfigDigest,
|
|
24
|
+
reconcileAebExecutionDurable,
|
|
25
|
+
verifyAebEvaluation,
|
|
26
|
+
type AebAdapter,
|
|
27
|
+
type AebDigest,
|
|
28
|
+
type AebDurableConsumptionStore,
|
|
29
|
+
type AebEvaluationRecord,
|
|
30
|
+
type AebPinnedConfig,
|
|
31
|
+
type AebStatusInput,
|
|
32
|
+
type AebVerificationOptions,
|
|
33
|
+
} from '@emilia-protocol/verify/aeb-adapter-contract';
|
|
34
|
+
import { actionDigest as aecActionDigest } from '@emilia-protocol/verify/evidence-chain';
|
|
35
|
+
|
|
36
|
+
export const PROPOSAL_TO_EFFECT_VERSION = 'EMILIA-PROPOSAL-TO-EFFECT-v1';
|
|
37
|
+
|
|
38
|
+
type JsonObject = Record<string, any>;
|
|
39
|
+
type FetchLike = typeof fetch;
|
|
40
|
+
|
|
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}$/;
|
|
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
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface ProposalToEffectProfile {
|
|
114
|
+
id: string;
|
|
115
|
+
action_type: string;
|
|
116
|
+
selector: JsonObject;
|
|
117
|
+
required_fields: readonly string[];
|
|
118
|
+
authorization: {
|
|
119
|
+
authorization_endpoint: string;
|
|
120
|
+
flow: typeof EP_APPROVAL_FLOW;
|
|
121
|
+
};
|
|
122
|
+
aeb_requirement_ref: string;
|
|
123
|
+
ttl_sec: number;
|
|
124
|
+
/**
|
|
125
|
+
* Relying-party-controlled canonicalization and CAID derivation. It runs on
|
|
126
|
+
* both proposal creation and execution. Never select it from presented data.
|
|
127
|
+
*/
|
|
128
|
+
canonicalize_action(input: unknown): { action: JsonObject; caid: string };
|
|
129
|
+
caid_selector?: { field: string };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface ProposalToEffectProposal {
|
|
133
|
+
'@version': typeof PROPOSAL_TO_EFFECT_VERSION;
|
|
134
|
+
proposal_id: string;
|
|
135
|
+
operation_id: string;
|
|
136
|
+
initiator_id: string;
|
|
137
|
+
profile_id: string;
|
|
138
|
+
action: JsonObject;
|
|
139
|
+
action_digest: string;
|
|
140
|
+
aeb_action_digest: AebDigest;
|
|
141
|
+
caid: string;
|
|
142
|
+
created_at: string;
|
|
143
|
+
expires_at: string;
|
|
144
|
+
challenge: {
|
|
145
|
+
action: string;
|
|
146
|
+
action_hash: string;
|
|
147
|
+
required_fields: string[];
|
|
148
|
+
caid_selector?: { field: string };
|
|
149
|
+
};
|
|
150
|
+
authorization: {
|
|
151
|
+
authorization_endpoint: string;
|
|
152
|
+
flow: typeof EP_APPROVAL_FLOW;
|
|
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
|
+
};
|
|
162
|
+
aeb: {
|
|
163
|
+
requirement_ref: string;
|
|
164
|
+
pinned_config_digest: AebDigest;
|
|
165
|
+
consumption_nonce: AebDigest;
|
|
166
|
+
};
|
|
167
|
+
integrity: {
|
|
168
|
+
alg: 'HMAC-SHA256';
|
|
169
|
+
value: string;
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface ProposalToEffectGate {
|
|
174
|
+
check(input: JsonObject): Promise<JsonObject>;
|
|
175
|
+
run(input: JsonObject, effect: (authorization: JsonObject) => unknown | Promise<unknown>): Promise<JsonObject>;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface ProposalToEffectProviderVerification {
|
|
179
|
+
valid: boolean;
|
|
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;
|
|
201
|
+
reason?: string;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface ProposalToEffectOptions {
|
|
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
|
+
};
|
|
223
|
+
profiles: Record<string, ProposalToEffectProfile>;
|
|
224
|
+
aeb: {
|
|
225
|
+
config: AebPinnedConfig;
|
|
226
|
+
adapters: Record<string, AebAdapter>;
|
|
227
|
+
store: AebDurableConsumptionStore;
|
|
228
|
+
resolve_artifacts(input: {
|
|
229
|
+
proposal: ProposalToEffectProposal;
|
|
230
|
+
evaluation: AebEvaluationRecord;
|
|
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;
|
|
251
|
+
verify_provider_evidence(input: {
|
|
252
|
+
evidence: unknown;
|
|
253
|
+
expected: {
|
|
254
|
+
operation_id: string;
|
|
255
|
+
caid: string;
|
|
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;
|
|
263
|
+
};
|
|
264
|
+
}): Promise<ProposalToEffectProviderVerification> | ProposalToEffectProviderVerification;
|
|
265
|
+
};
|
|
266
|
+
now?: () => number;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function isPlainObject(value: unknown): value is JsonObject {
|
|
270
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
271
|
+
const prototype = Object.getPrototypeOf(value);
|
|
272
|
+
return prototype === Object.prototype || prototype === null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function clone<T>(value: T): T {
|
|
276
|
+
return structuredClone(value);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function assertIdentifier(value: unknown, name: string): asserts value is string {
|
|
280
|
+
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
|
281
|
+
throw new Error(`${name}_invalid`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
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
|
+
|
|
322
|
+
function assertProfile(profile: ProposalToEffectProfile): void {
|
|
323
|
+
if (!isPlainObject(profile)) throw new Error('proposal_profile_invalid');
|
|
324
|
+
assertIdentifier(profile.id, 'proposal_profile_id');
|
|
325
|
+
assertIdentifier(profile.action_type, 'proposal_action_type');
|
|
326
|
+
assertIdentifier(profile.aeb_requirement_ref, 'proposal_aeb_requirement_ref');
|
|
327
|
+
if (!isPlainObject(profile.selector) || Object.keys(profile.selector).length === 0) {
|
|
328
|
+
throw new Error('proposal_selector_invalid');
|
|
329
|
+
}
|
|
330
|
+
const requiredFields = validateRequiredFields(profile.required_fields);
|
|
331
|
+
if (!requiredFields.ok) throw new Error(requiredFields.reason);
|
|
332
|
+
if (!Number.isSafeInteger(profile.ttl_sec) || profile.ttl_sec <= 0 || profile.ttl_sec > 86_400) {
|
|
333
|
+
throw new Error('proposal_ttl_invalid');
|
|
334
|
+
}
|
|
335
|
+
if (typeof profile.canonicalize_action !== 'function') {
|
|
336
|
+
throw new Error('proposal_canonicalizer_required');
|
|
337
|
+
}
|
|
338
|
+
const authorization = validateApprovalAuthorization(profile.authorization);
|
|
339
|
+
if (!authorization.ok) throw new Error(authorization.reason);
|
|
340
|
+
if (profile.caid_selector) {
|
|
341
|
+
const caidSelector = validateCaidSelector(profile.caid_selector);
|
|
342
|
+
if (!caidSelector.ok) throw new Error(caidSelector.reason);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function canonicalizeForProfile(profile: ProposalToEffectProfile, input: unknown): { action: JsonObject; caid: string } {
|
|
347
|
+
const normalized = profile.canonicalize_action(clone(input));
|
|
348
|
+
if (!isPlainObject(normalized) || !isPlainObject(normalized.action)
|
|
349
|
+
|| typeof normalized.caid !== 'string' || !CAID_PATTERN.test(normalized.caid)) {
|
|
350
|
+
throw new Error('proposal_canonicalization_invalid');
|
|
351
|
+
}
|
|
352
|
+
if (normalized.action.action_type !== profile.action_type) {
|
|
353
|
+
throw new Error('proposal_action_type_mismatch');
|
|
354
|
+
}
|
|
355
|
+
for (const field of profile.required_fields) {
|
|
356
|
+
if (!Object.hasOwn(normalized.action, field) || normalized.action[field] === undefined) {
|
|
357
|
+
throw new Error(`proposal_required_field_missing:${field}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (profile.caid_selector) {
|
|
361
|
+
const field = profile.caid_selector.field;
|
|
362
|
+
if (typeof field !== 'string' || normalized.action[field] !== normalized.caid) {
|
|
363
|
+
throw new Error(`proposal_caid_binding_invalid:${field}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// Both digest functions reject unsupported/non-canonical JSON values.
|
|
367
|
+
approvalActionHash(normalized.action);
|
|
368
|
+
digestAeb(normalized.action);
|
|
369
|
+
return { action: clone(normalized.action), caid: normalized.caid };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function assertSameObject(left: unknown, right: unknown, reason: string): void {
|
|
373
|
+
if (digestAeb(left) !== digestAeb(right)) throw new Error(reason);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function exactProposalKeys(proposal: JsonObject): boolean {
|
|
377
|
+
const expected = [
|
|
378
|
+
'@version', 'action', 'action_digest', 'aeb', 'aeb_action_digest', 'authorization',
|
|
379
|
+
'caid', 'challenge', 'consequence', 'created_at', 'expires_at', 'initiator_id',
|
|
380
|
+
'integrity', 'operation_id', 'profile_id', 'proposal_id',
|
|
381
|
+
].sort();
|
|
382
|
+
const actual = Object.keys(proposal).sort();
|
|
383
|
+
return expected.length === actual.length && expected.every((key, index) => key === actual[index]);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function proposalAdmissibility(proposal: ProposalToEffectProposal, record: AebEvaluationRecord): JsonObject {
|
|
387
|
+
return {
|
|
388
|
+
admissibility_profile: { id: `aeb:${proposal.aeb.requirement_ref}`, version: '1' },
|
|
389
|
+
profile_hash: proposal.aeb.pinned_config_digest,
|
|
390
|
+
verdict: 'admissible',
|
|
391
|
+
replay_digest: record.evidence_digest,
|
|
392
|
+
challenge_id: proposal.proposal_id,
|
|
393
|
+
aeb_evaluation_digest: digestAeb(record),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function refusal(reason: string, extra: JsonObject = {}): JsonObject {
|
|
398
|
+
return { ok: false, reason, ...extra };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function proposalToEffectConsumptionNonce(
|
|
402
|
+
operationId: string,
|
|
403
|
+
pinnedConfigDigest: AebDigest,
|
|
404
|
+
): AebDigest {
|
|
405
|
+
assertIdentifier(operationId, 'proposal_operation_id');
|
|
406
|
+
if (typeof pinnedConfigDigest !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(pinnedConfigDigest)) {
|
|
407
|
+
throw new Error('proposal_aeb_pin_invalid');
|
|
408
|
+
}
|
|
409
|
+
return digestAeb({
|
|
410
|
+
domain: PROPOSAL_TO_EFFECT_VERSION,
|
|
411
|
+
operation_id: operationId,
|
|
412
|
+
pinned_config_digest: pinnedConfigDigest,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function expectedAebCompositionDigest(proposal: ProposalToEffectProposal): AebDigest {
|
|
417
|
+
return `sha256:${aecActionDigest({
|
|
418
|
+
caid: proposal.caid,
|
|
419
|
+
normalized_action_digest: proposal.aeb_action_digest,
|
|
420
|
+
})}` as AebDigest;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function createProposalToEffect(options: ProposalToEffectOptions) {
|
|
424
|
+
if (!options?.gate || typeof options.gate.check !== 'function' || typeof options.gate.run !== 'function') {
|
|
425
|
+
throw new Error('proposal_gate_required');
|
|
426
|
+
}
|
|
427
|
+
if (!isPlainObject(options.profiles) || Object.keys(options.profiles).length === 0) {
|
|
428
|
+
throw new Error('proposal_profiles_required');
|
|
429
|
+
}
|
|
430
|
+
for (const [id, profile] of Object.entries(options.profiles)) {
|
|
431
|
+
assertProfile(profile);
|
|
432
|
+
if (id !== profile.id) throw new Error('proposal_profile_registry_mismatch');
|
|
433
|
+
}
|
|
434
|
+
if (!options.aeb?.config || !options.aeb.adapters || !options.aeb.store
|
|
435
|
+
|| typeof options.aeb.resolve_artifacts !== 'function'
|
|
436
|
+
|| typeof options.aeb.currentStatusResolver !== 'function'
|
|
437
|
+
|| typeof options.aeb.statusVerifier !== 'function'
|
|
438
|
+
|| typeof options.aeb.verify_provider_evidence !== 'function') {
|
|
439
|
+
throw new Error('proposal_aeb_configuration_required');
|
|
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
|
+
}
|
|
456
|
+
const now = options.now ?? Date.now;
|
|
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
|
+
}
|
|
548
|
+
|
|
549
|
+
function profileFor(id: unknown): ProposalToEffectProfile {
|
|
550
|
+
if (typeof id !== 'string' || !options.profiles[id]) throw new Error('proposal_profile_not_pinned');
|
|
551
|
+
return options.profiles[id];
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function prepare(input: {
|
|
555
|
+
proposal_id: string;
|
|
556
|
+
profile_id: string;
|
|
557
|
+
operation_id: string;
|
|
558
|
+
initiator_id: string;
|
|
559
|
+
action: unknown;
|
|
560
|
+
}): ProposalToEffectProposal {
|
|
561
|
+
assertIdentifier(input?.proposal_id, 'proposal_id');
|
|
562
|
+
assertIdentifier(input?.operation_id, 'proposal_operation_id');
|
|
563
|
+
assertIdentifier(input?.initiator_id, 'proposal_initiator_id');
|
|
564
|
+
const profile = profileFor(input?.profile_id);
|
|
565
|
+
const normalized = canonicalizeForProfile(profile, input.action);
|
|
566
|
+
const createdAtMs = currentTime();
|
|
567
|
+
const actionDigest = approvalActionHash(normalized.action);
|
|
568
|
+
const base = {
|
|
569
|
+
'@version': PROPOSAL_TO_EFFECT_VERSION as typeof PROPOSAL_TO_EFFECT_VERSION,
|
|
570
|
+
proposal_id: input.proposal_id,
|
|
571
|
+
operation_id: input.operation_id,
|
|
572
|
+
initiator_id: input.initiator_id,
|
|
573
|
+
profile_id: profile.id,
|
|
574
|
+
action: normalized.action,
|
|
575
|
+
action_digest: actionDigest,
|
|
576
|
+
aeb_action_digest: digestAeb(normalized.action),
|
|
577
|
+
caid: normalized.caid,
|
|
578
|
+
created_at: new Date(createdAtMs).toISOString(),
|
|
579
|
+
expires_at: new Date(createdAtMs + profile.ttl_sec * 1000).toISOString(),
|
|
580
|
+
challenge: {
|
|
581
|
+
action: profile.action_type,
|
|
582
|
+
action_hash: actionDigest,
|
|
583
|
+
required_fields: [...profile.required_fields],
|
|
584
|
+
...(profile.caid_selector ? { caid_selector: clone(profile.caid_selector) } : {}),
|
|
585
|
+
},
|
|
586
|
+
authorization: clone(profile.authorization),
|
|
587
|
+
aeb: {
|
|
588
|
+
requirement_ref: profile.aeb_requirement_ref,
|
|
589
|
+
pinned_config_digest: configDigest,
|
|
590
|
+
consumption_nonce: proposalToEffectConsumptionNonce(input.operation_id, configDigest),
|
|
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) },
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function verifyProposal(input: unknown, { allowExpired = false }: { allowExpired?: boolean } = {}): {
|
|
607
|
+
proposal: ProposalToEffectProposal;
|
|
608
|
+
profile: ProposalToEffectProfile;
|
|
609
|
+
} {
|
|
610
|
+
if (!isPlainObject(input) || !exactProposalKeys(input)
|
|
611
|
+
|| input['@version'] !== PROPOSAL_TO_EFFECT_VERSION) {
|
|
612
|
+
throw new Error('proposal_shape_invalid');
|
|
613
|
+
}
|
|
614
|
+
if (!proposalIntegrityValid(input)) throw new Error('proposal_integrity_invalid');
|
|
615
|
+
const proposal = input as ProposalToEffectProposal;
|
|
616
|
+
assertIdentifier(proposal.proposal_id, 'proposal_id');
|
|
617
|
+
assertIdentifier(proposal.operation_id, 'proposal_operation_id');
|
|
618
|
+
assertIdentifier(proposal.initiator_id, 'proposal_initiator_id');
|
|
619
|
+
const profile = profileFor(proposal.profile_id);
|
|
620
|
+
const normalized = canonicalizeForProfile(profile, proposal.action);
|
|
621
|
+
if (normalized.caid !== proposal.caid) throw new Error('proposal_caid_mismatch');
|
|
622
|
+
assertSameObject(normalized.action, proposal.action, 'proposal_action_not_canonical');
|
|
623
|
+
if (approvalActionHash(normalized.action) !== proposal.action_digest) {
|
|
624
|
+
throw new Error('proposal_action_digest_mismatch');
|
|
625
|
+
}
|
|
626
|
+
if (digestAeb(normalized.action) !== proposal.aeb_action_digest) {
|
|
627
|
+
throw new Error('proposal_aeb_action_digest_mismatch');
|
|
628
|
+
}
|
|
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');
|
|
636
|
+
assertSameObject(proposal.authorization, profile.authorization, 'proposal_authorization_mismatch');
|
|
637
|
+
if (!isPlainObject(proposal.aeb)
|
|
638
|
+
|| Object.keys(proposal.aeb).sort().join(',') !== 'consumption_nonce,pinned_config_digest,requirement_ref'
|
|
639
|
+
|| proposal.aeb?.requirement_ref !== profile.aeb_requirement_ref
|
|
640
|
+
|| proposal.aeb?.pinned_config_digest !== configDigest) {
|
|
641
|
+
throw new Error('proposal_aeb_pin_mismatch');
|
|
642
|
+
}
|
|
643
|
+
if (proposal.aeb.consumption_nonce !== proposalToEffectConsumptionNonce(proposal.operation_id, configDigest)) {
|
|
644
|
+
throw new Error('proposal_aeb_nonce_mismatch');
|
|
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
|
+
}
|
|
656
|
+
const expectedChallenge = {
|
|
657
|
+
action: profile.action_type,
|
|
658
|
+
action_hash: proposal.action_digest,
|
|
659
|
+
required_fields: [...profile.required_fields],
|
|
660
|
+
...(profile.caid_selector ? { caid_selector: clone(profile.caid_selector) } : {}),
|
|
661
|
+
};
|
|
662
|
+
assertSameObject(proposal.challenge, expectedChallenge, 'proposal_challenge_mismatch');
|
|
663
|
+
return { proposal: clone(proposal), profile };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async function verifyEvaluation(proposal: ProposalToEffectProposal, evaluation: unknown) {
|
|
667
|
+
if (!isPlainObject(evaluation)) return { valid: false, reason: 'aeb_evaluation_missing', record: null };
|
|
668
|
+
const record = evaluation as unknown as AebEvaluationRecord;
|
|
669
|
+
if (record.operation_id !== proposal.operation_id
|
|
670
|
+
|| record.consumption_nonce !== proposal.aeb.consumption_nonce
|
|
671
|
+
|| record.initiator_id !== proposal.initiator_id
|
|
672
|
+
|| record.executor_id !== proposal.consequence.executor_id
|
|
673
|
+
|| record.requirement_ref !== proposal.aeb.requirement_ref
|
|
674
|
+
|| record.caid !== proposal.caid) {
|
|
675
|
+
return { valid: false, reason: 'aeb_evaluation_binding_mismatch', record };
|
|
676
|
+
}
|
|
677
|
+
if (record.verdict === 'SATISFIED'
|
|
678
|
+
&& record.composition?.action_digest !== expectedAebCompositionDigest(proposal)) {
|
|
679
|
+
return { valid: false, reason: 'aeb_evaluation_binding_mismatch', record };
|
|
680
|
+
}
|
|
681
|
+
let artifacts: Record<string, unknown>;
|
|
682
|
+
try {
|
|
683
|
+
artifacts = await options.aeb.resolve_artifacts({ proposal: clone(proposal), evaluation: clone(record) });
|
|
684
|
+
} catch {
|
|
685
|
+
return { valid: false, reason: 'aeb_artifact_resolution_failed', record };
|
|
686
|
+
}
|
|
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',
|
|
728
|
+
config: options.aeb.config,
|
|
729
|
+
adapters: options.aeb.adapters,
|
|
730
|
+
artifacts,
|
|
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'
|
|
739
|
+
|| record.authority_constraints?.one_time_consumption !== true) {
|
|
740
|
+
return { valid: false, reason: 'aeb_evaluation_refused', record, checked };
|
|
741
|
+
}
|
|
742
|
+
return { valid: true, reason: null, record, checked };
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function gateInput(proposal: ProposalToEffectProposal, profile: ProposalToEffectProfile, receipt: unknown, record: AebEvaluationRecord) {
|
|
746
|
+
return {
|
|
747
|
+
selector: {
|
|
748
|
+
...clone(profile.selector),
|
|
749
|
+
operation_id: proposal.operation_id,
|
|
750
|
+
initiator_id: proposal.initiator_id,
|
|
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,
|
|
758
|
+
},
|
|
759
|
+
receipt,
|
|
760
|
+
observedAction: clone(proposal.action),
|
|
761
|
+
admissibility: proposalAdmissibility(proposal, record),
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async function execute(
|
|
766
|
+
input: { proposal: unknown; receipt: unknown; evaluation: 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>,
|
|
774
|
+
): Promise<JsonObject> {
|
|
775
|
+
if (typeof effect !== 'function') throw new Error('proposal_effect_required');
|
|
776
|
+
const { proposal, profile } = verifyProposal(input?.proposal);
|
|
777
|
+
const evaluation = await verifyEvaluation(proposal, input?.evaluation);
|
|
778
|
+
if (!evaluation.valid || !evaluation.record) {
|
|
779
|
+
return refusal(evaluation.reason || 'aeb_evaluation_refused', { aeb: evaluation.checked ?? null });
|
|
780
|
+
}
|
|
781
|
+
if (!evaluation.checked?.valid || evaluation.checked.execution_authorizing !== true) {
|
|
782
|
+
return refusal('aeb_execution_verification_required', { aeb: evaluation.checked ?? null });
|
|
783
|
+
}
|
|
784
|
+
const preparedGateInput = gateInput(proposal, profile, input.receipt, evaluation.record);
|
|
785
|
+
const preflight = await options.gate.check({ ...preparedGateInput, consumptionMode: 'none' });
|
|
786
|
+
if (preflight.allow !== true) {
|
|
787
|
+
return refusal(preflight.reason || 'gate_refused', { authorization: preflight });
|
|
788
|
+
}
|
|
789
|
+
if (preflight.reason === 'not_guarded' || preflight.requirement?.receipt_required !== true) {
|
|
790
|
+
return refusal('gate_profile_not_receipt_guarded', { authorization: preflight });
|
|
791
|
+
}
|
|
792
|
+
const reservation = await authorizeAebExecutionDurable(evaluation.record, {
|
|
793
|
+
verification: evaluation.checked,
|
|
794
|
+
local_authorization: true,
|
|
795
|
+
store: options.aeb.store,
|
|
796
|
+
});
|
|
797
|
+
if (!reservation.invoke_allowed || !reservation.reservation_key) {
|
|
798
|
+
return refusal(
|
|
799
|
+
reservation.reason === 'consumption_conflict' ? 'aeb_consumption_conflict' : reservation.reason,
|
|
800
|
+
{ aeb: reservation },
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
const key = reservation.reservation_key;
|
|
804
|
+
let attemptId: string;
|
|
805
|
+
try {
|
|
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
|
+
}
|
|
896
|
+
if (result?.ok !== true) {
|
|
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);
|
|
909
|
+
return refusal(result?.authorization?.reason || result?.reason || 'gate_refused', {
|
|
910
|
+
authorization: result?.authorization ?? null,
|
|
911
|
+
consequence: { state: 'RELEASED', attempt: clone(binding) },
|
|
912
|
+
});
|
|
913
|
+
}
|
|
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');
|
|
925
|
+
if (committed.state !== 'CONSUMED') {
|
|
926
|
+
const error: any = new Error('aeb_consumption_commit_failed');
|
|
927
|
+
error.code = 'EMILIA_PROPOSAL_TO_EFFECT_COMMIT_FAILED';
|
|
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');
|
|
932
|
+
}
|
|
933
|
+
return {
|
|
934
|
+
...result,
|
|
935
|
+
proposal: clone(proposal),
|
|
936
|
+
aeb: committed,
|
|
937
|
+
consequence: { state: 'COMMITTED', attempt: clone(binding) },
|
|
938
|
+
};
|
|
939
|
+
} catch (error: any) {
|
|
940
|
+
const outcome = error?.proposalToEffect?.outcome ?? error?.emiliaGateOutcome?.outcome;
|
|
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
|
+
}
|
|
951
|
+
}
|
|
952
|
+
throw attachAttempt(error, callbackEntered ? (outcome || 'indeterminate') : outcome);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
async function reconcile(input: {
|
|
957
|
+
proposal: unknown;
|
|
958
|
+
evaluation: unknown;
|
|
959
|
+
attempt: ConsequenceAttemptReference | (ConsequenceAttemptBinding & { owner: ConsequenceAttemptOwnerHandle });
|
|
960
|
+
provider_evidence: unknown;
|
|
961
|
+
aeb_recovery_authorization?: unknown;
|
|
962
|
+
}): Promise<JsonObject> {
|
|
963
|
+
const { proposal } = verifyProposal(input?.proposal, { allowExpired: true });
|
|
964
|
+
if (!isPlainObject(input?.evaluation)) return refusal('aeb_evaluation_missing');
|
|
965
|
+
const record = input.evaluation as unknown as AebEvaluationRecord;
|
|
966
|
+
if (record.operation_id !== proposal.operation_id
|
|
967
|
+
|| record.consumption_nonce !== proposal.aeb.consumption_nonce
|
|
968
|
+
|| record.initiator_id !== proposal.initiator_id
|
|
969
|
+
|| record.executor_id !== proposal.consequence.executor_id
|
|
970
|
+
|| record.requirement_ref !== proposal.aeb.requirement_ref
|
|
971
|
+
|| record.caid !== proposal.caid) {
|
|
972
|
+
return refusal('aeb_evaluation_binding_mismatch');
|
|
973
|
+
}
|
|
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',
|
|
985
|
+
config: options.aeb.config,
|
|
986
|
+
adapters: options.aeb.adapters,
|
|
987
|
+
artifacts,
|
|
988
|
+
expected_action: clone(proposal.action),
|
|
989
|
+
executor_id: proposal.consequence.executor_id,
|
|
990
|
+
};
|
|
991
|
+
const historical = verifyAebEvaluation(record, historicalOptions);
|
|
992
|
+
if (!historical.valid) return refusal('aeb_evaluation_refused', { aeb: historical });
|
|
993
|
+
if (record.verdict !== 'SATISFIED'
|
|
994
|
+
|| record.authority_constraints?.one_time_consumption !== true) {
|
|
995
|
+
return refusal('aeb_evaluation_refused', { aeb: historical });
|
|
996
|
+
}
|
|
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');
|
|
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
|
+
};
|
|
1018
|
+
let provider: ProposalToEffectProviderVerification;
|
|
1019
|
+
try {
|
|
1020
|
+
provider = await options.aeb.verify_provider_evidence({
|
|
1021
|
+
evidence: clone(input.provider_evidence),
|
|
1022
|
+
expected: {
|
|
1023
|
+
operation_id: proposal.operation_id,
|
|
1024
|
+
caid: proposal.caid,
|
|
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,
|
|
1032
|
+
},
|
|
1033
|
+
});
|
|
1034
|
+
} catch {
|
|
1035
|
+
return refusal('provider_evidence_unverified');
|
|
1036
|
+
}
|
|
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)) {
|
|
1045
|
+
return refusal(provider?.reason || 'provider_evidence_unverified');
|
|
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';
|
|
1076
|
+
const key = aebReservationKey(record);
|
|
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
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
return {
|
|
1216
|
+
ok: true,
|
|
1217
|
+
state: snapshot.state,
|
|
1218
|
+
consequence: { state: snapshot.state, attempt: clone(attempt) },
|
|
1219
|
+
reservation_key: key,
|
|
1220
|
+
aeb: repaired,
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
async function beginApproval(input: {
|
|
1225
|
+
proposal: unknown;
|
|
1226
|
+
approver_id: string;
|
|
1227
|
+
idempotency_key: string;
|
|
1228
|
+
requester_authorization: string | (() => string | Promise<string>);
|
|
1229
|
+
fetch_impl?: FetchLike;
|
|
1230
|
+
}): Promise<JsonObject> {
|
|
1231
|
+
const { proposal, profile } = verifyProposal(input?.proposal);
|
|
1232
|
+
return beginReceiptApproval({
|
|
1233
|
+
authorization: proposal.authorization,
|
|
1234
|
+
trustedAuthorization: profile.authorization,
|
|
1235
|
+
challenge: proposal.challenge,
|
|
1236
|
+
action: proposal.action,
|
|
1237
|
+
approver_id: input.approver_id,
|
|
1238
|
+
idempotency_key: input.idempotency_key,
|
|
1239
|
+
requesterAuthorization: input.requester_authorization,
|
|
1240
|
+
fetchImpl: input.fetch_impl,
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
async function pollApproval(input: {
|
|
1245
|
+
proposal: unknown;
|
|
1246
|
+
request_id: string;
|
|
1247
|
+
poll_token: string;
|
|
1248
|
+
fetch_impl?: FetchLike;
|
|
1249
|
+
}): Promise<JsonObject> {
|
|
1250
|
+
const { proposal, profile } = verifyProposal(input?.proposal, { allowExpired: true });
|
|
1251
|
+
return pollReceiptApproval({
|
|
1252
|
+
authorization: proposal.authorization,
|
|
1253
|
+
trustedAuthorization: profile.authorization,
|
|
1254
|
+
request_id: input.request_id,
|
|
1255
|
+
poll_token: input.poll_token,
|
|
1256
|
+
fetchImpl: input.fetch_impl,
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
return Object.freeze({
|
|
1261
|
+
prepare,
|
|
1262
|
+
verifyProposal,
|
|
1263
|
+
beginApproval,
|
|
1264
|
+
pollApproval,
|
|
1265
|
+
execute,
|
|
1266
|
+
reconcile,
|
|
1267
|
+
repairAeb,
|
|
1268
|
+
getReconciliationHandle,
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
export default {
|
|
1273
|
+
PROPOSAL_TO_EFFECT_VERSION,
|
|
1274
|
+
proposalToEffectConsumptionNonce,
|
|
1275
|
+
createProposalToEffect,
|
|
1276
|
+
};
|