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