@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
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Durable coordinator for heterogeneous Remedy Program case sets.
|
|
4
|
+
*
|
|
5
|
+
* A case set is an immutable tenant-scoped manifest. Every leg names one exact
|
|
6
|
+
* child Remedy Program case and exact remedy bindings. The coordinator can
|
|
7
|
+
* become terminal only after the repository's signed child-receipt verifier
|
|
8
|
+
* accepts every leg against its full child state snapshot.
|
|
9
|
+
*/
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
import { canonicalize } from '../execution-binding.js';
|
|
13
|
+
import { verifyRemedyProgramReceipt } from './remedy-program-receipt.js';
|
|
14
|
+
|
|
15
|
+
export const REMEDY_CASE_SET_VERSION = 'EP-GATE-REMEDY-CASE-SET-v1';
|
|
16
|
+
|
|
17
|
+
type DataRecord = Record<string, any>;
|
|
18
|
+
type Failure = Readonly<{ ok: false; reason: string }>;
|
|
19
|
+
|
|
20
|
+
const DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
21
|
+
const CAID = /^caid:1:[a-z][a-z0-9.-]*\.[1-9][0-9]*:jcs-sha256:[A-Za-z0-9_-]{43}$/;
|
|
22
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._:/#@+-]{0,255}$/;
|
|
23
|
+
const CREATE_KEYS = new Set(['tenantId', 'caseSetId', 'ownerToken', 'legs']);
|
|
24
|
+
const RECORD_KEYS = new Set([
|
|
25
|
+
'tenantId', 'caseSetId', 'ownerToken', 'expectedRevision', 'children',
|
|
26
|
+
]);
|
|
27
|
+
const STATUS_KEYS = new Set(['tenantId', 'caseSetId']);
|
|
28
|
+
const LEG_KEYS = new Set([
|
|
29
|
+
'leg_id', 'child_instance_id', 'remedy_profile_digest',
|
|
30
|
+
'destination_binding_digest', 'max_remedy_units', 'unit', 'original', 'remedy',
|
|
31
|
+
]);
|
|
32
|
+
const ORIGINAL_KEYS = new Set([
|
|
33
|
+
'caid', 'action_digest', 'operation_id', 'consequence_mode',
|
|
34
|
+
'consequence_digest', 'terminal_evidence_digest', 'outcome', 'occurred_at',
|
|
35
|
+
]);
|
|
36
|
+
const REMEDY_KEYS = new Set([
|
|
37
|
+
'operation_id', 'caid', 'action_digest', 'owner_mode', 'owner_digest',
|
|
38
|
+
]);
|
|
39
|
+
const MANIFEST_KEYS = new Set(['version', 'tenant_id', 'case_set_id', 'legs']);
|
|
40
|
+
const CHILD_INPUT_KEYS = new Set(['legId', 'state', 'receipt']);
|
|
41
|
+
const OBSERVATION_KEYS = new Set([
|
|
42
|
+
'leg_id', 'status', 'case_revision', 'receipt_content_digest',
|
|
43
|
+
'state_snapshot_digest',
|
|
44
|
+
]);
|
|
45
|
+
const STATE_KEYS = new Set([
|
|
46
|
+
'version', 'tenant_id', 'case_set_id', 'status', 'revision', 'created_at',
|
|
47
|
+
'updated_at', 'owner_token_digest', 'manifest', 'manifest_digest',
|
|
48
|
+
'observations', 'create_request_digest', 'last_request_digest',
|
|
49
|
+
]);
|
|
50
|
+
const ISSUER_KEYS = new Set(['issuer', 'tenant', 'environment', 'audience', 'key_id']);
|
|
51
|
+
|
|
52
|
+
export interface RemedyCaseSetState extends Record<string, unknown> {
|
|
53
|
+
version: string;
|
|
54
|
+
tenant_id: string;
|
|
55
|
+
case_set_id: string;
|
|
56
|
+
status: 'open' | 'indeterminate' | 'completed';
|
|
57
|
+
revision: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface RemedyCaseSetResult extends Record<string, unknown> {
|
|
61
|
+
ok: boolean;
|
|
62
|
+
reason?: string;
|
|
63
|
+
state?: RemedyCaseSetState;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface RemedyCaseSetStore {
|
|
67
|
+
readonly durable: boolean;
|
|
68
|
+
create(state: RemedyCaseSetState): unknown | Promise<unknown>;
|
|
69
|
+
get(input: Readonly<{ tenantId: string; caseSetId: string }>): unknown | Promise<unknown>;
|
|
70
|
+
compareAndSwap(input: Readonly<{
|
|
71
|
+
tenantId: string;
|
|
72
|
+
caseSetId: string;
|
|
73
|
+
expectedRevision: number;
|
|
74
|
+
ownerTokenDigest: string;
|
|
75
|
+
state: RemedyCaseSetState;
|
|
76
|
+
}>): unknown | Promise<unknown>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface RemedyCaseSetCoordinatorOptions {
|
|
80
|
+
store: RemedyCaseSetStore;
|
|
81
|
+
tenantId: string;
|
|
82
|
+
trustedReceiptKeys: Record<string, string>;
|
|
83
|
+
expectedReceiptIssuer: {
|
|
84
|
+
issuer: string;
|
|
85
|
+
tenant: string;
|
|
86
|
+
environment: string;
|
|
87
|
+
audience: string;
|
|
88
|
+
key_id: string;
|
|
89
|
+
};
|
|
90
|
+
now?: () => number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isRecord(value: unknown): value is DataRecord {
|
|
94
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
95
|
+
const prototype = Object.getPrototypeOf(value);
|
|
96
|
+
return prototype === Object.prototype || prototype === null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isDataRecord(value: unknown): value is DataRecord {
|
|
100
|
+
if (!isRecord(value)) return false;
|
|
101
|
+
return Reflect.ownKeys(value).every((key) => {
|
|
102
|
+
if (typeof key !== 'string') return false;
|
|
103
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
104
|
+
return descriptor?.enumerable === true && Object.hasOwn(descriptor, 'value');
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function exactKeys(value: unknown, keys: Set<string>): value is DataRecord {
|
|
109
|
+
return isDataRecord(value)
|
|
110
|
+
&& Object.keys(value).length === keys.size
|
|
111
|
+
&& Object.keys(value).every((key) => keys.has(key));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function validId(value: unknown): value is string {
|
|
115
|
+
return typeof value === 'string' && ID.test(value);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function validContext(value: unknown): value is string {
|
|
119
|
+
return typeof value === 'string' && value.length > 0
|
|
120
|
+
&& Buffer.byteLength(value, 'utf8') <= 512
|
|
121
|
+
&& !/[\u0000-\u001f\u007f]/.test(value);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function instant(value: unknown): number {
|
|
125
|
+
if (typeof value !== 'string'
|
|
126
|
+
|| !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return NaN;
|
|
127
|
+
const parsed = Date.parse(value);
|
|
128
|
+
return Number.isFinite(parsed) && new Date(parsed).toISOString() === value ? parsed : NaN;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function canonicalCopy<T>(value: T): T {
|
|
132
|
+
return JSON.parse(canonicalize(value));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function canonicalDigest(value: unknown): string {
|
|
136
|
+
return `sha256:${crypto.createHash('sha256').update(canonicalize(value)).digest('hex')}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function same(left: unknown, right: unknown): boolean {
|
|
140
|
+
try {
|
|
141
|
+
return canonicalize(left) === canonicalize(right);
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function deepFreeze<T>(value: T): Readonly<T> {
|
|
148
|
+
if (value === null || typeof value !== 'object' || Object.isFrozen(value)) return value;
|
|
149
|
+
Object.freeze(value);
|
|
150
|
+
for (const child of Object.values(value as DataRecord)) deepFreeze(child);
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function fail(reason: string): Failure {
|
|
155
|
+
return Object.freeze({ ok: false, reason });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function validOriginal(value: unknown): value is DataRecord {
|
|
159
|
+
return exactKeys(value, ORIGINAL_KEYS)
|
|
160
|
+
&& typeof value.caid === 'string' && CAID.test(value.caid)
|
|
161
|
+
&& typeof value.action_digest === 'string' && DIGEST.test(value.action_digest)
|
|
162
|
+
&& validId(value.operation_id)
|
|
163
|
+
&& ['receipt-program', 'action-escrow'].includes(value.consequence_mode)
|
|
164
|
+
&& typeof value.consequence_digest === 'string' && DIGEST.test(value.consequence_digest)
|
|
165
|
+
&& typeof value.terminal_evidence_digest === 'string' && DIGEST.test(value.terminal_evidence_digest)
|
|
166
|
+
&& ['executed', 'indeterminate'].includes(value.outcome)
|
|
167
|
+
&& Number.isFinite(instant(value.occurred_at));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function validRemedy(value: unknown): value is DataRecord {
|
|
171
|
+
return exactKeys(value, REMEDY_KEYS)
|
|
172
|
+
&& validId(value.operation_id)
|
|
173
|
+
&& typeof value.caid === 'string' && CAID.test(value.caid)
|
|
174
|
+
&& typeof value.action_digest === 'string' && DIGEST.test(value.action_digest)
|
|
175
|
+
&& ['receipt-program', 'action-escrow'].includes(value.owner_mode)
|
|
176
|
+
&& typeof value.owner_digest === 'string' && DIGEST.test(value.owner_digest);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function validLeg(value: unknown): value is DataRecord {
|
|
180
|
+
return exactKeys(value, LEG_KEYS)
|
|
181
|
+
&& validId(value.leg_id)
|
|
182
|
+
&& validId(value.child_instance_id)
|
|
183
|
+
&& typeof value.remedy_profile_digest === 'string' && DIGEST.test(value.remedy_profile_digest)
|
|
184
|
+
&& typeof value.destination_binding_digest === 'string'
|
|
185
|
+
&& DIGEST.test(value.destination_binding_digest)
|
|
186
|
+
&& Number.isSafeInteger(value.max_remedy_units) && value.max_remedy_units > 0
|
|
187
|
+
&& validContext(value.unit)
|
|
188
|
+
&& validOriginal(value.original)
|
|
189
|
+
&& validRemedy(value.remedy)
|
|
190
|
+
&& value.remedy.operation_id !== value.original.operation_id
|
|
191
|
+
&& value.remedy.action_digest !== value.original.action_digest;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function validManifest(value: unknown, tenantId?: string, caseSetId?: string): value is DataRecord {
|
|
195
|
+
if (!exactKeys(value, MANIFEST_KEYS)
|
|
196
|
+
|| value.version !== REMEDY_CASE_SET_VERSION
|
|
197
|
+
|| !validContext(value.tenant_id)
|
|
198
|
+
|| (tenantId !== undefined && value.tenant_id !== tenantId)
|
|
199
|
+
|| !validId(value.case_set_id)
|
|
200
|
+
|| (caseSetId !== undefined && value.case_set_id !== caseSetId)
|
|
201
|
+
|| !Array.isArray(value.legs) || value.legs.length === 0 || value.legs.length > 256
|
|
202
|
+
|| !value.legs.every(validLeg)) return false;
|
|
203
|
+
const unique = (selector: (leg: DataRecord) => string) => (
|
|
204
|
+
new Set(value.legs.map(selector)).size === value.legs.length
|
|
205
|
+
);
|
|
206
|
+
return unique((leg) => leg.leg_id)
|
|
207
|
+
&& unique((leg) => leg.child_instance_id)
|
|
208
|
+
&& unique((leg) => leg.remedy.operation_id)
|
|
209
|
+
&& unique((leg) => leg.remedy.action_digest)
|
|
210
|
+
&& unique((leg) => leg.remedy.caid);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function validObservation(value: unknown): value is DataRecord {
|
|
214
|
+
if (!exactKeys(value, OBSERVATION_KEYS) || !validId(value.leg_id)
|
|
215
|
+
|| !['pending', 'indeterminate', 'executed'].includes(value.status)) return false;
|
|
216
|
+
const pending = value.status === 'pending';
|
|
217
|
+
return pending
|
|
218
|
+
? value.case_revision === null
|
|
219
|
+
&& value.receipt_content_digest === null
|
|
220
|
+
&& value.state_snapshot_digest === null
|
|
221
|
+
: Number.isSafeInteger(value.case_revision) && value.case_revision >= 0
|
|
222
|
+
&& typeof value.receipt_content_digest === 'string'
|
|
223
|
+
&& DIGEST.test(value.receipt_content_digest)
|
|
224
|
+
&& typeof value.state_snapshot_digest === 'string'
|
|
225
|
+
&& DIGEST.test(value.state_snapshot_digest);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function validState(value: unknown, tenantId?: string, caseSetId?: string): value is DataRecord {
|
|
229
|
+
if (!exactKeys(value, STATE_KEYS)
|
|
230
|
+
|| value.version !== REMEDY_CASE_SET_VERSION
|
|
231
|
+
|| !validContext(value.tenant_id)
|
|
232
|
+
|| (tenantId !== undefined && value.tenant_id !== tenantId)
|
|
233
|
+
|| !validId(value.case_set_id)
|
|
234
|
+
|| (caseSetId !== undefined && value.case_set_id !== caseSetId)
|
|
235
|
+
|| !['open', 'indeterminate', 'completed'].includes(value.status)
|
|
236
|
+
|| !Number.isSafeInteger(value.revision) || value.revision < 0
|
|
237
|
+
|| !Number.isFinite(instant(value.created_at))
|
|
238
|
+
|| !Number.isFinite(instant(value.updated_at))
|
|
239
|
+
|| instant(value.updated_at) < instant(value.created_at)
|
|
240
|
+
|| typeof value.owner_token_digest !== 'string' || !DIGEST.test(value.owner_token_digest)
|
|
241
|
+
|| !validManifest(value.manifest, value.tenant_id, value.case_set_id)
|
|
242
|
+
|| typeof value.manifest_digest !== 'string'
|
|
243
|
+
|| value.manifest_digest !== canonicalDigest(value.manifest)
|
|
244
|
+
|| !Array.isArray(value.observations)
|
|
245
|
+
|| value.observations.length !== value.manifest.legs.length
|
|
246
|
+
|| !value.observations.every(validObservation)
|
|
247
|
+
|| typeof value.create_request_digest !== 'string' || !DIGEST.test(value.create_request_digest)
|
|
248
|
+
|| (value.last_request_digest !== null
|
|
249
|
+
&& (typeof value.last_request_digest !== 'string' || !DIGEST.test(value.last_request_digest)))) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
const legIds = value.manifest.legs.map((leg: DataRecord) => leg.leg_id);
|
|
253
|
+
if (value.observations.some((entry: DataRecord, index: number) => entry.leg_id !== legIds[index])) {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
const statuses = value.observations.map((entry: DataRecord) => entry.status);
|
|
257
|
+
if (value.status === 'open') return statuses.every((status: string) => status === 'pending');
|
|
258
|
+
if (value.status === 'completed') return statuses.every((status: string) => status === 'executed');
|
|
259
|
+
return statuses.includes('indeterminate') && statuses.every((status: string) => status !== 'pending');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function validIssuer(value: unknown): value is DataRecord {
|
|
263
|
+
return exactKeys(value, ISSUER_KEYS) && Object.values(value).every(validContext);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function publicKey(value: unknown): crypto.KeyObject | null {
|
|
267
|
+
try {
|
|
268
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value)) return null;
|
|
269
|
+
const bytes = Buffer.from(value, 'base64url');
|
|
270
|
+
if (bytes.toString('base64url') !== value) return null;
|
|
271
|
+
const key = crypto.createPublicKey({ key: bytes, format: 'der', type: 'spki' });
|
|
272
|
+
return key.asymmetricKeyType === 'ed25519' ? key : null;
|
|
273
|
+
} catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function ownerDigest(value: string): string {
|
|
279
|
+
return canonicalDigest({ owner_token: value });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function originalWithoutEvidence(value: DataRecord): DataRecord {
|
|
283
|
+
const result = canonicalCopy(value);
|
|
284
|
+
delete result.evidence_digest;
|
|
285
|
+
return result;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Build a durable, ownership-fenced coordinator for one pinned tenant. */
|
|
289
|
+
export function createRemedyCaseSetCoordinator(options: RemedyCaseSetCoordinatorOptions) {
|
|
290
|
+
if (!isDataRecord(options)
|
|
291
|
+
|| !options.store || options.store.durable !== true
|
|
292
|
+
|| typeof options.store.create !== 'function'
|
|
293
|
+
|| typeof options.store.get !== 'function'
|
|
294
|
+
|| typeof options.store.compareAndSwap !== 'function'
|
|
295
|
+
|| !validContext(options.tenantId)
|
|
296
|
+
|| !validIssuer(options.expectedReceiptIssuer)
|
|
297
|
+
|| options.expectedReceiptIssuer.tenant !== options.tenantId
|
|
298
|
+
|| !isDataRecord(options.trustedReceiptKeys)
|
|
299
|
+
|| Object.keys(options.trustedReceiptKeys).length === 0
|
|
300
|
+
|| !Object.entries(options.trustedReceiptKeys).every(
|
|
301
|
+
([keyId, key]) => validId(keyId) && publicKey(key) !== null,
|
|
302
|
+
)
|
|
303
|
+
|| !Object.hasOwn(options.trustedReceiptKeys, options.expectedReceiptIssuer.key_id)
|
|
304
|
+
|| (options.now !== undefined && typeof options.now !== 'function')) {
|
|
305
|
+
throw new TypeError('durable Remedy case-set coordinator configuration invalid');
|
|
306
|
+
}
|
|
307
|
+
const sourceStore = options.store;
|
|
308
|
+
const store = Object.freeze({
|
|
309
|
+
create: sourceStore.create.bind(sourceStore),
|
|
310
|
+
get: sourceStore.get.bind(sourceStore),
|
|
311
|
+
compareAndSwap: sourceStore.compareAndSwap.bind(sourceStore),
|
|
312
|
+
});
|
|
313
|
+
const tenantId = options.tenantId;
|
|
314
|
+
const trustedReceiptKeys = deepFreeze(canonicalCopy(options.trustedReceiptKeys));
|
|
315
|
+
const expectedReceiptIssuer = deepFreeze(canonicalCopy(options.expectedReceiptIssuer));
|
|
316
|
+
const now = options.now ?? Date.now;
|
|
317
|
+
|
|
318
|
+
const clock = (): number => {
|
|
319
|
+
try {
|
|
320
|
+
const value = now();
|
|
321
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : NaN;
|
|
322
|
+
} catch {
|
|
323
|
+
return NaN;
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const checkedState = (value: unknown, caseSetId: string): RemedyCaseSetResult => {
|
|
328
|
+
try {
|
|
329
|
+
const state = canonicalCopy(value);
|
|
330
|
+
return validState(state, tenantId, caseSetId)
|
|
331
|
+
? { ok: true, state: deepFreeze(state) as RemedyCaseSetState }
|
|
332
|
+
: fail('store_state_invalid');
|
|
333
|
+
} catch {
|
|
334
|
+
return fail('store_state_invalid');
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const load = async (caseSetId: string): Promise<RemedyCaseSetResult> => {
|
|
339
|
+
try {
|
|
340
|
+
const result: any = await store.get({ tenantId, caseSetId });
|
|
341
|
+
if (!isDataRecord(result)) return fail('store_response_invalid');
|
|
342
|
+
if (result.ok === true) return checkedState(result.state, caseSetId);
|
|
343
|
+
if (result.reason === 'case_set_not_found') return fail('case_set_not_found');
|
|
344
|
+
return fail('store_operation_failed');
|
|
345
|
+
} catch {
|
|
346
|
+
return fail('store_unavailable');
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
async function create(input: unknown): Promise<RemedyCaseSetResult> {
|
|
351
|
+
let value: DataRecord;
|
|
352
|
+
try {
|
|
353
|
+
value = canonicalCopy(input) as DataRecord;
|
|
354
|
+
} catch {
|
|
355
|
+
return fail('case_set_create_input_invalid');
|
|
356
|
+
}
|
|
357
|
+
if (!exactKeys(value, CREATE_KEYS)
|
|
358
|
+
|| value.tenantId !== tenantId
|
|
359
|
+
|| !validId(value.caseSetId)
|
|
360
|
+
|| !validContext(value.ownerToken) || Buffer.byteLength(value.ownerToken, 'utf8') > 256
|
|
361
|
+
|| !Array.isArray(value.legs)) return fail('case_set_create_input_invalid');
|
|
362
|
+
const manifest = {
|
|
363
|
+
version: REMEDY_CASE_SET_VERSION,
|
|
364
|
+
tenant_id: tenantId,
|
|
365
|
+
case_set_id: value.caseSetId,
|
|
366
|
+
legs: value.legs,
|
|
367
|
+
};
|
|
368
|
+
if (!validManifest(manifest, tenantId, value.caseSetId)) {
|
|
369
|
+
return fail('case_set_manifest_invalid');
|
|
370
|
+
}
|
|
371
|
+
const requestDigest = canonicalDigest(value);
|
|
372
|
+
const existing = await load(value.caseSetId);
|
|
373
|
+
if (existing.ok) {
|
|
374
|
+
return (existing.state as DataRecord).create_request_digest === requestDigest
|
|
375
|
+
? { ok: true, idempotent: true, state: existing.state }
|
|
376
|
+
: fail('case_set_exists');
|
|
377
|
+
}
|
|
378
|
+
if (existing.reason !== 'case_set_not_found') return existing;
|
|
379
|
+
const at = clock();
|
|
380
|
+
if (!Number.isFinite(at)) return fail('clock_invalid');
|
|
381
|
+
const timestamp = new Date(at).toISOString();
|
|
382
|
+
const state: DataRecord = {
|
|
383
|
+
version: REMEDY_CASE_SET_VERSION,
|
|
384
|
+
tenant_id: tenantId,
|
|
385
|
+
case_set_id: value.caseSetId,
|
|
386
|
+
status: 'open',
|
|
387
|
+
revision: 0,
|
|
388
|
+
created_at: timestamp,
|
|
389
|
+
updated_at: timestamp,
|
|
390
|
+
owner_token_digest: ownerDigest(value.ownerToken),
|
|
391
|
+
manifest: canonicalCopy(manifest),
|
|
392
|
+
manifest_digest: canonicalDigest(manifest),
|
|
393
|
+
observations: manifest.legs.map((entry: DataRecord) => ({
|
|
394
|
+
leg_id: entry.leg_id,
|
|
395
|
+
status: 'pending',
|
|
396
|
+
case_revision: null,
|
|
397
|
+
receipt_content_digest: null,
|
|
398
|
+
state_snapshot_digest: null,
|
|
399
|
+
})),
|
|
400
|
+
create_request_digest: requestDigest,
|
|
401
|
+
last_request_digest: null,
|
|
402
|
+
};
|
|
403
|
+
if (!validState(state, tenantId, value.caseSetId)) return fail('case_set_manifest_invalid');
|
|
404
|
+
try {
|
|
405
|
+
const stored: any = await store.create(deepFreeze(canonicalCopy(state)) as RemedyCaseSetState);
|
|
406
|
+
if (!isDataRecord(stored)) return fail('store_response_invalid');
|
|
407
|
+
if (stored.ok === true) return checkedState(stored.state ?? state, value.caseSetId);
|
|
408
|
+
if (stored.reason !== 'case_set_exists') return fail('store_operation_failed');
|
|
409
|
+
const raced = await load(value.caseSetId);
|
|
410
|
+
return raced.ok && (raced.state as DataRecord).create_request_digest === requestDigest
|
|
411
|
+
? { ok: true, idempotent: true, state: raced.state }
|
|
412
|
+
: fail('case_set_exists');
|
|
413
|
+
} catch {
|
|
414
|
+
return fail('store_unavailable');
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function verifyChild(
|
|
419
|
+
child: DataRecord,
|
|
420
|
+
leg: DataRecord,
|
|
421
|
+
previous: DataRecord,
|
|
422
|
+
): DataRecord | Failure {
|
|
423
|
+
try {
|
|
424
|
+
const state = canonicalCopy(child.state);
|
|
425
|
+
if (!isDataRecord(state)
|
|
426
|
+
|| state.tenant_id !== tenantId
|
|
427
|
+
|| state.instance_id !== leg.child_instance_id
|
|
428
|
+
|| state.remedy_profile_digest !== leg.remedy_profile_digest
|
|
429
|
+
|| state.destination_binding_digest !== leg.destination_binding_digest
|
|
430
|
+
|| state.max_remedy_units !== leg.max_remedy_units
|
|
431
|
+
|| state.unit !== leg.unit
|
|
432
|
+
|| !same(originalWithoutEvidence(state.original), leg.original)) {
|
|
433
|
+
return fail('child_receipt_binding_mismatch');
|
|
434
|
+
}
|
|
435
|
+
const attempts = [
|
|
436
|
+
...(state.active_remedy === null ? [] : [state.active_remedy]),
|
|
437
|
+
...(Array.isArray(state.remedies) ? state.remedies : []),
|
|
438
|
+
];
|
|
439
|
+
if (attempts.length !== 1) return fail('child_receipt_binding_mismatch');
|
|
440
|
+
const attempt = attempts[0];
|
|
441
|
+
if (!isDataRecord(attempt)
|
|
442
|
+
|| attempt.remedy_operation_id !== leg.remedy.operation_id
|
|
443
|
+
|| attempt.remedy_caid !== leg.remedy.caid
|
|
444
|
+
|| attempt.remedy_action_digest !== leg.remedy.action_digest
|
|
445
|
+
|| attempt.destination_binding_digest !== leg.destination_binding_digest
|
|
446
|
+
|| attempt.units !== leg.max_remedy_units
|
|
447
|
+
|| attempt.unit !== leg.unit) return fail('child_receipt_binding_mismatch');
|
|
448
|
+
const expected = {
|
|
449
|
+
original_operation_id: leg.original.operation_id,
|
|
450
|
+
original_action_digest: leg.original.action_digest,
|
|
451
|
+
original_terminal_evidence_digest: leg.original.terminal_evidence_digest,
|
|
452
|
+
case_instance_id: leg.child_instance_id,
|
|
453
|
+
case_revision: state.revision,
|
|
454
|
+
case_status: state.status,
|
|
455
|
+
remedy_operation_id: leg.remedy.operation_id,
|
|
456
|
+
remedy_action_digest: leg.remedy.action_digest,
|
|
457
|
+
remedy_caid: leg.remedy.caid,
|
|
458
|
+
destination_binding_digest: leg.destination_binding_digest,
|
|
459
|
+
units: leg.max_remedy_units,
|
|
460
|
+
unit: leg.unit,
|
|
461
|
+
owner_mode: leg.remedy.owner_mode,
|
|
462
|
+
owner_digest: leg.remedy.owner_digest,
|
|
463
|
+
};
|
|
464
|
+
const verified = verifyRemedyProgramReceipt(child.receipt, {
|
|
465
|
+
trustedKeys: trustedReceiptKeys,
|
|
466
|
+
expectedIssuer: expectedReceiptIssuer,
|
|
467
|
+
state,
|
|
468
|
+
expected,
|
|
469
|
+
});
|
|
470
|
+
if (verified.valid !== true || !isDataRecord(verified.payload)) {
|
|
471
|
+
return fail(verified.reason === 'receipt_expected_binding_mismatch'
|
|
472
|
+
|| verified.reason === 'receipt_state_snapshot_mismatch'
|
|
473
|
+
? 'child_receipt_binding_mismatch' : 'child_receipt_invalid');
|
|
474
|
+
}
|
|
475
|
+
const remedy = verified.payload.remedy;
|
|
476
|
+
const caseRecord = verified.payload.case;
|
|
477
|
+
let status: 'executed' | 'indeterminate';
|
|
478
|
+
if (remedy.status === 'executed' && remedy.outcome === 'executed'
|
|
479
|
+
&& state.status === 'remedied' && state.active_remedy === null
|
|
480
|
+
&& state.remedied_units === leg.max_remedy_units && state.remaining_units === 0) {
|
|
481
|
+
status = 'executed';
|
|
482
|
+
} else if (remedy.status === 'indeterminate' && remedy.outcome === 'indeterminate'
|
|
483
|
+
&& state.status === 'remedy_indeterminate' && state.active_remedy !== null) {
|
|
484
|
+
status = 'indeterminate';
|
|
485
|
+
} else {
|
|
486
|
+
return fail('child_not_terminal_or_indeterminate');
|
|
487
|
+
}
|
|
488
|
+
const observation = {
|
|
489
|
+
leg_id: leg.leg_id,
|
|
490
|
+
status,
|
|
491
|
+
case_revision: state.revision,
|
|
492
|
+
receipt_content_digest: verified.content_digest,
|
|
493
|
+
state_snapshot_digest: caseRecord.state_snapshot_digest,
|
|
494
|
+
};
|
|
495
|
+
if (previous.status === 'executed' && !same(previous, observation)) {
|
|
496
|
+
return fail('completed_child_substitution');
|
|
497
|
+
}
|
|
498
|
+
if (previous.status !== 'pending') {
|
|
499
|
+
if (observation.case_revision < previous.case_revision) return fail('child_revision_regression');
|
|
500
|
+
if (observation.case_revision === previous.case_revision && !same(previous, observation)) {
|
|
501
|
+
return fail('child_revision_conflict');
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return observation;
|
|
505
|
+
} catch {
|
|
506
|
+
return fail('child_receipt_invalid');
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function recordChildren(input: unknown): Promise<RemedyCaseSetResult> {
|
|
511
|
+
let value: DataRecord;
|
|
512
|
+
try {
|
|
513
|
+
value = canonicalCopy(input) as DataRecord;
|
|
514
|
+
} catch {
|
|
515
|
+
return fail('case_set_children_input_invalid');
|
|
516
|
+
}
|
|
517
|
+
if (!exactKeys(value, RECORD_KEYS)
|
|
518
|
+
|| value.tenantId !== tenantId
|
|
519
|
+
|| !validId(value.caseSetId)
|
|
520
|
+
|| !validContext(value.ownerToken) || Buffer.byteLength(value.ownerToken, 'utf8') > 256
|
|
521
|
+
|| !Number.isSafeInteger(value.expectedRevision) || value.expectedRevision < 0
|
|
522
|
+
|| !Array.isArray(value.children)) return fail('case_set_children_input_invalid');
|
|
523
|
+
const loaded = await load(value.caseSetId);
|
|
524
|
+
if (!loaded.ok) return loaded;
|
|
525
|
+
const state = loaded.state as DataRecord;
|
|
526
|
+
const requestDigest = canonicalDigest(value);
|
|
527
|
+
if (state.last_request_digest === requestDigest) {
|
|
528
|
+
return { ok: true, idempotent: true, state: loaded.state };
|
|
529
|
+
}
|
|
530
|
+
if (state.status === 'completed') return fail('case_set_terminal');
|
|
531
|
+
if (state.owner_token_digest !== ownerDigest(value.ownerToken)) return fail('ownership_conflict');
|
|
532
|
+
if (state.revision !== value.expectedRevision) return fail('state_transition_conflict');
|
|
533
|
+
if (value.children.length !== state.manifest.legs.length) return fail('case_set_incomplete');
|
|
534
|
+
const byLeg = new Map<string, DataRecord>();
|
|
535
|
+
for (const child of value.children) {
|
|
536
|
+
if (!exactKeys(child, CHILD_INPUT_KEYS) || !validId(child.legId) || byLeg.has(child.legId)) {
|
|
537
|
+
return fail('case_set_child_set_invalid');
|
|
538
|
+
}
|
|
539
|
+
byLeg.set(child.legId, child);
|
|
540
|
+
}
|
|
541
|
+
if (state.manifest.legs.some((leg: DataRecord) => !byLeg.has(leg.leg_id))) {
|
|
542
|
+
return fail('case_set_incomplete');
|
|
543
|
+
}
|
|
544
|
+
const observations: DataRecord[] = [];
|
|
545
|
+
for (const [index, leg] of state.manifest.legs.entries()) {
|
|
546
|
+
const result = verifyChild(byLeg.get(leg.leg_id)!, leg, state.observations[index]);
|
|
547
|
+
if (result.ok === false && typeof result.reason === 'string') return result as Failure;
|
|
548
|
+
observations.push(result as DataRecord);
|
|
549
|
+
}
|
|
550
|
+
const receiptDigests = observations.map((entry) => entry.receipt_content_digest);
|
|
551
|
+
if (new Set(receiptDigests).size !== receiptDigests.length) {
|
|
552
|
+
return fail('child_evidence_replayed');
|
|
553
|
+
}
|
|
554
|
+
const at = clock();
|
|
555
|
+
if (!Number.isFinite(at)) return fail('clock_invalid');
|
|
556
|
+
if (at < instant(state.updated_at)) return fail('clock_regression');
|
|
557
|
+
const next = canonicalCopy(state);
|
|
558
|
+
next.revision += 1;
|
|
559
|
+
next.updated_at = new Date(at).toISOString();
|
|
560
|
+
next.observations = observations;
|
|
561
|
+
next.status = observations.every((entry) => entry.status === 'executed')
|
|
562
|
+
? 'completed' : 'indeterminate';
|
|
563
|
+
next.last_request_digest = requestDigest;
|
|
564
|
+
if (next.manifest_digest !== state.manifest_digest || !same(next.manifest, state.manifest)
|
|
565
|
+
|| !validState(next, tenantId, value.caseSetId)) return fail('case_set_state_invalid');
|
|
566
|
+
try {
|
|
567
|
+
const stored: any = await store.compareAndSwap({
|
|
568
|
+
tenantId,
|
|
569
|
+
caseSetId: value.caseSetId,
|
|
570
|
+
expectedRevision: state.revision,
|
|
571
|
+
ownerTokenDigest: state.owner_token_digest,
|
|
572
|
+
state: deepFreeze(canonicalCopy(next)) as RemedyCaseSetState,
|
|
573
|
+
});
|
|
574
|
+
if (!isDataRecord(stored)) return fail('store_response_invalid');
|
|
575
|
+
if (stored.ok === true) return checkedState(stored.state ?? next, value.caseSetId);
|
|
576
|
+
if (stored.reason === 'revision_conflict') return fail('state_transition_conflict');
|
|
577
|
+
if (stored.reason === 'ownership_conflict') return fail('ownership_conflict');
|
|
578
|
+
return fail('store_operation_failed');
|
|
579
|
+
} catch {
|
|
580
|
+
return fail('store_unavailable');
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function status(input: unknown): Promise<RemedyCaseSetResult> {
|
|
585
|
+
let value: DataRecord;
|
|
586
|
+
try {
|
|
587
|
+
value = canonicalCopy(input) as DataRecord;
|
|
588
|
+
} catch {
|
|
589
|
+
return fail('case_set_lookup_invalid');
|
|
590
|
+
}
|
|
591
|
+
if (!exactKeys(value, STATUS_KEYS) || value.tenantId !== tenantId || !validId(value.caseSetId)) {
|
|
592
|
+
return fail('case_set_lookup_invalid');
|
|
593
|
+
}
|
|
594
|
+
return load(value.caseSetId);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return Object.freeze({ create, recordChildren, status });
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export default Object.freeze({
|
|
601
|
+
REMEDY_CASE_SET_VERSION,
|
|
602
|
+
createRemedyCaseSetCoordinator,
|
|
603
|
+
});
|