@emilia-protocol/gate 0.17.0 → 0.18.2
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 +67 -0
- package/README.md +51 -0
- package/admission-store-postgres.js +4 -0
- package/admission-store.js +4 -0
- package/dist/admission-store-postgres.d.ts +66 -0
- package/dist/admission-store-postgres.d.ts.map +1 -0
- package/dist/admission-store-postgres.js +537 -0
- package/dist/admission-store-postgres.js.map +1 -0
- package/dist/admission-store.d.ts +292 -0
- package/dist/admission-store.d.ts.map +1 -0
- package/dist/admission-store.js +777 -0
- package/dist/admission-store.js.map +1 -0
- package/dist/gate-qualification-v2.d.ts +225 -0
- package/dist/gate-qualification-v2.d.ts.map +1 -0
- package/dist/gate-qualification-v2.js +1011 -0
- package/dist/gate-qualification-v2.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/referee-runner.d.ts +35 -0
- package/dist/referee-runner.d.ts.map +1 -0
- package/dist/referee-runner.js +294 -0
- package/dist/referee-runner.js.map +1 -0
- package/dist/referee.d.ts +124 -0
- package/dist/referee.d.ts.map +1 -0
- package/dist/referee.js +434 -0
- package/dist/referee.js.map +1 -0
- package/gate-qualification-v2.js +4 -0
- package/package.json +29 -3
- package/referee-runner.js +3 -0
- package/referee.js +3 -0
- package/src/admission-store-postgres.ts +695 -0
- package/src/admission-store.ts +1065 -0
- package/src/gate-qualification-v2.ts +1534 -0
- package/src/index.ts +5 -0
- package/src/referee-runner.ts +365 -0
- package/src/referee.ts +625 -0
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* Gate Qualification v2 admission custody.
|
|
5
|
+
*
|
|
6
|
+
* The immutable snapshot is the complete input to one consequential operation.
|
|
7
|
+
* Mutable lifecycle state lives in a separately CAS-owned record and every
|
|
8
|
+
* accepted transition is chained into an append-only journal. The in-memory
|
|
9
|
+
* implementation is a linearizable reference for conformance and crash-model
|
|
10
|
+
* tests; it is deliberately not a durability claim.
|
|
11
|
+
*/
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
export const ADMISSION_SNAPSHOT_VERSION = 'EP-GATE-ADMISSION-SNAPSHOT-v2';
|
|
14
|
+
export const ADMISSION_RECORD_VERSION = 'EP-GATE-ADMISSION-RECORD-v2';
|
|
15
|
+
export const ADMISSION_JOURNAL_VERSION = 'EP-GATE-ADMISSION-JOURNAL-v2';
|
|
16
|
+
export const ADMISSION_CURRENTNESS_VERSION = 'EP-GATE-ADMISSION-CURRENTNESS-v2';
|
|
17
|
+
export const ADMISSION_LIMITS = Object.freeze({
|
|
18
|
+
inputs: 128,
|
|
19
|
+
resources: 64,
|
|
20
|
+
testResults: 64,
|
|
21
|
+
agentEvidence: 32,
|
|
22
|
+
identifierBytes: 512,
|
|
23
|
+
currentnessMaxAgeMs: 5_000,
|
|
24
|
+
});
|
|
25
|
+
const SHA256 = /^sha256:[0-9a-f]{64}$/;
|
|
26
|
+
const CAID = /^caid:1:[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*\.[1-9][0-9]*:jcs-sha256:[A-Za-z0-9_-]{43}$/;
|
|
27
|
+
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,511}$/;
|
|
28
|
+
const RFC3339 = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
29
|
+
const OWNER_TOKEN = /^admission-owner:v2:[A-Za-z0-9_-]{32,128}$/;
|
|
30
|
+
const REQUIRED_SINGLETON_ROLES = Object.freeze([
|
|
31
|
+
'candidate_manifest',
|
|
32
|
+
'runtime_measurement',
|
|
33
|
+
'qualification_statement',
|
|
34
|
+
'qualification_status',
|
|
35
|
+
'aeb',
|
|
36
|
+
'aec',
|
|
37
|
+
'local_policy',
|
|
38
|
+
'authorization',
|
|
39
|
+
]);
|
|
40
|
+
const REPEATABLE_ROLES = new Set([
|
|
41
|
+
'test_result',
|
|
42
|
+
'agent_evaluation_evidence',
|
|
43
|
+
]);
|
|
44
|
+
const ROLE_ORDER = new Map([
|
|
45
|
+
'candidate_manifest', 'runtime_measurement', 'test_result',
|
|
46
|
+
'agent_evaluation_evidence', 'qualification_statement',
|
|
47
|
+
'qualification_status', 'aeb', 'aec', 'local_policy', 'authorization',
|
|
48
|
+
].map((role, index) => [role, index]));
|
|
49
|
+
export class AdmissionStoreValidationError extends TypeError {
|
|
50
|
+
code;
|
|
51
|
+
constructor(code, message) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = 'AdmissionStoreValidationError';
|
|
54
|
+
this.code = code;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function fail(code, message) {
|
|
58
|
+
throw new AdmissionStoreValidationError(code, message);
|
|
59
|
+
}
|
|
60
|
+
function plain(value) {
|
|
61
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
62
|
+
return false;
|
|
63
|
+
const proto = Object.getPrototypeOf(value);
|
|
64
|
+
return proto === Object.prototype || proto === null;
|
|
65
|
+
}
|
|
66
|
+
function canonical(value) {
|
|
67
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'string')
|
|
68
|
+
return JSON.stringify(value);
|
|
69
|
+
if (typeof value === 'number') {
|
|
70
|
+
if (!Number.isFinite(value))
|
|
71
|
+
fail('invalid_json', 'non-finite JSON number');
|
|
72
|
+
return JSON.stringify(value);
|
|
73
|
+
}
|
|
74
|
+
if (Array.isArray(value))
|
|
75
|
+
return `[${value.map((item) => canonical(item)).join(',')}]`;
|
|
76
|
+
if (!plain(value))
|
|
77
|
+
fail('invalid_json', 'non-plain JSON object');
|
|
78
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
|
|
79
|
+
}
|
|
80
|
+
function hash(domain, value) {
|
|
81
|
+
return `sha256:${crypto.createHash('sha256').update(domain).update('\0').update(canonical(value)).digest('hex')}`;
|
|
82
|
+
}
|
|
83
|
+
function deepFreeze(value) {
|
|
84
|
+
if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
85
|
+
for (const child of Object.values(value))
|
|
86
|
+
deepFreeze(child);
|
|
87
|
+
Object.freeze(value);
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
function frozenCopy(value) {
|
|
92
|
+
return deepFreeze(structuredClone(value));
|
|
93
|
+
}
|
|
94
|
+
function identifier(value, field) {
|
|
95
|
+
if (typeof value !== 'string' || !IDENTIFIER.test(value))
|
|
96
|
+
fail('invalid_identifier', `${field} is invalid`);
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
function digest(value, field) {
|
|
100
|
+
if (typeof value !== 'string' || !SHA256.test(value))
|
|
101
|
+
fail('invalid_digest', `${field} is invalid`);
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
function instant(value, field) {
|
|
105
|
+
if (typeof value !== 'string' || !RFC3339.test(value))
|
|
106
|
+
fail('invalid_instant', `${field} must be RFC 3339`);
|
|
107
|
+
const ms = Date.parse(value);
|
|
108
|
+
if (!Number.isFinite(ms))
|
|
109
|
+
fail('invalid_instant', `${field} must identify an instant`);
|
|
110
|
+
return { iso: new Date(ms).toISOString(), ms };
|
|
111
|
+
}
|
|
112
|
+
function nonNegativeInteger(value, field) {
|
|
113
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
114
|
+
fail('invalid_integer', `${field} is invalid`);
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
function stringBytes(value) { return Buffer.byteLength(value, 'utf8'); }
|
|
118
|
+
function currentMs(source) {
|
|
119
|
+
const raw = typeof source === 'function' ? source() : source;
|
|
120
|
+
if (raw === undefined)
|
|
121
|
+
return Date.now();
|
|
122
|
+
if (raw instanceof Date)
|
|
123
|
+
return raw.getTime();
|
|
124
|
+
if (typeof raw === 'string')
|
|
125
|
+
return instant(raw, 'now').ms;
|
|
126
|
+
if (!Number.isFinite(raw))
|
|
127
|
+
fail('invalid_time', 'now is invalid');
|
|
128
|
+
return raw;
|
|
129
|
+
}
|
|
130
|
+
function normalizeInputs(raw, admittedAt) {
|
|
131
|
+
if (!Array.isArray(raw) || raw.length > ADMISSION_LIMITS.inputs)
|
|
132
|
+
fail('invalid_inputs', 'inputs are invalid');
|
|
133
|
+
const counts = new Map();
|
|
134
|
+
const seen = new Set();
|
|
135
|
+
const inputs = raw.map((entry, index) => {
|
|
136
|
+
if (!plain(entry))
|
|
137
|
+
fail('invalid_input', `inputs[${index}] is invalid`);
|
|
138
|
+
const role = entry.role;
|
|
139
|
+
if (!ROLE_ORDER.has(role))
|
|
140
|
+
fail('invalid_input_role', `inputs[${index}].role is invalid`);
|
|
141
|
+
const normalized = {
|
|
142
|
+
role,
|
|
143
|
+
artifact_type: identifier(entry.artifact_type, `inputs[${index}].artifact_type`),
|
|
144
|
+
subject: identifier(entry.subject, `inputs[${index}].subject`),
|
|
145
|
+
payload_digest: digest(entry.payload_digest, `inputs[${index}].payload_digest`),
|
|
146
|
+
profile_digest: digest(entry.profile_digest, `inputs[${index}].profile_digest`),
|
|
147
|
+
verifier_id: identifier(entry.verifier_id, `inputs[${index}].verifier_id`),
|
|
148
|
+
trust_configuration_digest: digest(entry.trust_configuration_digest, `inputs[${index}].trust_configuration_digest`),
|
|
149
|
+
valid_until: instant(entry.valid_until, `inputs[${index}].valid_until`).iso,
|
|
150
|
+
};
|
|
151
|
+
if (Date.parse(normalized.valid_until) <= admittedAt)
|
|
152
|
+
fail('expired_input', `inputs[${index}] is expired`);
|
|
153
|
+
const key = canonical(normalized);
|
|
154
|
+
if (seen.has(key))
|
|
155
|
+
fail('duplicate_input', `inputs[${index}] is duplicated`);
|
|
156
|
+
seen.add(key);
|
|
157
|
+
counts.set(role, (counts.get(role) ?? 0) + 1);
|
|
158
|
+
if (!REPEATABLE_ROLES.has(role) && (counts.get(role) ?? 0) > 1)
|
|
159
|
+
fail('duplicate_input_role', `${role} is singleton`);
|
|
160
|
+
return normalized;
|
|
161
|
+
});
|
|
162
|
+
for (const role of REQUIRED_SINGLETON_ROLES)
|
|
163
|
+
if (counts.get(role) !== 1)
|
|
164
|
+
fail('missing_input_role', `${role} is required exactly once`);
|
|
165
|
+
for (const role of REPEATABLE_ROLES)
|
|
166
|
+
if ((counts.get(role) ?? 0) < 1)
|
|
167
|
+
fail('missing_input_role', `${role} is required`);
|
|
168
|
+
inputs.sort((left, right) => {
|
|
169
|
+
const role = (ROLE_ORDER.get(left.role) ?? 99) - (ROLE_ORDER.get(right.role) ?? 99);
|
|
170
|
+
if (role !== 0)
|
|
171
|
+
return role;
|
|
172
|
+
return Buffer.from(canonical(left)).compare(Buffer.from(canonical(right)));
|
|
173
|
+
});
|
|
174
|
+
return inputs;
|
|
175
|
+
}
|
|
176
|
+
function normalizeDigests(raw, field, limit) {
|
|
177
|
+
if (!Array.isArray(raw) || raw.length < 1 || raw.length > limit)
|
|
178
|
+
fail('invalid_digest_list', `${field} is invalid`);
|
|
179
|
+
const values = raw.map((value, index) => digest(value, `${field}[${index}]`)).sort();
|
|
180
|
+
if (new Set(values).size !== values.length)
|
|
181
|
+
fail('duplicate_digest', `${field} contains duplicates`);
|
|
182
|
+
return values;
|
|
183
|
+
}
|
|
184
|
+
function normalizeResources(raw, admittedAt) {
|
|
185
|
+
if (!Array.isArray(raw) || raw.length < 1 || raw.length > ADMISSION_LIMITS.resources)
|
|
186
|
+
fail('invalid_resources', 'resource reservations are invalid');
|
|
187
|
+
const allowed = new Set(['replay', 'capability', 'budget', 'qualification_use', 'provider_operation', 'external_lease']);
|
|
188
|
+
const seen = new Set();
|
|
189
|
+
const values = raw.map((entry, index) => {
|
|
190
|
+
if (!plain(entry) || !allowed.has(entry.kind))
|
|
191
|
+
fail('invalid_resource', `resource[${index}] is invalid`);
|
|
192
|
+
const value = {
|
|
193
|
+
kind: entry.kind,
|
|
194
|
+
resource_id: identifier(entry.resource_id, `resource[${index}].resource_id`),
|
|
195
|
+
reservation_id: identifier(entry.reservation_id, `resource[${index}].reservation_id`),
|
|
196
|
+
digest: digest(entry.digest, `resource[${index}].digest`),
|
|
197
|
+
expires_at: instant(entry.expires_at, `resource[${index}].expires_at`).iso,
|
|
198
|
+
};
|
|
199
|
+
if (Date.parse(value.expires_at) <= admittedAt)
|
|
200
|
+
fail('expired_resource', `resource[${index}] is expired`);
|
|
201
|
+
const key = `${value.kind}\0${value.resource_id}`;
|
|
202
|
+
if (seen.has(key))
|
|
203
|
+
fail('duplicate_resource', `resource[${index}] is duplicated`);
|
|
204
|
+
seen.add(key);
|
|
205
|
+
return value;
|
|
206
|
+
});
|
|
207
|
+
values.sort((left, right) => Buffer.from(`${left.kind}\0${left.resource_id}`).compare(Buffer.from(`${right.kind}\0${right.resource_id}`)));
|
|
208
|
+
return values;
|
|
209
|
+
}
|
|
210
|
+
function normalizeRelation(raw) {
|
|
211
|
+
if (raw === undefined || raw === null)
|
|
212
|
+
return null;
|
|
213
|
+
if (!plain(raw))
|
|
214
|
+
fail('invalid_relation', 'relation is invalid');
|
|
215
|
+
return {
|
|
216
|
+
tenant_id: identifier(raw.tenant_id, 'relation.tenant_id'),
|
|
217
|
+
admission_id: identifier(raw.admission_id, 'relation.admission_id'),
|
|
218
|
+
operation_id: identifier(raw.operation_id, 'relation.operation_id'),
|
|
219
|
+
snapshot_digest: digest(raw.snapshot_digest, 'relation.snapshot_digest'),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
export function createAdmissionSnapshot(raw) {
|
|
223
|
+
if (!plain(raw))
|
|
224
|
+
fail('invalid_snapshot', 'snapshot input is invalid');
|
|
225
|
+
const admitted = instant(raw.admitted_at, 'admitted_at');
|
|
226
|
+
const expires = instant(raw.expires_at, 'expires_at');
|
|
227
|
+
if (expires.ms <= admitted.ms)
|
|
228
|
+
fail('invalid_expiration', 'expires_at must follow admitted_at');
|
|
229
|
+
const inputs = normalizeInputs(raw.inputs, admitted.ms);
|
|
230
|
+
const resources = normalizeResources(raw.resource_reservations, admitted.ms);
|
|
231
|
+
const status = raw.qualification_status;
|
|
232
|
+
if (!plain(status))
|
|
233
|
+
fail('invalid_status_binding', 'qualification_status is invalid');
|
|
234
|
+
const statusBinding = {
|
|
235
|
+
authority_id: identifier(status.authority_id, 'qualification_status.authority_id'),
|
|
236
|
+
sequence: nonNegativeInteger(status.sequence, 'qualification_status.sequence'),
|
|
237
|
+
head_payload_digest: digest(status.head_payload_digest, 'qualification_status.head_payload_digest'),
|
|
238
|
+
observed_at: instant(status.observed_at, 'qualification_status.observed_at').iso,
|
|
239
|
+
expires_at: instant(status.expires_at, 'qualification_status.expires_at').iso,
|
|
240
|
+
};
|
|
241
|
+
const custody = raw.candidate_custody;
|
|
242
|
+
if (!plain(custody)
|
|
243
|
+
|| !['GATE', 'EXECUTOR_ADAPTER', 'EXTERNAL'].includes(custody.request_construction)
|
|
244
|
+
|| !['GATE', 'EXECUTOR_ADAPTER', 'EXTERNAL'].includes(custody.mutation_credential_custody)
|
|
245
|
+
|| !['SYSTEM_OF_RECORD', 'ACTUATOR', 'MIDDLEWARE'].includes(custody.enforcement_placement)) {
|
|
246
|
+
fail('invalid_custody', 'candidate_custody is invalid');
|
|
247
|
+
}
|
|
248
|
+
const provider = raw.provider;
|
|
249
|
+
if (!plain(provider))
|
|
250
|
+
fail('invalid_provider', 'provider is invalid');
|
|
251
|
+
const body = {
|
|
252
|
+
'@version': ADMISSION_SNAPSHOT_VERSION,
|
|
253
|
+
tenant_id: identifier(raw.tenant_id, 'tenant_id'),
|
|
254
|
+
admission_id: identifier(raw.admission_id, 'admission_id'),
|
|
255
|
+
operation_id: identifier(raw.operation_id, 'operation_id'),
|
|
256
|
+
candidate_manifest_digest: digest(raw.candidate_manifest_digest, 'candidate_manifest_digest'),
|
|
257
|
+
runtime_measurement_digest: digest(raw.runtime_measurement_digest, 'runtime_measurement_digest'),
|
|
258
|
+
candidate_custody: {
|
|
259
|
+
request_construction: custody.request_construction,
|
|
260
|
+
mutation_credential_custody: custody.mutation_credential_custody,
|
|
261
|
+
enforcement_placement: custody.enforcement_placement,
|
|
262
|
+
evidence_digest: digest(custody.evidence_digest, 'candidate_custody.evidence_digest'),
|
|
263
|
+
},
|
|
264
|
+
assignment_digest: digest(raw.assignment_digest, 'assignment_digest'),
|
|
265
|
+
qualification_policy_digest: digest(raw.qualification_policy_digest, 'qualification_policy_digest'),
|
|
266
|
+
test_result_payload_digests: normalizeDigests(raw.test_result_payload_digests, 'test_result_payload_digests', ADMISSION_LIMITS.testResults),
|
|
267
|
+
agent_evaluation_evidence_payload_digests: normalizeDigests(raw.agent_evaluation_evidence_payload_digests, 'agent_evaluation_evidence_payload_digests', ADMISSION_LIMITS.agentEvidence),
|
|
268
|
+
qualification_statement_payload_digest: digest(raw.qualification_statement_payload_digest, 'qualification_statement_payload_digest'),
|
|
269
|
+
qualification_status: statusBinding,
|
|
270
|
+
caid: typeof raw.caid === 'string' && CAID.test(raw.caid) ? raw.caid : fail('invalid_caid', 'caid is invalid'),
|
|
271
|
+
action_digest: digest(raw.action_digest, 'action_digest'),
|
|
272
|
+
effect_request_digest: digest(raw.effect_request_digest, 'effect_request_digest'),
|
|
273
|
+
provider: {
|
|
274
|
+
provider_id: identifier(provider.provider_id, 'provider.provider_id'),
|
|
275
|
+
account_id: identifier(provider.account_id, 'provider.account_id'),
|
|
276
|
+
environment: identifier(provider.environment, 'provider.environment'),
|
|
277
|
+
},
|
|
278
|
+
executor_adapter_digest: digest(raw.executor_adapter_digest, 'executor_adapter_digest'),
|
|
279
|
+
idempotency_key: identifier(raw.idempotency_key, 'idempotency_key'),
|
|
280
|
+
authorization_policy_digest: digest(raw.authorization_policy_digest, 'authorization_policy_digest'),
|
|
281
|
+
trust_epoch: nonNegativeInteger(raw.trust_epoch, 'trust_epoch'),
|
|
282
|
+
trust_configuration_digest: digest(raw.trust_configuration_digest, 'trust_configuration_digest'),
|
|
283
|
+
configuration_epoch: nonNegativeInteger(raw.configuration_epoch, 'configuration_epoch'),
|
|
284
|
+
configuration_digest: digest(raw.configuration_digest, 'configuration_digest'),
|
|
285
|
+
inputs,
|
|
286
|
+
resource_reservations: resources,
|
|
287
|
+
admitted_at: admitted.iso,
|
|
288
|
+
expires_at: expires.iso,
|
|
289
|
+
supersedes_admission_id: raw.supersedes_admission_id === undefined || raw.supersedes_admission_id === null
|
|
290
|
+
? null : identifier(raw.supersedes_admission_id, 'supersedes_admission_id'),
|
|
291
|
+
remedy_for: normalizeRelation(raw.remedy_for),
|
|
292
|
+
};
|
|
293
|
+
const ceilings = [statusBinding.expires_at, ...inputs.map((input) => input.valid_until), ...resources.map((resource) => resource.expires_at)];
|
|
294
|
+
if (ceilings.some((ceiling) => expires.ms > Date.parse(ceiling)))
|
|
295
|
+
fail('expiration_exceeds_evidence', 'expires_at exceeds an input deadline');
|
|
296
|
+
const snapshot = {
|
|
297
|
+
body: frozenCopy(body),
|
|
298
|
+
snapshot_digest: hash(`${ADMISSION_SNAPSHOT_VERSION}:DIGEST`, body),
|
|
299
|
+
};
|
|
300
|
+
return frozenCopy(snapshot);
|
|
301
|
+
}
|
|
302
|
+
function validateSnapshot(raw) {
|
|
303
|
+
if (!plain(raw) || !plain(raw.body))
|
|
304
|
+
fail('invalid_snapshot', 'snapshot is invalid');
|
|
305
|
+
const recreated = createAdmissionSnapshot(raw.body);
|
|
306
|
+
if (raw.snapshot_digest !== recreated.snapshot_digest)
|
|
307
|
+
fail('snapshot_digest_mismatch', 'snapshot digest does not match body');
|
|
308
|
+
return recreated;
|
|
309
|
+
}
|
|
310
|
+
function operationKey(tenant, operation) { return JSON.stringify([tenant, operation]); }
|
|
311
|
+
function admissionKey(tenant, admission) { return JSON.stringify([tenant, admission]); }
|
|
312
|
+
function resourceKey(tenant, resource) { return JSON.stringify([tenant, resource.kind, resource.resource_id]); }
|
|
313
|
+
function tokenDigest(token) { return hash(`${ADMISSION_RECORD_VERSION}:TOKEN`, token); }
|
|
314
|
+
function defaultOwnerToken() { return `admission-owner:v2:${crypto.randomBytes(32).toString('base64url')}`; }
|
|
315
|
+
function defaultInvocationToken() { return `admission-invocation:v2:${crypto.randomBytes(32).toString('base64url')}`; }
|
|
316
|
+
function validateOwner(value) {
|
|
317
|
+
if (typeof value !== 'string' || !OWNER_TOKEN.test(value))
|
|
318
|
+
fail('invalid_owner_token', 'owner token is invalid');
|
|
319
|
+
return value;
|
|
320
|
+
}
|
|
321
|
+
function finalizeRecord(raw) {
|
|
322
|
+
const { record_digest: _ignored, ...body } = raw;
|
|
323
|
+
return frozenCopy({ ...body, record_digest: hash(`${ADMISSION_RECORD_VERSION}:DIGEST`, body) });
|
|
324
|
+
}
|
|
325
|
+
function finalizeJournal(raw) {
|
|
326
|
+
return frozenCopy({ ...raw, entry_digest: hash(`${ADMISSION_JOURNAL_VERSION}:DIGEST`, raw) });
|
|
327
|
+
}
|
|
328
|
+
export function verifyAdmissionJournal(entries) {
|
|
329
|
+
let predecessor = null;
|
|
330
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
331
|
+
const entry = entries[index];
|
|
332
|
+
if (entry.sequence !== index)
|
|
333
|
+
return { ok: false, at: index, reason: 'sequence_mismatch' };
|
|
334
|
+
if (entry.predecessor_digest !== predecessor)
|
|
335
|
+
return { ok: false, at: index, reason: 'predecessor_mismatch' };
|
|
336
|
+
const { entry_digest, ...body } = entry;
|
|
337
|
+
if (hash(`${ADMISSION_JOURNAL_VERSION}:DIGEST`, body) !== entry_digest)
|
|
338
|
+
return { ok: false, at: index, reason: 'digest_mismatch' };
|
|
339
|
+
predecessor = entry_digest;
|
|
340
|
+
}
|
|
341
|
+
return { ok: true };
|
|
342
|
+
}
|
|
343
|
+
function exactOperationIdentity(left, right) {
|
|
344
|
+
return left.tenant_id === right.tenant_id
|
|
345
|
+
&& left.operation_id === right.operation_id
|
|
346
|
+
&& left.caid === right.caid
|
|
347
|
+
&& left.action_digest === right.action_digest
|
|
348
|
+
&& left.effect_request_digest === right.effect_request_digest
|
|
349
|
+
&& canonical(left.provider) === canonical(right.provider)
|
|
350
|
+
&& left.executor_adapter_digest === right.executor_adapter_digest
|
|
351
|
+
&& left.idempotency_key === right.idempotency_key;
|
|
352
|
+
}
|
|
353
|
+
function currentnessMatches(snapshot, observation, now, maxAgeMs) {
|
|
354
|
+
const body = snapshot.body;
|
|
355
|
+
const observedAt = Date.parse(observation.observed_at);
|
|
356
|
+
if (observation['@version'] !== ADMISSION_CURRENTNESS_VERSION
|
|
357
|
+
|| observation.candidate_match !== 'EXACT_MATCH'
|
|
358
|
+
|| !Number.isFinite(observedAt)
|
|
359
|
+
|| observedAt > now
|
|
360
|
+
|| now - observedAt > maxAgeMs
|
|
361
|
+
|| Date.parse(observation.qualification_status_expires_at) <= now
|
|
362
|
+
|| observation.qualification_status_authority_id !== body.qualification_status.authority_id
|
|
363
|
+
|| observation.qualification_status_sequence !== body.qualification_status.sequence
|
|
364
|
+
|| observation.qualification_status_head_digest !== body.qualification_status.head_payload_digest
|
|
365
|
+
|| observation.trust_epoch !== body.trust_epoch
|
|
366
|
+
|| observation.trust_configuration_digest !== body.trust_configuration_digest
|
|
367
|
+
|| observation.configuration_epoch !== body.configuration_epoch
|
|
368
|
+
|| observation.configuration_digest !== body.configuration_digest
|
|
369
|
+
|| observation.runtime_measurement_digest !== body.runtime_measurement_digest)
|
|
370
|
+
return false;
|
|
371
|
+
const expected = body.resource_reservations.filter((resource) => resource.kind === 'external_lease');
|
|
372
|
+
if (observation.external_leases.length !== expected.length)
|
|
373
|
+
return false;
|
|
374
|
+
const byId = new Map(observation.external_leases.map((lease) => [lease.resource_id, lease]));
|
|
375
|
+
return expected.every((lease) => {
|
|
376
|
+
const current = byId.get(lease.resource_id);
|
|
377
|
+
return current?.digest === lease.digest && Date.parse(current.expires_at) > now;
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
/** Linearizable, explicitly test-only reference implementation. */
|
|
381
|
+
export function createMemoryAdmissionStore(options = {}) {
|
|
382
|
+
const snapshots = new Map();
|
|
383
|
+
const records = new Map();
|
|
384
|
+
const operationHeads = new Map();
|
|
385
|
+
const resourceOwners = new Map();
|
|
386
|
+
const journals = new Map();
|
|
387
|
+
const ownerFactory = options.ownerTokenFactory ?? defaultOwnerToken;
|
|
388
|
+
const invocationFactory = options.invocationTokenFactory ?? defaultInvocationToken;
|
|
389
|
+
const maxCurrentnessAgeMs = options.maxCurrentnessAgeMs
|
|
390
|
+
?? ADMISSION_LIMITS.currentnessMaxAgeMs;
|
|
391
|
+
if (!Number.isSafeInteger(maxCurrentnessAgeMs)
|
|
392
|
+
|| maxCurrentnessAgeMs < 1
|
|
393
|
+
|| maxCurrentnessAgeMs > 300_000) {
|
|
394
|
+
fail('invalid_currentness_age', 'max currentness age is invalid');
|
|
395
|
+
}
|
|
396
|
+
let queue = Promise.resolve();
|
|
397
|
+
function atomic(fn) {
|
|
398
|
+
const next = queue.then(fn, fn);
|
|
399
|
+
queue = next.then(() => undefined, () => undefined);
|
|
400
|
+
return next;
|
|
401
|
+
}
|
|
402
|
+
function append(key, ownerToken, draft, event, at) {
|
|
403
|
+
const history = journals.get(key) ?? [];
|
|
404
|
+
if (draft.revision !== history.length)
|
|
405
|
+
throw new Error('admission invariant: revision/journal mismatch');
|
|
406
|
+
const record = finalizeRecord(draft);
|
|
407
|
+
const previous = history.at(-1);
|
|
408
|
+
const entry = finalizeJournal({
|
|
409
|
+
'@version': ADMISSION_JOURNAL_VERSION,
|
|
410
|
+
tenant_id: record.tenant_id,
|
|
411
|
+
admission_id: record.admission_id,
|
|
412
|
+
operation_id: record.operation_id,
|
|
413
|
+
sequence: history.length,
|
|
414
|
+
event,
|
|
415
|
+
snapshot_digest: record.snapshot_digest,
|
|
416
|
+
record_digest: record.record_digest,
|
|
417
|
+
predecessor_digest: previous?.entry_digest ?? null,
|
|
418
|
+
recorded_at: at,
|
|
419
|
+
});
|
|
420
|
+
journals.set(key, [...history, entry]);
|
|
421
|
+
records.set(key, { record, ownerToken });
|
|
422
|
+
return record;
|
|
423
|
+
}
|
|
424
|
+
function next(current, at, changes) {
|
|
425
|
+
return {
|
|
426
|
+
...current,
|
|
427
|
+
...changes,
|
|
428
|
+
revision: current.revision + 1,
|
|
429
|
+
updated_at: at,
|
|
430
|
+
predecessor_record_digest: current.record_digest,
|
|
431
|
+
resources: changes.resources ?? current.resources.map((resource) => ({ ...resource })),
|
|
432
|
+
record_digest: undefined,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
function selectCas(input) {
|
|
436
|
+
const key = admissionKey(identifier(input.tenant_id, 'tenant_id'), identifier(input.admission_id, 'admission_id'));
|
|
437
|
+
const stored = records.get(key);
|
|
438
|
+
if (!stored)
|
|
439
|
+
return { ok: false, reason: 'admission_not_found' };
|
|
440
|
+
if (stored.record.revision !== nonNegativeInteger(input.expected_revision, 'expected_revision'))
|
|
441
|
+
return { ok: false, reason: 'revision_conflict' };
|
|
442
|
+
if (stored.ownerToken !== validateOwner(input.owner_token))
|
|
443
|
+
return { ok: false, reason: 'owner_conflict' };
|
|
444
|
+
return { key, stored };
|
|
445
|
+
}
|
|
446
|
+
function resourcesAvailable(snapshot, ignored) {
|
|
447
|
+
return snapshot.body.resource_reservations.every((resource) => {
|
|
448
|
+
const owner = resourceOwners.get(resourceKey(snapshot.body.tenant_id, resource));
|
|
449
|
+
return owner === undefined || owner === ignored;
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
function claimResources(snapshot, key) {
|
|
453
|
+
for (const resource of snapshot.body.resource_reservations)
|
|
454
|
+
resourceOwners.set(resourceKey(snapshot.body.tenant_id, resource), key);
|
|
455
|
+
}
|
|
456
|
+
function freeResources(record, key) {
|
|
457
|
+
for (const resource of record.resources) {
|
|
458
|
+
const rKey = resourceKey(record.tenant_id, resource);
|
|
459
|
+
if (resourceOwners.get(rKey) === key)
|
|
460
|
+
resourceOwners.delete(rKey);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
function invariantViolations() {
|
|
464
|
+
const violations = [];
|
|
465
|
+
for (const [key, stored] of records) {
|
|
466
|
+
const record = stored.record;
|
|
467
|
+
const history = journals.get(key) ?? [];
|
|
468
|
+
if (!snapshots.has(record.snapshot_digest))
|
|
469
|
+
violations.push(`${key}:snapshot_missing`);
|
|
470
|
+
if (!verifyAdmissionJournal(history).ok)
|
|
471
|
+
violations.push(`${key}:journal_invalid`);
|
|
472
|
+
if (history.length !== record.revision + 1 || history.at(-1)?.record_digest !== record.record_digest)
|
|
473
|
+
violations.push(`${key}:head_mismatch`);
|
|
474
|
+
if (record.state === 'RESERVED' && (record.execution_right !== 'RESERVED' || record.provider_attempt !== 'NOT_ENTERED'))
|
|
475
|
+
violations.push(`${key}:reserved_invalid`);
|
|
476
|
+
if (['INVOKING', 'INDETERMINATE', 'COMMITTED', 'PROVEN_NOT_COMMITTED'].includes(record.state)
|
|
477
|
+
&& (record.execution_right !== 'CONSUMED' || record.resources.some((resource) => resource.state !== 'CONSUMED')))
|
|
478
|
+
violations.push(`${key}:consumption_invalid`);
|
|
479
|
+
if (record.state === 'SUPERSEDED' && record.superseded_by_admission_id === null)
|
|
480
|
+
violations.push(`${key}:supersession_missing`);
|
|
481
|
+
}
|
|
482
|
+
for (const [op, admission] of operationHeads) {
|
|
483
|
+
const stored = records.get(admission);
|
|
484
|
+
if (!stored || operationKey(stored.record.tenant_id, stored.record.operation_id) !== op)
|
|
485
|
+
violations.push(`${op}:operation_head_invalid`);
|
|
486
|
+
}
|
|
487
|
+
return violations;
|
|
488
|
+
}
|
|
489
|
+
const store = {
|
|
490
|
+
testOnly: true,
|
|
491
|
+
durable: false,
|
|
492
|
+
atomic: true,
|
|
493
|
+
compareAndSwap: true,
|
|
494
|
+
appendOnlyJournal: true,
|
|
495
|
+
exclusiveActuation: true,
|
|
496
|
+
transactionalCurrentness: true,
|
|
497
|
+
reserve(raw) {
|
|
498
|
+
return atomic(() => {
|
|
499
|
+
const snapshot = plain(raw) && Object.hasOwn(raw, 'snapshot_digest')
|
|
500
|
+
? validateSnapshot(raw)
|
|
501
|
+
: createAdmissionSnapshot(raw);
|
|
502
|
+
const body = snapshot.body;
|
|
503
|
+
if (body.supersedes_admission_id !== null)
|
|
504
|
+
return { ok: false, reason: 'relation_conflict' };
|
|
505
|
+
const key = admissionKey(body.tenant_id, body.admission_id);
|
|
506
|
+
const opKey = operationKey(body.tenant_id, body.operation_id);
|
|
507
|
+
if (records.has(key))
|
|
508
|
+
return { ok: false, reason: 'admission_exists' };
|
|
509
|
+
if (operationHeads.has(opKey))
|
|
510
|
+
return { ok: false, reason: 'operation_exists' };
|
|
511
|
+
const now = currentMs(options.now);
|
|
512
|
+
if (Date.parse(body.expires_at) <= now)
|
|
513
|
+
return { ok: false, reason: 'admission_expired' };
|
|
514
|
+
if (body.remedy_for !== null) {
|
|
515
|
+
const target = records.get(admissionKey(body.remedy_for.tenant_id, body.remedy_for.admission_id));
|
|
516
|
+
if (!target || target.record.snapshot_digest !== body.remedy_for.snapshot_digest)
|
|
517
|
+
return { ok: false, reason: 'relation_not_found' };
|
|
518
|
+
if (!['INVOKING', 'INDETERMINATE', 'COMMITTED', 'PROVEN_NOT_COMMITTED'].includes(target.record.state))
|
|
519
|
+
return { ok: false, reason: 'relation_conflict' };
|
|
520
|
+
const targetSnapshot = snapshots.get(target.record.snapshot_digest);
|
|
521
|
+
if (!targetSnapshot || targetSnapshot.body.operation_id === body.operation_id || targetSnapshot.body.caid === body.caid)
|
|
522
|
+
return { ok: false, reason: 'relation_conflict' };
|
|
523
|
+
}
|
|
524
|
+
if (!resourcesAvailable(snapshot))
|
|
525
|
+
return { ok: false, reason: 'resource_conflict' };
|
|
526
|
+
const owner = validateOwner(ownerFactory());
|
|
527
|
+
const at = new Date(now).toISOString();
|
|
528
|
+
snapshots.set(snapshot.snapshot_digest, snapshot);
|
|
529
|
+
operationHeads.set(opKey, key);
|
|
530
|
+
claimResources(snapshot, key);
|
|
531
|
+
const record = append(key, owner, {
|
|
532
|
+
'@version': ADMISSION_RECORD_VERSION,
|
|
533
|
+
tenant_id: body.tenant_id,
|
|
534
|
+
admission_id: body.admission_id,
|
|
535
|
+
operation_id: body.operation_id,
|
|
536
|
+
snapshot_digest: snapshot.snapshot_digest,
|
|
537
|
+
revision: 0,
|
|
538
|
+
state: 'RESERVED',
|
|
539
|
+
execution_right: 'RESERVED',
|
|
540
|
+
provider_attempt: 'NOT_ENTERED',
|
|
541
|
+
owner_digest: tokenDigest(owner),
|
|
542
|
+
invocation_token_digest: null,
|
|
543
|
+
provider_outcome: null,
|
|
544
|
+
effect_relation: null,
|
|
545
|
+
resources: body.resource_reservations.map((resource) => ({ ...resource, state: 'RESERVED' })),
|
|
546
|
+
superseded_by_admission_id: null,
|
|
547
|
+
refusal_reason: null,
|
|
548
|
+
invocation_started_at: null,
|
|
549
|
+
created_at: at,
|
|
550
|
+
updated_at: at,
|
|
551
|
+
predecessor_record_digest: null,
|
|
552
|
+
}, 'RESERVED', at);
|
|
553
|
+
return { ok: true, snapshot, record, owner_token: owner };
|
|
554
|
+
});
|
|
555
|
+
},
|
|
556
|
+
release(input, reason = 'released_before_invocation') {
|
|
557
|
+
return atomic(() => {
|
|
558
|
+
const selected = selectCas(input);
|
|
559
|
+
if ('ok' in selected)
|
|
560
|
+
return selected;
|
|
561
|
+
const { key, stored } = selected;
|
|
562
|
+
if (stored.record.execution_right === 'CONSUMED')
|
|
563
|
+
return { ok: false, reason: 'execution_right_consumed' };
|
|
564
|
+
if (stored.record.state !== 'RESERVED')
|
|
565
|
+
return { ok: false, reason: 'state_conflict' };
|
|
566
|
+
const at = new Date(currentMs(options.now)).toISOString();
|
|
567
|
+
freeResources(stored.record, key);
|
|
568
|
+
const record = append(key, stored.ownerToken, next(stored.record, at, {
|
|
569
|
+
state: 'RELEASED', execution_right: 'RELEASED', refusal_reason: reason,
|
|
570
|
+
resources: stored.record.resources.map((resource) => ({ ...resource, state: 'RELEASED' })),
|
|
571
|
+
}), 'RELEASED', at);
|
|
572
|
+
return { ok: true, record };
|
|
573
|
+
});
|
|
574
|
+
},
|
|
575
|
+
expire(input) {
|
|
576
|
+
return atomic(() => {
|
|
577
|
+
const selected = selectCas(input);
|
|
578
|
+
if ('ok' in selected)
|
|
579
|
+
return selected;
|
|
580
|
+
const { key, stored } = selected;
|
|
581
|
+
if (stored.record.state !== 'RESERVED')
|
|
582
|
+
return { ok: false, reason: 'state_conflict' };
|
|
583
|
+
const snapshot = snapshots.get(stored.record.snapshot_digest);
|
|
584
|
+
const now = currentMs(options.now);
|
|
585
|
+
if (Date.parse(snapshot.body.expires_at) > now)
|
|
586
|
+
return { ok: false, reason: 'state_conflict' };
|
|
587
|
+
const at = new Date(now).toISOString();
|
|
588
|
+
freeResources(stored.record, key);
|
|
589
|
+
const record = append(key, stored.ownerToken, next(stored.record, at, {
|
|
590
|
+
state: 'EXPIRED', execution_right: 'RELEASED', refusal_reason: 'admission_expired',
|
|
591
|
+
resources: stored.record.resources.map((resource) => ({ ...resource, state: 'RELEASED' })),
|
|
592
|
+
}), 'EXPIRED', at);
|
|
593
|
+
return { ok: true, record };
|
|
594
|
+
});
|
|
595
|
+
},
|
|
596
|
+
supersede(input) {
|
|
597
|
+
return atomic(() => {
|
|
598
|
+
const selected = selectCas(input);
|
|
599
|
+
if ('ok' in selected)
|
|
600
|
+
return selected;
|
|
601
|
+
const { key, stored } = selected;
|
|
602
|
+
if (stored.record.state !== 'RESERVED' || stored.record.execution_right !== 'RESERVED')
|
|
603
|
+
return { ok: false, reason: 'state_conflict' };
|
|
604
|
+
const predecessor = snapshots.get(stored.record.snapshot_digest);
|
|
605
|
+
const successor = createAdmissionSnapshot({ ...input.successor, supersedes_admission_id: stored.record.admission_id, remedy_for: null });
|
|
606
|
+
if (successor.body.admission_id === stored.record.admission_id || !exactOperationIdentity(predecessor.body, successor.body))
|
|
607
|
+
return { ok: false, reason: 'operation_conflict' };
|
|
608
|
+
const successorKey = admissionKey(successor.body.tenant_id, successor.body.admission_id);
|
|
609
|
+
if (records.has(successorKey))
|
|
610
|
+
return { ok: false, reason: 'admission_exists' };
|
|
611
|
+
const now = currentMs(options.now);
|
|
612
|
+
if (Date.parse(successor.body.expires_at) <= now)
|
|
613
|
+
return { ok: false, reason: 'admission_expired' };
|
|
614
|
+
if (!resourcesAvailable(successor, key))
|
|
615
|
+
return { ok: false, reason: 'resource_conflict' };
|
|
616
|
+
const at = new Date(now).toISOString();
|
|
617
|
+
freeResources(stored.record, key);
|
|
618
|
+
const predecessorRecord = append(key, stored.ownerToken, next(stored.record, at, {
|
|
619
|
+
state: 'SUPERSEDED', execution_right: 'RELEASED', superseded_by_admission_id: successor.body.admission_id,
|
|
620
|
+
resources: stored.record.resources.map((resource) => ({ ...resource, state: 'RELEASED' })),
|
|
621
|
+
}), 'SUPERSEDED', at);
|
|
622
|
+
const owner = validateOwner(ownerFactory());
|
|
623
|
+
snapshots.set(successor.snapshot_digest, successor);
|
|
624
|
+
claimResources(successor, successorKey);
|
|
625
|
+
operationHeads.set(operationKey(successor.body.tenant_id, successor.body.operation_id), successorKey);
|
|
626
|
+
const successorRecord = append(successorKey, owner, {
|
|
627
|
+
'@version': ADMISSION_RECORD_VERSION,
|
|
628
|
+
tenant_id: successor.body.tenant_id,
|
|
629
|
+
admission_id: successor.body.admission_id,
|
|
630
|
+
operation_id: successor.body.operation_id,
|
|
631
|
+
snapshot_digest: successor.snapshot_digest,
|
|
632
|
+
revision: 0,
|
|
633
|
+
state: 'RESERVED', execution_right: 'RESERVED', provider_attempt: 'NOT_ENTERED',
|
|
634
|
+
owner_digest: tokenDigest(owner), invocation_token_digest: null,
|
|
635
|
+
provider_outcome: null, effect_relation: null,
|
|
636
|
+
resources: successor.body.resource_reservations.map((resource) => ({ ...resource, state: 'RESERVED' })),
|
|
637
|
+
superseded_by_admission_id: null, refusal_reason: null, invocation_started_at: null,
|
|
638
|
+
created_at: at, updated_at: at, predecessor_record_digest: null,
|
|
639
|
+
}, 'RESERVED', at);
|
|
640
|
+
return { ok: true, predecessor_record: predecessorRecord, successor_snapshot: successor, successor_record: successorRecord, successor_owner_token: owner };
|
|
641
|
+
});
|
|
642
|
+
},
|
|
643
|
+
beginInvocation(input) {
|
|
644
|
+
return atomic(async () => {
|
|
645
|
+
const selected = selectCas(input);
|
|
646
|
+
if ('ok' in selected)
|
|
647
|
+
return selected;
|
|
648
|
+
const { key, stored } = selected;
|
|
649
|
+
if (stored.record.state !== 'RESERVED' || stored.record.execution_right !== 'RESERVED' || stored.record.provider_attempt !== 'NOT_ENTERED')
|
|
650
|
+
return { ok: false, reason: 'state_conflict' };
|
|
651
|
+
const snapshot = snapshots.get(stored.record.snapshot_digest);
|
|
652
|
+
const now = currentMs(options.now);
|
|
653
|
+
if (Date.parse(snapshot.body.expires_at) <= now)
|
|
654
|
+
return { ok: false, reason: 'admission_expired' };
|
|
655
|
+
const observation = options.currentnessOracle
|
|
656
|
+
? await options.currentnessOracle.read(snapshot)
|
|
657
|
+
: null;
|
|
658
|
+
if (!observation || !currentnessMatches(snapshot, observation, now, maxCurrentnessAgeMs)) {
|
|
659
|
+
const at = new Date(now).toISOString();
|
|
660
|
+
freeResources(stored.record, key);
|
|
661
|
+
append(key, stored.ownerToken, next(stored.record, at, {
|
|
662
|
+
state: 'RELEASED', execution_right: 'RELEASED', refusal_reason: 'currentness_refused',
|
|
663
|
+
resources: stored.record.resources.map((resource) => ({ ...resource, state: 'RELEASED' })),
|
|
664
|
+
}), 'RELEASED', at);
|
|
665
|
+
return { ok: false, reason: 'currentness_refused' };
|
|
666
|
+
}
|
|
667
|
+
const invocationToken = invocationFactory();
|
|
668
|
+
if (typeof invocationToken !== 'string' || invocationToken.length < 48)
|
|
669
|
+
fail('invalid_invocation_token', 'invocation token is invalid');
|
|
670
|
+
const at = new Date(now).toISOString();
|
|
671
|
+
const record = append(key, stored.ownerToken, next(stored.record, at, {
|
|
672
|
+
state: 'INVOKING', execution_right: 'CONSUMED', provider_attempt: 'INVOKING',
|
|
673
|
+
invocation_token_digest: tokenDigest(invocationToken), invocation_started_at: at,
|
|
674
|
+
resources: stored.record.resources.map((resource) => ({ ...resource, state: 'CONSUMED' })),
|
|
675
|
+
}), 'INVOKING', at);
|
|
676
|
+
return { ok: true, snapshot, record, invocation_token: invocationToken };
|
|
677
|
+
});
|
|
678
|
+
},
|
|
679
|
+
recoverIndeterminate(input) {
|
|
680
|
+
return atomic(() => {
|
|
681
|
+
const key = admissionKey(identifier(input.tenant_id, 'tenant_id'), identifier(input.admission_id, 'admission_id'));
|
|
682
|
+
const stored = records.get(key);
|
|
683
|
+
if (!stored)
|
|
684
|
+
return { ok: false, reason: 'admission_not_found' };
|
|
685
|
+
if (stored.ownerToken !== validateOwner(input.owner_token))
|
|
686
|
+
return { ok: false, reason: 'owner_conflict' };
|
|
687
|
+
if (stored.record.state !== 'INVOKING')
|
|
688
|
+
return { ok: false, reason: 'state_conflict' };
|
|
689
|
+
const reconciliationToken = invocationFactory();
|
|
690
|
+
if (typeof reconciliationToken !== 'string' || reconciliationToken.length < 48)
|
|
691
|
+
fail('invalid_invocation_token', 'reconciliation token is invalid');
|
|
692
|
+
const at = new Date(currentMs(options.now)).toISOString();
|
|
693
|
+
const record = append(key, stored.ownerToken, next(stored.record, at, {
|
|
694
|
+
state: 'INDETERMINATE', provider_attempt: 'INDETERMINATE',
|
|
695
|
+
invocation_token_digest: tokenDigest(reconciliationToken),
|
|
696
|
+
provider_outcome: { value: 'INDETERMINATE', evidence_digest: null, observed_at: at },
|
|
697
|
+
refusal_reason: 'ambiguous_provider_entry',
|
|
698
|
+
}), 'RECOVERED_INDETERMINATE', at);
|
|
699
|
+
return { ok: true, record, reconciliation_token: reconciliationToken };
|
|
700
|
+
});
|
|
701
|
+
},
|
|
702
|
+
recordProviderOutcome(input) {
|
|
703
|
+
return atomic(() => {
|
|
704
|
+
const selected = selectCas(input);
|
|
705
|
+
if ('ok' in selected)
|
|
706
|
+
return selected;
|
|
707
|
+
const { key, stored } = selected;
|
|
708
|
+
if (stored.record.execution_right !== 'CONSUMED' || stored.record.invocation_token_digest !== tokenDigest(input.invocation_token))
|
|
709
|
+
return { ok: false, reason: 'invocation_token_conflict' };
|
|
710
|
+
if (!['INVOKING', 'INDETERMINATE', 'COMMITTED', 'PROVEN_NOT_COMMITTED'].includes(stored.record.state))
|
|
711
|
+
return { ok: false, reason: 'state_conflict' };
|
|
712
|
+
if (!['COMMITTED', 'PROVEN_NOT_COMMITTED', 'INDETERMINATE'].includes(input.value))
|
|
713
|
+
fail('invalid_provider_outcome', 'provider outcome is invalid');
|
|
714
|
+
const observed = instant(input.observed_at, 'observed_at').iso;
|
|
715
|
+
const evidence = input.evidence_digest === null ? null : digest(input.evidence_digest, 'evidence_digest');
|
|
716
|
+
if (input.value !== 'INDETERMINATE' && evidence === null)
|
|
717
|
+
return { ok: false, reason: 'evidence_required' };
|
|
718
|
+
const current = stored.record.provider_outcome;
|
|
719
|
+
if (current && current.value !== 'INDETERMINATE' && (current.value !== input.value || current.evidence_digest !== evidence))
|
|
720
|
+
return { ok: false, reason: 'outcome_conflict' };
|
|
721
|
+
const at = new Date(currentMs(options.now)).toISOString();
|
|
722
|
+
const record = append(key, stored.ownerToken, next(stored.record, at, {
|
|
723
|
+
state: input.value,
|
|
724
|
+
provider_attempt: input.value,
|
|
725
|
+
provider_outcome: { value: input.value, evidence_digest: evidence, observed_at: observed },
|
|
726
|
+
}), 'PROVIDER_OUTCOME', at);
|
|
727
|
+
return { ok: true, record };
|
|
728
|
+
});
|
|
729
|
+
},
|
|
730
|
+
recordEffectRelation(input) {
|
|
731
|
+
return atomic(() => {
|
|
732
|
+
const selected = selectCas(input);
|
|
733
|
+
if ('ok' in selected)
|
|
734
|
+
return selected;
|
|
735
|
+
const { key, stored } = selected;
|
|
736
|
+
if (stored.record.execution_right !== 'CONSUMED' || stored.record.invocation_token_digest !== tokenDigest(input.invocation_token))
|
|
737
|
+
return { ok: false, reason: 'invocation_token_conflict' };
|
|
738
|
+
if (!['OBSERVED_AS_REQUESTED', 'DIVERGED', 'INDETERMINATE'].includes(input.value))
|
|
739
|
+
fail('invalid_effect_relation', 'effect relation is invalid');
|
|
740
|
+
const observed = instant(input.observed_at, 'observed_at').iso;
|
|
741
|
+
const evidence = input.evidence_digest === null ? null : digest(input.evidence_digest, 'evidence_digest');
|
|
742
|
+
if (input.value !== 'INDETERMINATE' && evidence === null)
|
|
743
|
+
return { ok: false, reason: 'evidence_required' };
|
|
744
|
+
const current = stored.record.effect_relation;
|
|
745
|
+
if (current && current.value !== 'INDETERMINATE' && (current.value !== input.value || current.evidence_digest !== evidence))
|
|
746
|
+
return { ok: false, reason: 'outcome_conflict' };
|
|
747
|
+
const at = new Date(currentMs(options.now)).toISOString();
|
|
748
|
+
const record = append(key, stored.ownerToken, next(stored.record, at, {
|
|
749
|
+
effect_relation: { value: input.value, evidence_digest: evidence, observed_at: observed },
|
|
750
|
+
}), 'EFFECT_RELATION', at);
|
|
751
|
+
return { ok: true, record };
|
|
752
|
+
});
|
|
753
|
+
},
|
|
754
|
+
read(input) {
|
|
755
|
+
return atomic(() => records.get(admissionKey(identifier(input.tenant_id, 'tenant_id'), identifier(input.admission_id, 'admission_id')))?.record ?? null);
|
|
756
|
+
},
|
|
757
|
+
readByOperation(input) {
|
|
758
|
+
return atomic(() => {
|
|
759
|
+
const head = operationHeads.get(operationKey(identifier(input.tenant_id, 'tenant_id'), identifier(input.operation_id, 'operation_id')));
|
|
760
|
+
return head ? records.get(head)?.record ?? null : null;
|
|
761
|
+
});
|
|
762
|
+
},
|
|
763
|
+
readSnapshot(raw) { return atomic(() => snapshots.get(digest(raw, 'snapshot_digest')) ?? null); },
|
|
764
|
+
journal(input) {
|
|
765
|
+
return atomic(() => journals.get(admissionKey(identifier(input.tenant_id, 'tenant_id'), identifier(input.admission_id, 'admission_id'))) ?? []);
|
|
766
|
+
},
|
|
767
|
+
checkInvariants() {
|
|
768
|
+
return atomic(() => {
|
|
769
|
+
const violations = invariantViolations();
|
|
770
|
+
return { ok: violations.length === 0, violations };
|
|
771
|
+
});
|
|
772
|
+
},
|
|
773
|
+
};
|
|
774
|
+
return store;
|
|
775
|
+
}
|
|
776
|
+
export default createMemoryAdmissionStore;
|
|
777
|
+
//# sourceMappingURL=admission-store.js.map
|