@serve.zone/interfaces 27.3.1 → 27.5.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/.smartconfig.json +11 -4
- package/changelog.md +30 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/index.d.ts +1 -0
- package/dist_ts/data/index.js +2 -1
- package/dist_ts/data/runtimeassignment.d.ts +120 -0
- package/dist_ts/data/runtimeassignment.js +267 -0
- package/dist_ts/runtime.cloudlylegacydeploymentsettlement.offlineexport.d.ts +291 -0
- package/dist_ts/runtime.cloudlylegacydeploymentsettlement.offlineexport.golden.d.ts +30 -0
- package/dist_ts/runtime.cloudlylegacydeploymentsettlement.offlineexport.golden.js +408 -0
- package/dist_ts/runtime.cloudlylegacydeploymentsettlement.offlineexport.js +1126 -0
- package/dist_ts/runtime.corestore.databaseexportpackage.d.ts +118 -0
- package/dist_ts/runtime.corestore.databaseexportpackage.golden.d.ts +22 -0
- package/dist_ts/runtime.corestore.databaseexportpackage.golden.js +242 -0
- package/dist_ts/runtime.corestore.databaseexportpackage.js +428 -0
- package/dist_ts/runtime.d.ts +2 -0
- package/dist_ts/runtime.js +3 -1
- package/package.json +4 -4
- package/readme.md +67 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/index.ts +1 -0
- package/ts/data/runtimeassignment.ts +402 -0
- package/ts/runtime.cloudlylegacydeploymentsettlement.offlineexport.golden.ts +510 -0
- package/ts/runtime.cloudlylegacydeploymentsettlement.offlineexport.ts +2470 -0
- package/ts/runtime.corestore.databaseexportpackage.golden.ts +323 -0
- package/ts/runtime.corestore.databaseexportpackage.ts +862 -0
- package/ts/runtime.ts +2 -0
- /package/{license → license.md} +0 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import type { TSha256Digest, TImmutableContainerPlatform } from './immutableimage.js';
|
|
2
|
+
import { buildDigestPinnedImageReference, isSha256Digest } from './immutableimage.js';
|
|
3
|
+
import { canonicalizeStrictJson, createCanonicalJsonSha256Hex } from '../private/canonicaljson.js';
|
|
4
|
+
|
|
5
|
+
export interface IRuntimeControllerV1 {
|
|
6
|
+
kind: 'cloudly' | 'onebox';
|
|
7
|
+
id: string;
|
|
8
|
+
/** Persisted authority incarnation; changing it requires explicit re-admission. */
|
|
9
|
+
epoch: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Content-bound reference, not a credential, mount path or permission grant. */
|
|
13
|
+
export interface IRuntimeAuthorityReferenceV1 {
|
|
14
|
+
id: string;
|
|
15
|
+
generation: number;
|
|
16
|
+
digest: TSha256Digest;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface IRuntimeAssignmentReferenceV1 {
|
|
20
|
+
assignmentId: string;
|
|
21
|
+
generation: number;
|
|
22
|
+
digest: TSha256Digest;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Derived from authenticated transport and locally configured ownership. */
|
|
26
|
+
export interface IRuntimeAssignmentScopeV1 {
|
|
27
|
+
controller: IRuntimeControllerV1;
|
|
28
|
+
organizationId: string;
|
|
29
|
+
nodeId: string;
|
|
30
|
+
runtimeNamespace: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type TRuntimeAssignmentDispositionV1 =
|
|
34
|
+
| 'run'
|
|
35
|
+
| 'stop-preserve'
|
|
36
|
+
| 'remove-runtime-preserve-storage';
|
|
37
|
+
|
|
38
|
+
export interface IRuntimeAssignmentV1 extends IRuntimeAssignmentScopeV1 {
|
|
39
|
+
schemaVersion: 1;
|
|
40
|
+
assignmentId: string;
|
|
41
|
+
serviceId: string;
|
|
42
|
+
/** Stable logical replica; independent of container IDs and execution attempts. */
|
|
43
|
+
replicaId: string;
|
|
44
|
+
/** One execution attempt, immutable for this assignment. */
|
|
45
|
+
attemptId: string;
|
|
46
|
+
/** Logical topology provenance; placement authority remains nodeId. */
|
|
47
|
+
workerGroupId: string | null;
|
|
48
|
+
siteId: string | null;
|
|
49
|
+
generation: number;
|
|
50
|
+
/** Exact CAS predecessor; null only at generation 1. */
|
|
51
|
+
previous: IRuntimeAssignmentReferenceV1 | null;
|
|
52
|
+
disposition: TRuntimeAssignmentDispositionV1;
|
|
53
|
+
/** No automatic relocation or expiry-based claim of a stopped writer. */
|
|
54
|
+
offlinePolicy: 'continue-node-bound';
|
|
55
|
+
workload: {
|
|
56
|
+
registryHost: string;
|
|
57
|
+
repository: string;
|
|
58
|
+
imageDigest: TSha256Digest;
|
|
59
|
+
platform: TImmutableContainerPlatform;
|
|
60
|
+
/** Immutable complete invocation/configuration owned by the controller. */
|
|
61
|
+
config: IRuntimeAuthorityReferenceV1;
|
|
62
|
+
network: IRuntimeAuthorityReferenceV1 | null;
|
|
63
|
+
secrets: IRuntimeAuthorityReferenceV1 | null;
|
|
64
|
+
/** Sorted unique reference IDs. Empty means no persistent storage claims. */
|
|
65
|
+
storage: IRuntimeAuthorityReferenceV1[];
|
|
66
|
+
};
|
|
67
|
+
digest: TSha256Digest;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type TRuntimeAssignmentDecisionV1 =
|
|
71
|
+
| { status: 'accept' | 'replay'; assignment: IRuntimeAssignmentV1 }
|
|
72
|
+
| { status: 'reject'; reason: 'invalid' | 'scope-mismatch' | 'conflict' | 'stale'
|
|
73
|
+
| 'predecessor-mismatch' | 'attempt-mismatch' | 'disposition-regression' };
|
|
74
|
+
|
|
75
|
+
/** Value-free execution evidence. Receipt/acceptance alone never means ready. */
|
|
76
|
+
export interface IRuntimeAssignmentObservationV1 {
|
|
77
|
+
schemaVersion: 1;
|
|
78
|
+
assignment: IRuntimeAssignmentReferenceV1;
|
|
79
|
+
/** Authenticated reporter session; its authority is verified by the receiver. */
|
|
80
|
+
reporterSessionId: string;
|
|
81
|
+
/** Durable per-assignment counter; never resets when a reporter reconnects. */
|
|
82
|
+
sequence: number;
|
|
83
|
+
observedAt: number;
|
|
84
|
+
phase: 'pending' | 'running' | 'stopped' | 'removed' | 'failed' | 'unknown';
|
|
85
|
+
ready: boolean;
|
|
86
|
+
runtime: {
|
|
87
|
+
sandboxId: string;
|
|
88
|
+
containerId: string;
|
|
89
|
+
imageDigest: TSha256Digest;
|
|
90
|
+
} | null;
|
|
91
|
+
digest: TSha256Digest;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const runtimeAssignmentV1Contract = Object.freeze({
|
|
95
|
+
assignmentDigestDomain: 'serve.zone/runtime-assignment-v1',
|
|
96
|
+
observationDigestDomain: 'serve.zone/runtime-assignment-observation-v1',
|
|
97
|
+
maximumStorageReferences: 64,
|
|
98
|
+
omission: 'no-effect',
|
|
99
|
+
removal: 'retain-assignment-tombstone-and-storage',
|
|
100
|
+
} as const);
|
|
101
|
+
|
|
102
|
+
const fail: (messageArg: string) => never = (messageArg) => { throw new Error(messageArg); };
|
|
103
|
+
const isIdentifier = (valueArg: unknown): valueArg is string =>
|
|
104
|
+
typeof valueArg === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(valueArg);
|
|
105
|
+
const isPositiveInteger = (valueArg: unknown): valueArg is number =>
|
|
106
|
+
typeof valueArg === 'number' && Number.isSafeInteger(valueArg) && valueArg > 0;
|
|
107
|
+
const isDigest = (valueArg: unknown): valueArg is TSha256Digest =>
|
|
108
|
+
typeof valueArg === 'string' && isSha256Digest(valueArg);
|
|
109
|
+
|
|
110
|
+
/** Reject accessors before reading any fields; do not run input-owned code. */
|
|
111
|
+
const isExactRecord = (
|
|
112
|
+
valueArg: unknown,
|
|
113
|
+
keysArg: readonly string[],
|
|
114
|
+
): valueArg is Record<string, unknown> => {
|
|
115
|
+
if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) return false;
|
|
116
|
+
const prototype = Object.getPrototypeOf(valueArg);
|
|
117
|
+
if (prototype !== Object.prototype && prototype !== null) return false;
|
|
118
|
+
const keys = Reflect.ownKeys(valueArg);
|
|
119
|
+
return keys.length === keysArg.length && keys.every((keyArg) => {
|
|
120
|
+
const descriptor = Object.getOwnPropertyDescriptor(valueArg, keyArg);
|
|
121
|
+
return typeof keyArg === 'string' && keysArg.includes(keyArg)
|
|
122
|
+
&& descriptor?.enumerable === true && Object.hasOwn(descriptor, 'value');
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const isController = (valueArg: unknown): valueArg is IRuntimeControllerV1 =>
|
|
127
|
+
isExactRecord(valueArg, ['kind', 'id', 'epoch'])
|
|
128
|
+
&& (valueArg.kind === 'cloudly' || valueArg.kind === 'onebox')
|
|
129
|
+
&& isIdentifier(valueArg.id) && isIdentifier(valueArg.epoch);
|
|
130
|
+
const isAuthorityReference = (valueArg: unknown): valueArg is IRuntimeAuthorityReferenceV1 =>
|
|
131
|
+
isExactRecord(valueArg, ['id', 'generation', 'digest'])
|
|
132
|
+
&& isIdentifier(valueArg.id) && isPositiveInteger(valueArg.generation) && isDigest(valueArg.digest);
|
|
133
|
+
const isAssignmentReference = (valueArg: unknown): valueArg is IRuntimeAssignmentReferenceV1 =>
|
|
134
|
+
isExactRecord(valueArg, ['assignmentId', 'generation', 'digest'])
|
|
135
|
+
&& isIdentifier(valueArg.assignmentId) && isPositiveInteger(valueArg.generation)
|
|
136
|
+
&& isDigest(valueArg.digest);
|
|
137
|
+
|
|
138
|
+
const assignmentKeys = [
|
|
139
|
+
'schemaVersion', 'controller', 'organizationId', 'nodeId', 'runtimeNamespace',
|
|
140
|
+
'assignmentId', 'serviceId', 'replicaId', 'attemptId', 'workerGroupId', 'siteId',
|
|
141
|
+
'generation', 'previous', 'disposition', 'offlinePolicy', 'workload', 'digest',
|
|
142
|
+
] as const;
|
|
143
|
+
|
|
144
|
+
const assertAssignment = (valueArg: unknown): IRuntimeAssignmentV1 => {
|
|
145
|
+
if (!isExactRecord(valueArg, assignmentKeys)
|
|
146
|
+
|| valueArg.schemaVersion !== 1 || !isController(valueArg.controller)
|
|
147
|
+
|| !isPositiveInteger(valueArg.generation) || !isDigest(valueArg.digest)
|
|
148
|
+
|| valueArg.offlinePolicy !== 'continue-node-bound'
|
|
149
|
+
|| !['run', 'stop-preserve', 'remove-runtime-preserve-storage'].includes(
|
|
150
|
+
valueArg.disposition as string,
|
|
151
|
+
)) fail('runtime assignment must use its exact v1 schema');
|
|
152
|
+
for (const key of ['organizationId', 'nodeId', 'runtimeNamespace', 'assignmentId',
|
|
153
|
+
'serviceId', 'replicaId', 'attemptId']) {
|
|
154
|
+
if (!isIdentifier(valueArg[key])) fail(`runtime assignment ${key} must be a canonical identifier`);
|
|
155
|
+
}
|
|
156
|
+
for (const key of ['workerGroupId', 'siteId']) {
|
|
157
|
+
if (valueArg[key] !== null && !isIdentifier(valueArg[key])) fail('invalid runtime topology ID');
|
|
158
|
+
}
|
|
159
|
+
if (valueArg.generation === 1 ? valueArg.previous !== null
|
|
160
|
+
: !isAssignmentReference(valueArg.previous)
|
|
161
|
+
|| valueArg.previous.assignmentId !== valueArg.assignmentId
|
|
162
|
+
|| valueArg.previous.generation !== (valueArg.generation as number) - 1) {
|
|
163
|
+
fail('runtime assignment must bind its exact preceding generation');
|
|
164
|
+
}
|
|
165
|
+
const workload = valueArg.workload;
|
|
166
|
+
if (!isExactRecord(workload, ['registryHost', 'repository', 'imageDigest', 'platform',
|
|
167
|
+
'config', 'network', 'secrets', 'storage'])
|
|
168
|
+
|| typeof workload.registryHost !== 'string' || workload.registryHost.length > 253
|
|
169
|
+
|| typeof workload.repository !== 'string' || workload.repository.length > 255
|
|
170
|
+
|| !isDigest(workload.imageDigest)
|
|
171
|
+
|| !['linux/amd64', 'linux/arm64'].includes(workload.platform as string)
|
|
172
|
+
|| !isAuthorityReference(workload.config)
|
|
173
|
+
|| (workload.network !== null && !isAuthorityReference(workload.network))
|
|
174
|
+
|| (workload.secrets !== null && !isAuthorityReference(workload.secrets))
|
|
175
|
+
|| !Array.isArray(workload.storage)
|
|
176
|
+
|| workload.storage.length > runtimeAssignmentV1Contract.maximumStorageReferences) {
|
|
177
|
+
fail('runtime assignment workload must use exact immutable references');
|
|
178
|
+
}
|
|
179
|
+
buildDigestPinnedImageReference(workload.registryHost as string,
|
|
180
|
+
workload.repository as string, workload.imageDigest as TSha256Digest);
|
|
181
|
+
let previousId = '';
|
|
182
|
+
for (let index = 0; index < (workload.storage as unknown[]).length; index++) {
|
|
183
|
+
const descriptor = Object.getOwnPropertyDescriptor(workload.storage, String(index));
|
|
184
|
+
if (!descriptor || !Object.hasOwn(descriptor, 'value')
|
|
185
|
+
|| !isAuthorityReference(descriptor.value) || descriptor.value.id <= previousId) {
|
|
186
|
+
fail('runtime assignment storage references must be dense, sorted and unique');
|
|
187
|
+
}
|
|
188
|
+
previousId = descriptor.value.id;
|
|
189
|
+
}
|
|
190
|
+
canonicalizeStrictJson(valueArg, fail);
|
|
191
|
+
return valueArg as unknown as IRuntimeAssignmentV1;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const digestPayload = async (domainArg: string, valueArg: { digest: TSha256Digest }): Promise<TSha256Digest> => {
|
|
195
|
+
const { digest: _digest, ...payload } = valueArg;
|
|
196
|
+
return `sha256:${await createCanonicalJsonSha256Hex(
|
|
197
|
+
canonicalizeStrictJson({ domain: domainArg, payload }, fail), fail,
|
|
198
|
+
)}`;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/** Digest helpers validate structure and exclude only the object's own digest. */
|
|
202
|
+
export const computeRuntimeAssignmentV1Digest = async (
|
|
203
|
+
assignmentArg: IRuntimeAssignmentV1,
|
|
204
|
+
): Promise<TSha256Digest> => digestPayload(
|
|
205
|
+
runtimeAssignmentV1Contract.assignmentDigestDomain, assertAssignment(assignmentArg),
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
export const validateRuntimeAssignmentV1 = async (valueArg: unknown): Promise<string[]> => {
|
|
209
|
+
try {
|
|
210
|
+
const assignment = snapshotAssignment(valueArg);
|
|
211
|
+
return assignment.digest === await computeRuntimeAssignmentV1Digest(assignment)
|
|
212
|
+
? [] : ['runtime assignment digest does not match'];
|
|
213
|
+
} catch {
|
|
214
|
+
return ['runtime assignment must use its exact canonical v1 schema'];
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const snapshotAssignment = (valueArg: unknown): IRuntimeAssignmentV1 =>
|
|
219
|
+
JSON.parse(canonicalizeStrictJson(assertAssignment(valueArg), fail)) as IRuntimeAssignmentV1;
|
|
220
|
+
|
|
221
|
+
export const getRuntimeAssignmentV1Reference = (
|
|
222
|
+
assignmentArg: IRuntimeAssignmentV1,
|
|
223
|
+
): IRuntimeAssignmentReferenceV1 => ({
|
|
224
|
+
assignmentId: assignmentArg.assignmentId,
|
|
225
|
+
generation: assignmentArg.generation,
|
|
226
|
+
digest: assignmentArg.digest,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const canonical = (valueArg: unknown): string => canonicalizeStrictJson(valueArg, fail);
|
|
230
|
+
const immutableAttempt = (assignmentArg: IRuntimeAssignmentV1): string => {
|
|
231
|
+
const { generation, previous, disposition, digest, ...attempt } = assignmentArg;
|
|
232
|
+
return canonical(attempt);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Pure CAS decision, not authentication or execution. The caller must persist
|
|
237
|
+
* acceptance atomically before effects, serialize replica ownership, and retain
|
|
238
|
+
* removed assignments as tombstones. New attempts require separate stop/fencing
|
|
239
|
+
* admission for every older writer. An omitted assignment causes no transition.
|
|
240
|
+
* Persist only the returned detached snapshot, never the mutable input object.
|
|
241
|
+
*/
|
|
242
|
+
export const evaluateRuntimeAssignmentV1 = async (
|
|
243
|
+
incomingArg: IRuntimeAssignmentV1,
|
|
244
|
+
acceptedArg: IRuntimeAssignmentV1 | null,
|
|
245
|
+
trustedScopeArg: IRuntimeAssignmentScopeV1,
|
|
246
|
+
): Promise<TRuntimeAssignmentDecisionV1> => {
|
|
247
|
+
try {
|
|
248
|
+
incomingArg = snapshotAssignment(incomingArg);
|
|
249
|
+
acceptedArg = acceptedArg === null ? null : snapshotAssignment(acceptedArg);
|
|
250
|
+
if (!isExactRecord(trustedScopeArg, ['controller', 'organizationId', 'nodeId', 'runtimeNamespace'])
|
|
251
|
+
|| !isController(trustedScopeArg.controller)
|
|
252
|
+
|| !isIdentifier(trustedScopeArg.organizationId) || !isIdentifier(trustedScopeArg.nodeId)
|
|
253
|
+
|| !isIdentifier(trustedScopeArg.runtimeNamespace)) return { status: 'reject', reason: 'invalid' };
|
|
254
|
+
trustedScopeArg = JSON.parse(canonical(trustedScopeArg)) as IRuntimeAssignmentScopeV1;
|
|
255
|
+
} catch {
|
|
256
|
+
return { status: 'reject', reason: 'invalid' };
|
|
257
|
+
}
|
|
258
|
+
if ((await validateRuntimeAssignmentV1(incomingArg)).length
|
|
259
|
+
|| (acceptedArg !== null && (await validateRuntimeAssignmentV1(acceptedArg)).length)) {
|
|
260
|
+
return { status: 'reject', reason: 'invalid' };
|
|
261
|
+
}
|
|
262
|
+
const matchesScope = (assignmentArg: IRuntimeAssignmentV1): boolean =>
|
|
263
|
+
assignmentArg.controller.kind === trustedScopeArg.controller.kind
|
|
264
|
+
&& assignmentArg.controller.id === trustedScopeArg.controller.id
|
|
265
|
+
&& assignmentArg.controller.epoch === trustedScopeArg.controller.epoch
|
|
266
|
+
&& assignmentArg.organizationId === trustedScopeArg.organizationId
|
|
267
|
+
&& assignmentArg.nodeId === trustedScopeArg.nodeId
|
|
268
|
+
&& assignmentArg.runtimeNamespace === trustedScopeArg.runtimeNamespace;
|
|
269
|
+
if (!matchesScope(incomingArg) || (acceptedArg !== null && !matchesScope(acceptedArg))) {
|
|
270
|
+
return { status: 'reject', reason: 'scope-mismatch' };
|
|
271
|
+
}
|
|
272
|
+
if (acceptedArg === null) {
|
|
273
|
+
return incomingArg.generation === 1
|
|
274
|
+
&& incomingArg.disposition === 'run'
|
|
275
|
+
? { status: 'accept', assignment: incomingArg }
|
|
276
|
+
: { status: 'reject', reason: 'predecessor-mismatch' };
|
|
277
|
+
}
|
|
278
|
+
if (incomingArg.assignmentId !== acceptedArg.assignmentId) {
|
|
279
|
+
return { status: 'reject', reason: 'attempt-mismatch' };
|
|
280
|
+
}
|
|
281
|
+
if (incomingArg.generation < acceptedArg.generation) return { status: 'reject', reason: 'stale' };
|
|
282
|
+
if (incomingArg.generation === acceptedArg.generation) {
|
|
283
|
+
return incomingArg.digest === acceptedArg.digest
|
|
284
|
+
? { status: 'replay', assignment: acceptedArg } : { status: 'reject', reason: 'conflict' };
|
|
285
|
+
}
|
|
286
|
+
if (canonical(incomingArg.previous) !== canonical(getRuntimeAssignmentV1Reference(acceptedArg))) {
|
|
287
|
+
return { status: 'reject', reason: 'predecessor-mismatch' };
|
|
288
|
+
}
|
|
289
|
+
if (immutableAttempt(incomingArg) !== immutableAttempt(acceptedArg)) {
|
|
290
|
+
return { status: 'reject', reason: 'attempt-mismatch' };
|
|
291
|
+
}
|
|
292
|
+
const order: Record<TRuntimeAssignmentDispositionV1, number> = {
|
|
293
|
+
run: 0, 'stop-preserve': 1, 'remove-runtime-preserve-storage': 2,
|
|
294
|
+
};
|
|
295
|
+
if (order[incomingArg.disposition] !== order[acceptedArg.disposition] + 1) {
|
|
296
|
+
return { status: 'reject', reason: 'disposition-regression' };
|
|
297
|
+
}
|
|
298
|
+
return { status: 'accept', assignment: incomingArg };
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const assertObservation = (valueArg: unknown): IRuntimeAssignmentObservationV1 => {
|
|
302
|
+
if (!isExactRecord(valueArg, ['schemaVersion', 'assignment', 'reporterSessionId',
|
|
303
|
+
'sequence', 'observedAt', 'phase', 'ready', 'runtime', 'digest'])
|
|
304
|
+
|| valueArg.schemaVersion !== 1 || !isAssignmentReference(valueArg.assignment)
|
|
305
|
+
|| !isIdentifier(valueArg.reporterSessionId) || !isPositiveInteger(valueArg.sequence)
|
|
306
|
+
|| !isPositiveInteger(valueArg.observedAt) || !isDigest(valueArg.digest)
|
|
307
|
+
|| typeof valueArg.ready !== 'boolean'
|
|
308
|
+
|| !['pending', 'running', 'stopped', 'removed', 'failed', 'unknown'].includes(valueArg.phase as string)) {
|
|
309
|
+
fail('runtime observation must use its exact v1 schema');
|
|
310
|
+
}
|
|
311
|
+
if (valueArg.runtime !== null && (!isExactRecord(valueArg.runtime, [
|
|
312
|
+
'sandboxId', 'containerId', 'imageDigest',
|
|
313
|
+
]) || !isIdentifier(valueArg.runtime.sandboxId) || !isIdentifier(valueArg.runtime.containerId)
|
|
314
|
+
|| !isDigest(valueArg.runtime.imageDigest))) fail('runtime identity must be canonical');
|
|
315
|
+
if ((valueArg.ready && valueArg.phase !== 'running')
|
|
316
|
+
|| (valueArg.phase === 'running' && valueArg.runtime === null)
|
|
317
|
+
|| (valueArg.phase === 'removed' && valueArg.runtime !== null)) {
|
|
318
|
+
fail('runtime observation readiness and identity contradict its phase');
|
|
319
|
+
}
|
|
320
|
+
canonical(valueArg);
|
|
321
|
+
return valueArg as unknown as IRuntimeAssignmentObservationV1;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
export const computeRuntimeAssignmentObservationV1Digest = async (
|
|
325
|
+
observationArg: IRuntimeAssignmentObservationV1,
|
|
326
|
+
): Promise<TSha256Digest> => digestPayload(
|
|
327
|
+
runtimeAssignmentV1Contract.observationDigestDomain, assertObservation(observationArg),
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
/** Checks exact assignment evidence; transport must separately bind reporter/session. */
|
|
331
|
+
export const validateRuntimeAssignmentObservationV1 = async (
|
|
332
|
+
observationArg: unknown,
|
|
333
|
+
assignmentArg: IRuntimeAssignmentV1,
|
|
334
|
+
): Promise<string[]> => {
|
|
335
|
+
try {
|
|
336
|
+
const observation = JSON.parse(canonical(assertObservation(observationArg))) as IRuntimeAssignmentObservationV1;
|
|
337
|
+
assignmentArg = snapshotAssignment(assignmentArg);
|
|
338
|
+
if ((await validateRuntimeAssignmentV1(assignmentArg)).length) return ['invalid assignment'];
|
|
339
|
+
if (canonical(observation.assignment) !== canonical(getRuntimeAssignmentV1Reference(assignmentArg))) {
|
|
340
|
+
return ['runtime observation does not match the exact assignment'];
|
|
341
|
+
}
|
|
342
|
+
if (observation.digest !== await computeRuntimeAssignmentObservationV1Digest(observation)) {
|
|
343
|
+
return ['runtime observation digest does not match'];
|
|
344
|
+
}
|
|
345
|
+
if (observation.ready && (assignmentArg.disposition !== 'run'
|
|
346
|
+
|| observation.runtime?.imageDigest !== assignmentArg.workload.imageDigest)) {
|
|
347
|
+
return ['runtime readiness does not match authorized execution'];
|
|
348
|
+
}
|
|
349
|
+
return [];
|
|
350
|
+
} catch {
|
|
351
|
+
return ['runtime observation must use its exact canonical v1 schema'];
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
export type TRuntimeObservationDecisionV1 =
|
|
356
|
+
| { status: 'accept' | 'replay'; observation: IRuntimeAssignmentObservationV1 }
|
|
357
|
+
| { status: 'reject'; reason: 'invalid' | 'session-mismatch' | 'stale' | 'conflict' | 'sequence-gap' };
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Receiver must authorize the assignment's node before calling this helper.
|
|
361
|
+
* Compare/persist the returned snapshot atomically with the old receipt. A new
|
|
362
|
+
* session continues the durable counter; it does not reset observation history.
|
|
363
|
+
* Across dispositions, the old receipt must reference the exact predecessor;
|
|
364
|
+
* receivers must process each revision's observations in order.
|
|
365
|
+
* These observations are never sufficient authority to relocate a storage writer.
|
|
366
|
+
*/
|
|
367
|
+
export const evaluateRuntimeAssignmentObservationV1 = async (
|
|
368
|
+
incomingArg: IRuntimeAssignmentObservationV1,
|
|
369
|
+
acceptedArg: IRuntimeAssignmentObservationV1 | null,
|
|
370
|
+
assignmentArg: IRuntimeAssignmentV1,
|
|
371
|
+
trustedReporterSessionIdArg: string,
|
|
372
|
+
): Promise<TRuntimeObservationDecisionV1> => {
|
|
373
|
+
try {
|
|
374
|
+
incomingArg = JSON.parse(canonical(assertObservation(incomingArg))) as IRuntimeAssignmentObservationV1;
|
|
375
|
+
acceptedArg = acceptedArg === null ? null
|
|
376
|
+
: JSON.parse(canonical(assertObservation(acceptedArg))) as IRuntimeAssignmentObservationV1;
|
|
377
|
+
assignmentArg = snapshotAssignment(assignmentArg);
|
|
378
|
+
if (!isIdentifier(trustedReporterSessionIdArg)
|
|
379
|
+
|| (await validateRuntimeAssignmentObservationV1(incomingArg, assignmentArg)).length
|
|
380
|
+
|| (acceptedArg !== null && (
|
|
381
|
+
acceptedArg.digest !== await computeRuntimeAssignmentObservationV1Digest(acceptedArg)
|
|
382
|
+
|| acceptedArg.assignment.assignmentId !== assignmentArg.assignmentId
|
|
383
|
+
|| (canonical(acceptedArg.assignment) !== canonical(getRuntimeAssignmentV1Reference(assignmentArg))
|
|
384
|
+
&& canonical(acceptedArg.assignment) !== canonical(assignmentArg.previous))
|
|
385
|
+
))) return { status: 'reject', reason: 'invalid' };
|
|
386
|
+
if (incomingArg.reporterSessionId !== trustedReporterSessionIdArg) {
|
|
387
|
+
return { status: 'reject', reason: 'session-mismatch' };
|
|
388
|
+
}
|
|
389
|
+
if (acceptedArg !== null && incomingArg.sequence <= acceptedArg.sequence) {
|
|
390
|
+
if (incomingArg.sequence < acceptedArg.sequence) return { status: 'reject', reason: 'stale' };
|
|
391
|
+
return incomingArg.digest === acceptedArg.digest
|
|
392
|
+
? { status: 'replay', observation: acceptedArg }
|
|
393
|
+
: { status: 'reject', reason: 'conflict' };
|
|
394
|
+
}
|
|
395
|
+
if (incomingArg.sequence !== (acceptedArg?.sequence ?? 0) + 1) {
|
|
396
|
+
return { status: 'reject', reason: 'sequence-gap' };
|
|
397
|
+
}
|
|
398
|
+
return { status: 'accept', observation: incomingArg };
|
|
399
|
+
} catch {
|
|
400
|
+
return { status: 'reject', reason: 'invalid' };
|
|
401
|
+
}
|
|
402
|
+
};
|