@serve.zone/interfaces 32.16.0 → 32.18.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/readme.md CHANGED
@@ -1198,6 +1198,10 @@ source is identified by SHA-256 and may be `retained-inert` or `removed`; deleti
1198
1198
  erasure are not cutover requirements. Its exact owner generation is retained with that disposition.
1199
1199
  Retained recovery evidence is valid only while the released
1200
1200
  owner cannot consume it into live mode and the old credential remains revoked.
1201
+ `relay.credential`, `relay.acceptedRegistration`, `legacy.credential` and `legacy.invalidation`
1202
+ reference the records of [Cluster Relay Credential](#cluster-relay-credential). The old-mode
1203
+ refusal, the configuration CAS and the Spark bundle and unit identity are Spark-owned and come
1204
+ from [Spark Legacy Runtime Retirement](#spark-legacy-runtime-retirement).
1201
1205
 
1202
1206
  Before any post-pallet assignment exists, an administrator calls
1203
1207
  `requests.fleetcutover.prepareFleetCutoverIngress` with the exact server-created service ID,
@@ -1462,6 +1466,11 @@ untouched.
1462
1466
 
1463
1467
  Versions are `MAJOR.MINOR.PATCH` and nothing else — no prerelease, no build metadata, no `v`
1464
1468
  prefix, no leading zeros — and are compared per numeric component, so `32.10.0` is above `32.9.0`.
1469
+ That grammar is public as `data.IReleaseVersion`, `data.parseReleaseVersion`,
1470
+ `data.isReleaseVersion`, `data.compareReleaseVersions` and `data.maximumReleaseVersionLength`: any
1471
+ policy that has to order two released versions — a minimum bundle a server believes, a supported
1472
+ floor — reads them here instead of splitting strings of its own, which is how a comparison ends up
1473
+ saying `32.9.0` is above `32.10.0`.
1465
1474
  `minimumPeerVersion` is in the same major as `interfacesVersion` and never above it. Each
1466
1475
  deployable owns its minimums, one per session kind it serves, and raises one only in the commit
1467
1476
  that starts depending on a later interfaces minor, for that session kind alone.
@@ -1553,7 +1562,12 @@ Coreflow connection:
1553
1562
  exact next sequence with a strictly advancing `observedAt`, while an exact
1554
1563
  same-sequence/same-digest retry returns the byte-equivalent persisted receipt
1555
1564
  before age checks. It must reject conflicting, stale, skipped,
1556
- retired-session, old, future, or non-advancing reports. A reused session ID
1565
+ retired-session, old, future, or non-advancing reports. `evidence-refused` is the
1566
+ one rejection about content rather than sequencing: it names an evidence member
1567
+ this server refuses for this body and every resend of it, so the node must change
1568
+ the body instead of retrying it, while a server-side condition a later attempt
1569
+ could pass stays `acceptance-race` — telling those two apart while it accepts
1570
+ evidence is the consumer's own work. A reused session ID
1557
1571
  may be treated as new only after it leaves the bounded retired window, and its
1558
1572
  sequence-one observation must still advance the permanent timestamp
1559
1573
  high-water mark. The consumer must persist acceptance state and its receipt
@@ -2027,12 +2041,13 @@ with `clusterRelayCertificateDomainName(relay)`, which returns the origin's host
2027
2041
  Cloudly issues the certificate for and owns the A record for. An origin naming an address literal
2028
2042
  therefore has no issuable certificate.
2029
2043
 
2030
- `data.clusterRelayUndispatchedRefusals` names the three registration refusals that end a relay's
2031
- custody — `relay-cluster-not-enabled`, `relay-node-foreign` and `relay-sequence-not-advancing` —
2032
- with `data.TClusterRelayUndispatchedRefusal` as their union. Each is Cloudly reading its own
2033
- records and stating that it does not dispatch to this relay, so a relay that hears one drops what
2034
- it holds for its nodes instead of waiting. A refusal that only says Cloudly could not verify the
2035
- relay states nothing about dispatch and is deliberately not in the list.
2044
+ `data.clusterRelayUndispatchedRefusals` names the four registration refusals that end a relay's
2045
+ custody — `relay-cluster-not-enabled`, `relay-node-foreign`, `relay-sequence-not-advancing` and
2046
+ `relay-credential-generation-stale` — with `data.TClusterRelayUndispatchedRefusal` as their union.
2047
+ Each is Cloudly reading its own records and stating that it does not dispatch to this relay, so a
2048
+ relay that hears one drops what it holds for its nodes instead of waiting. A refusal that only
2049
+ says Cloudly could not verify the relay states nothing about dispatch and is deliberately not in
2050
+ the list.
2036
2051
 
2037
2052
  ```typescript
2038
2053
  const relay: data.IClusterRelay = {
@@ -2109,6 +2124,162 @@ const registrationReasons = data.validateRegisterClusterRelayRequest(registratio
2109
2124
  How the relay reaches each node's Corestore is part of the same document: see
2110
2125
  [Corestore Through The Relay](#corestore-through-the-relay).
2111
2126
 
2127
+ ### Cluster Relay Credential
2128
+
2129
+ A cluster's relay authenticates with its own generation-bearing credential instead of the shared
2130
+ cluster authority every cluster-side component used before it. `IClusterRelayCredential` is public
2131
+ metadata: the cluster, a `generation` that advances by one per accepted mint or rotation, the
2132
+ machine `userId` it authenticates as, and `delivery`, the `ISecretVersionReference` of the
2133
+ immutable encrypted version the bearer was delivered as. No record, receipt, request or response
2134
+ of this contract carries a bearer, a hash or key material, and every reader is exact-keyed, so a
2135
+ peer that adds a secret-looking member is refused rather than copied.
2136
+
2137
+ The credential authenticates the cluster's whole control-plane session, exactly what the shared
2138
+ authority authenticated, so a relay changes one credential and keeps every call it makes. The gain
2139
+ is a generation Cloudly fences each socket against and an authority it can rotate and retire on
2140
+ its own; the scope is not narrower. One cluster has one relay and therefore one credential record,
2141
+ and it never expires by itself: rotation is its lifecycle.
2142
+
2143
+ `mutateClusterRelayCredential { identity, mutation }` takes `TClusterRelayCredentialMutation`.
2144
+ `ensure` requires `expectedGeneration: null` and an absent record; `rotate` requires the exact
2145
+ current generation, never a minimum. The caller retains the complete request and its `mutationId`
2146
+ across retries: the identical body answers with the original result and `replayed: true` without
2147
+ renewing delivery, generation or authority, and a changed body fails closed. The first mint also
2148
+ records the shared authority the cluster still carries as `IClusterLegacyRelayCredential`, so a
2149
+ replacement never exists without a named predecessor. Rotation has no overlap: the previous
2150
+ generation stops authenticating at commit, and a relay still holding it hears
2151
+ `relay-credential-generation-stale`, drops what it holds for its nodes, reads the delivered version
2152
+ and reconnects. There is no revocation of the relay credential, because a rotation already covers
2153
+ a compromised one without a state in which the cluster has no relay authority at all.
2154
+
2155
+ An accepted `registerClusterRelay` answers with two references beside the origin and the offer:
2156
+ `credential`, the generation Cloudly authenticated the socket as, and `acceptedRegistration`, the
2157
+ record that acceptance wrote. The relay states no generation of its own — the registration request
2158
+ is unchanged — because a relay naming its own generation would be asserting its own rank.
2159
+ `IClusterRelayAcceptedRegistration` is that record: the registration facts the served read shape
2160
+ also carries, its own `id`, a `generation` that advances once per acceptance, the `credential`
2161
+ reference and a `digest`. Liveness is not part of it. `IClusterRelayRegistration` keeps exactly its
2162
+ published members, the facts plus `live`.
2163
+
2164
+ `retireLegacyClusterRelayCredential { identity, retirement }` revokes the shared authority, and is
2165
+ the last step of replacing it. `IClusterLegacyRelayCredentialRetirementRequest` carries
2166
+ compare-and-swap inputs only: the exact `expectedLegacyGeneration`, the `replacement` credential
2167
+ reference and the `acceptedRegistration` reference the caller observed. Cloudly dereferences both
2168
+ against its own current records, requires the registration to have been made under that
2169
+ replacement, and requires a verified live socket carrying it; a caller can state no liveness or
2170
+ proof of its own. Acceptance writes the one-shot `IClusterLegacyRelayCredentialInvalidation`
2171
+ (`generation: 1`) and advances the legacy record to `state: 'revoked'` naming it. The invalidation
2172
+ names the legacy record at the generation before the revocation and the record names the
2173
+ invalidation after it, so neither digest covers the other's.
2174
+ `data.clusterRelayCredentialRefusals` names every other answer, with
2175
+ `data.TClusterRelayCredentialRefusal` as their union. `getClusterRelayCredential { identity,
2176
+ clusterId }` reads the chain: the credential, the legacy record and the current accepted
2177
+ registration, each `null` while no record exists.
2178
+
2179
+ Each digest-bearing record has a `snapshot…`, `compute…Digest` and `validate…` function under its
2180
+ own digest domain in `data.clusterRelayCredentialContract` and `data.clusterRelayContract`.
2181
+ `bindClusterLegacyRelayCredentialInvalidation(invalidation, legacyCredential,
2182
+ acceptedRegistration)` joins an invalidation to the legacy record it revoked — the active record
2183
+ it names or that record's revoked successor — and to a registration whose `credential` equals the
2184
+ invalidation's `replacement`. It is a content check; dereferencing each record as current and
2185
+ requiring the live socket remain Cloudly's.
2186
+
2187
+ All three requests read only the verified `IIdentityCredential`, never an identity member a caller
2188
+ states about itself.
2189
+
2190
+ ### Spark Legacy Runtime Retirement
2191
+
2192
+ The Spark-owned half of `IFleetCutoverCoreflowRetirementEvidence` — the refused old mode, the
2193
+ configuration compare-and-swap, and the bundle and unit identity — is transported on the Swarm
2194
+ observation a node already posts. There is no route, request or endpoint for it. That is forced
2195
+ rather than preferred: the evidence requires `relay.nodeObservation` and `spark.observation` to be
2196
+ the identical accepted reference, so these facts must reach Cloudly on the very observation that
2197
+ carries the node's Docker census.
2198
+
2199
+ `data.ISparkLegacyRuntimeRetirement` is the optional observation member, available in every
2200
+ observation state because it describes the host and not its Docker daemon. Accepting it is
2201
+ state-independent; completing the evidence is not. `IFleetCutoverCoreflowRetirementEvidence`
2202
+ completes only from a `member` observation of the relay host, because `relay.nodeObservation` and
2203
+ `spark.observation` must be the identical accepted reference and that reference has to carry the
2204
+ relay host's local Docker snapshot, which no other observation state can state. It is
2205
+ `{ refusedMode, bundle, configuration }` and nothing else. It names no node: Cloudly binds it to
2206
+ the reporter it already authenticated, so there is no node identity on it to disagree with. It
2207
+ carries no digest of its own either — `computeSparkSwarmObservationDigest` canonicalizes the whole
2208
+ observation, so a second domain here would only be a second thing to keep in step.
2209
+
2210
+ `refusedMode` is one of `data.sparkLegacyRuntimeModes`, the two runtimes a released Spark could be
2211
+ put into by its `mode` configuration key. `data.sparkLegacyRuntimeModeServices` is the record over
2212
+ that union naming the Docker services each mode owns, so a refusal states a mode and a reader
2213
+ derives the set. `data.ISparkNodeBundleIdentity { version, sourceCommit, manifestSha256, unitName,
2214
+ unitDigest }` is read back from the installed tree and the installed unit file, never from the
2215
+ running process's own build constants: a build can state its own version, it cannot state that the
2216
+ host will start that same code again. `data.ISparkLegacyConfigurationState { ownerGeneration,
2217
+ sourceSha256, disposition }` names the released key-value source by digest alone. No decoded member
2218
+ of that file is representable in any shape here, because it carries the node's released bearer and
2219
+ its jump code; `sourceSha256` covers the whole file, whose smallest unguessable part is a 256-bit
2220
+ token. `disposition` is `retained-inert` or `removed` — the two of
2221
+ `data.sparkLegacyConfigurationDispositions`, with `data.TSparkLegacyConfigurationDisposition` as
2222
+ their union; erasure is not a cutover requirement.
2223
+
2224
+ Cloudly accepts an observation first and then advances two of its own records, which is what the
2225
+ evidence's two references point at. `data.ISparkLegacyModeRefusal` and
2226
+ `data.ISparkLegacyConfigurationRetirement` are revisions, not one-shot receipts: a node restates
2227
+ both on every observation, a bundle upgrade changes what the boot path is, and a disposition
2228
+ changes once. Both carry the accepted observation they were derived from, its reporter session and
2229
+ its sequence, and both obey the revision rule every record of this package obeys — a first
2230
+ generation has no predecessor and a later one names exactly the generation before it. The
2231
+ configuration record carries two counters on purpose: its own `generation` is contiguous because it
2232
+ is this record's revision, while `configuration.ownerGeneration` is the node's own and may skip,
2233
+ since an observation that never arrived must not look like a skipped revision.
2234
+
2235
+ `advancesSparkLegacyConfigurationState(current, next)` is the compare-and-swap rule, `null` for a
2236
+ node's first accepted state: the same `sourceSha256`, a strictly higher `ownerGeneration`, and no
2237
+ return from `removed` to `retained-inert`. Restating the accepted generation is a replay and
2238
+ therefore not an advance, which is what keeps `acceptedAt` and the named observation from aging
2239
+ forward on a node that has stopped changing. `data.sparkLegacyRetirementRefusals` names every other
2240
+ answer, with `data.TSparkLegacyRetirementRefusal` as their union.
2241
+ `bindSparkLegacyRetirement(modeRefusal, configurationRetirement)` joins the two receipts a
2242
+ retirement cites, requiring the same cluster, node, reporter session, sequence and accepted
2243
+ observation. It is a content check; whether each receipt is its node's current revision remains
2244
+ Cloudly's.
2245
+
2246
+ The answer travels on the observation's own accepted receipt, as
2247
+ `ISparkSwarmObservationAcceptedReceipt.fleetCutoverLegacyRetirement`, a
2248
+ `data.TSparkLegacyRetirementOutcome`: `{ state: 'accepted', generation }` for the revision this
2249
+ observation advanced both records to, `{ state: 'restated', generation }` for the replay that
2250
+ renewed nothing, and `{ state: 'refused', refusal, retryable: false }` naming one of the refusals
2251
+ above. A refused statement does not reject the observation. The statement is judged against
2252
+ records rather than against the message — a malformed one is already refused by
2253
+ `validateSparkLegacyRuntimeRetirement` — so everything left is a fact about this node's history,
2254
+ while the observation's liveness and census are true either way and the cluster's target set
2255
+ depends on their freshness. A refusal is never retryable: it answers one exact statement, so
2256
+ resending it cannot change the answer, and a genuine server-side race is the observation's own
2257
+ `acceptance-race` one level up. The flag is carried anyway, so a node meeting a refusal name a
2258
+ later minor added still learns it must stop.
2259
+ `validateSparkSwarmObservationResponse` binds the answer to the statement in both directions: an
2260
+ accepted receipt that answers a statement the observation never made, or leaves one it did make
2261
+ unanswered, is refused. A rejected receipt carries no outcome at all, because an observation that
2262
+ was not accepted never reached the statement.
2263
+ `data.snapshotSparkLegacyRetirementOutcome` and `data.validateSparkLegacyRetirementOutcome` read
2264
+ it exact-key per state, like every other shape here.
2265
+
2266
+ An omitted member is not a refusal and advances nothing, so the existence of these records is not
2267
+ what makes a retirement honest — the freshness of the observation they name is. Assembly must
2268
+ require that observation to still be inside `fleetCutoverContract.observationFreshnessMs`, exactly
2269
+ as `IFleetCutoverSparkObservationReference` already does, or a node that stopped refusing would go
2270
+ on looking retired.
2271
+
2272
+ Cloudly reads this member before Spark writes it. Every observation validator in this package is
2273
+ exact-key, so a server built against an earlier minor refuses the whole observation rather than
2274
+ ignoring a member it does not know — the peers of a session kind are upgraded before the side that
2275
+ starts depending on the later minor, and the `minimumPeerVersion` of that session kind is raised in
2276
+ the same commit that starts depending on it. A Spark that begins stating this member therefore
2277
+ raises its `sparkSwarmNode` minimum alone, which requires one minimum per session kind rather than
2278
+ one shared across a deployable's offers. The duty runs both ways: a receipt is read exact-key too,
2279
+ so a Cloudly that begins answering with `fleetCutoverLegacyRetirement` raises its own
2280
+ `sparkSwarmNode` minimum in the same commit, or an older Spark refuses the receipt for a member it
2281
+ does not know.
2282
+
2112
2283
  ### Cluster VPN Hub And Network
2113
2284
 
2114
2285
  A cluster's relay is also that cluster's managed VPN hub: every node of the cluster dials it, and
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/interfaces',
6
- version: '32.16.0',
6
+ version: '32.18.0',
7
7
  description: 'Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.'
8
8
  }
@@ -2,11 +2,23 @@ import * as plugins from '../plugins.js';
2
2
 
3
3
  import { type IDockerRegistryInfo } from '../data/docker.js';
4
4
  import type { IClusterNode } from './clusternode.js';
5
+ import type { IFleetCutoverAcceptedEvidenceReference } from './fleetcutover.js';
6
+ import type { TSha256Digest } from './immutableimage.js';
7
+ import type { IRuntimeAuthorityReference } from './runtimeassignment.js';
5
8
  import { isPlausibleUnicastIpv4 } from '../private/ipv4.js';
6
9
  import { validateProtocolOfferOn, type IProtocolOffer } from '../protocol.js';
7
10
  import { hasExactKeys } from '../private/dataproperties.js';
8
11
  import { isClusterNodeId } from '../private/nodeidentifier.js';
9
- import { networkNoiseKey } from '../private/networkcontract.js';
12
+ import {
13
+ captureNetwork,
14
+ networkCounter,
15
+ networkDigest,
16
+ networkHash,
17
+ networkId,
18
+ networkNoiseKey,
19
+ networkRecord,
20
+ networkReference,
21
+ } from '../private/networkcontract.js';
10
22
 
11
23
  export interface ICluster {
12
24
  id: string;
@@ -137,6 +149,16 @@ export interface IRegisterClusterRelayResponse {
137
149
  origin: string;
138
150
  /** Cloudly's own offer, so an accepted relay judges the session it just registered into. */
139
151
  protocol: IProtocolOffer;
152
+ /**
153
+ * The relay credential generation Cloudly authenticated this socket as.
154
+ *
155
+ * Read from Cloudly's own record and never from the registration: a relay that stated its own
156
+ * generation would be asserting its own rank. It is how a relay learns the exact generation its
157
+ * readiness is bound to.
158
+ */
159
+ credential: IRuntimeAuthorityReference;
160
+ /** The accepted registration record this answer wrote, as a retirement record later cites it. */
161
+ acceptedRegistration: IFleetCutoverAcceptedEvidenceReference;
140
162
  }
141
163
 
142
164
  /**
@@ -155,6 +177,13 @@ export const clusterRelayUndispatchedRefusals = Object.freeze([
155
177
  'relay-node-foreign',
156
178
  /** A registration of this relay identity at the same or a higher sequence is already recorded. */
157
179
  'relay-sequence-not-advancing',
180
+ /**
181
+ * The credential this socket authenticated with is no longer the cluster's current generation.
182
+ *
183
+ * Rotation has no overlap, so another relay may already hold the current one: this relay is not
184
+ * the one Cloudly dispatches to, which `relay-identity-unverified` never states.
185
+ */
186
+ 'relay-credential-generation-stale',
158
187
  ] as const);
159
188
 
160
189
  /** One of the reasons Cloudly states that it does not dispatch to a relay. */
@@ -342,14 +371,12 @@ export const validateRegisterClusterRelayRequest = (
342
371
  };
343
372
 
344
373
  /**
345
- * One cluster's relay as Cloudly serves it to an administrator.
374
+ * What one accepted relay registration states about the relay that sent it.
346
375
  *
347
- * This is the read shape, not the registration a relay sends: `IRegisterClusterRelayRequest` is
348
- * what the relay puts on the wire, and this is what Cloudly answers with after it accepted one.
349
- * `clusterId` names the cluster the relay serves, and `acceptedAt` is Cloudly's own acceptance
350
- * time, which is what orders two registrations of one cluster.
376
+ * The part the served read shape and the accepted record share: no record identity, no digest and
377
+ * no liveness, so neither shape restates the other's fields.
351
378
  */
352
- export interface IClusterRelayRegistration {
379
+ export interface IClusterRelayRegistrationFacts {
353
380
  clusterId: string;
354
381
  nodeId: string;
355
382
  listenAddress: string;
@@ -357,6 +384,17 @@ export interface IClusterRelayRegistration {
357
384
  version: string;
358
385
  registrationSequence: number;
359
386
  acceptedAt: number;
387
+ }
388
+
389
+ /**
390
+ * One cluster's relay as Cloudly serves it to an administrator.
391
+ *
392
+ * This is the read shape, not the registration a relay sends: `IRegisterClusterRelayRequest` is
393
+ * what the relay puts on the wire, and this is what Cloudly answers with after it accepted one.
394
+ * `clusterId` names the cluster the relay serves, and `acceptedAt` is Cloudly's own acceptance
395
+ * time, which is what orders two registrations of one cluster.
396
+ */
397
+ export interface IClusterRelayRegistration extends IClusterRelayRegistrationFacts {
360
398
  /**
361
399
  * Whether a verified socket carries this relay at this instant.
362
400
  *
@@ -367,6 +405,26 @@ export interface IClusterRelayRegistration {
367
405
  live: boolean;
368
406
  }
369
407
 
408
+ const clusterRelayRegistrationFactKeys = [
409
+ 'clusterId', 'nodeId', 'listenAddress', 'listenPort', 'version', 'registrationSequence',
410
+ 'acceptedAt',
411
+ ] as const;
412
+
413
+ /** The one format rule of every registration fact, whichever shape carries it. */
414
+ const validateClusterRelayRegistrationFacts = (
415
+ valueArg: Record<string, unknown>,
416
+ pathArg: string,
417
+ ): string[] => [
418
+ ...validateClusterRelayIdentifier(valueArg.clusterId, `${pathArg} clusterId`),
419
+ ...validateClusterRelayIdentifier(valueArg.nodeId, `${pathArg} nodeId`),
420
+ ...validateClusterRelayListenAddress(valueArg.listenAddress, `${pathArg} listenAddress`),
421
+ ...validateClusterRelayListenPort(valueArg.listenPort, `${pathArg} listenPort`),
422
+ ...validateClusterRelayVersion(valueArg.version, `${pathArg} version`),
423
+ ...validateClusterRelayNonNegativeInteger(valueArg.registrationSequence,
424
+ `${pathArg} registrationSequence`),
425
+ ...validateClusterRelayNonNegativeInteger(valueArg.acceptedAt, `${pathArg} acceptedAt`),
426
+ ];
427
+
370
428
  /**
371
429
  * Validates one served relay registration and names every reason it is refused.
372
430
  *
@@ -378,23 +436,95 @@ export const validateClusterRelayRegistration = (
378
436
  valueArg: unknown,
379
437
  pathArg = 'cluster relay registration',
380
438
  ): string[] => {
381
- if (!isRecord(valueArg) || !hasExactKeys(valueArg, ['clusterId', 'nodeId', 'listenAddress',
382
- 'listenPort', 'version', 'registrationSequence', 'acceptedAt', 'live'])) {
439
+ if (!isRecord(valueArg)
440
+ || !hasExactKeys(valueArg, [...clusterRelayRegistrationFactKeys, 'live'])) {
383
441
  return [`${pathArg} must use its exact schema`];
384
442
  }
385
443
  return [
386
- ...validateClusterRelayIdentifier(valueArg.clusterId, `${pathArg} clusterId`),
387
- ...validateClusterRelayIdentifier(valueArg.nodeId, `${pathArg} nodeId`),
388
- ...validateClusterRelayListenAddress(valueArg.listenAddress, `${pathArg} listenAddress`),
389
- ...validateClusterRelayListenPort(valueArg.listenPort, `${pathArg} listenPort`),
390
- ...validateClusterRelayVersion(valueArg.version, `${pathArg} version`),
391
- ...validateClusterRelayNonNegativeInteger(valueArg.registrationSequence,
392
- `${pathArg} registrationSequence`),
393
- ...validateClusterRelayNonNegativeInteger(valueArg.acceptedAt, `${pathArg} acceptedAt`),
444
+ ...validateClusterRelayRegistrationFacts(valueArg, pathArg),
394
445
  ...(typeof valueArg.live === 'boolean' ? [] : [`${pathArg} live must be a boolean`]),
395
446
  ];
396
447
  };
397
448
 
449
+ /**
450
+ * Cloudly's accepted registration record: the thing an evidence reference names.
451
+ *
452
+ * `credential` is the exact relay credential generation the registering socket authenticated as,
453
+ * so a reader can tell a registration made under the current credential from one made under a
454
+ * superseded one. Liveness is deliberately absent: a live socket is not a fact a record can hold,
455
+ * and a digest that covered it would change without anything having been registered.
456
+ */
457
+ export interface IClusterRelayAcceptedRegistration extends IClusterRelayRegistrationFacts {
458
+ id: string;
459
+ /** Advances by one per accepted registration, so a reference to a superseded one fails closed. */
460
+ generation: number;
461
+ credential: IRuntimeAuthorityReference;
462
+ digest: TSha256Digest;
463
+ }
464
+
465
+ export const clusterRelayContract = Object.freeze({
466
+ acceptedRegistrationDigestDomain: 'serve.zone/cluster-relay-accepted-registration',
467
+ } as const);
468
+
469
+ const failClusterRelayRecord = (): never => {
470
+ throw new Error('Cluster relay contract rejected.');
471
+ };
472
+
473
+ /** The facts of an already exact-keyed record, refused unless each passes its one format rule. */
474
+ const readClusterRelayRegistrationFacts = (
475
+ valueArg: Record<string, unknown>,
476
+ ): IClusterRelayRegistrationFacts => {
477
+ if (validateClusterRelayRegistrationFacts(valueArg, 'cluster relay registration').length > 0) {
478
+ failClusterRelayRecord();
479
+ }
480
+ const {
481
+ clusterId, nodeId, listenAddress, listenPort, version, registrationSequence, acceptedAt,
482
+ } = valueArg;
483
+ // Each member passed its format rule above, which is what the assertion states.
484
+ return {
485
+ clusterId, nodeId, listenAddress, listenPort, version, registrationSequence, acceptedAt,
486
+ } as IClusterRelayRegistrationFacts;
487
+ };
488
+
489
+ /** Structural copy only; whether the record is the cluster's current one is Cloudly's to decide. */
490
+ export const snapshotClusterRelayAcceptedRegistration = (
491
+ valueArg: unknown,
492
+ ): IClusterRelayAcceptedRegistration => {
493
+ try {
494
+ const value = networkRecord(captureNetwork(valueArg), [
495
+ ...clusterRelayRegistrationFactKeys, 'id', 'generation', 'credential', 'digest',
496
+ ]);
497
+ return {
498
+ ...readClusterRelayRegistrationFacts(value),
499
+ id: networkId(value.id),
500
+ generation: networkCounter(value.generation),
501
+ credential: networkReference(value.credential),
502
+ digest: networkDigest(value.digest),
503
+ };
504
+ } catch {
505
+ return failClusterRelayRecord();
506
+ }
507
+ };
508
+
509
+ export const computeClusterRelayAcceptedRegistrationDigest = async (
510
+ valueArg: unknown,
511
+ ): Promise<TSha256Digest> => networkHash(
512
+ clusterRelayContract.acceptedRegistrationDigestDomain,
513
+ snapshotClusterRelayAcceptedRegistration(valueArg),
514
+ );
515
+
516
+ export const validateClusterRelayAcceptedRegistration = async (
517
+ valueArg: unknown,
518
+ ): Promise<string[]> => {
519
+ try {
520
+ const value = snapshotClusterRelayAcceptedRegistration(valueArg);
521
+ return value.digest === await computeClusterRelayAcceptedRegistrationDigest(value)
522
+ ? [] : ['cluster relay accepted registration digest does not match'];
523
+ } catch {
524
+ return ['cluster relay accepted registration must use its exact canonical schema'];
525
+ }
526
+ };
527
+
398
528
  /**
399
529
  * The name the relay's certificate is issued for, and the A record Cloudly
400
530
  * owns for it. Derived from the origin so every consumer derives it
@@ -20,6 +20,12 @@ import {
20
20
  type IFleetCutoverPreparedVolumeHandoffIntent,
21
21
  type IFleetCutoverPreparedVolumeHandoffPage,
22
22
  } from './fleetcutover.js';
23
+ import {
24
+ validateSparkLegacyRetirementOutcome,
25
+ validateSparkLegacyRuntimeRetirement,
26
+ type ISparkLegacyRuntimeRetirement,
27
+ type TSparkLegacyRetirementOutcome,
28
+ } from './sparklegacyretirement.js';
23
29
  import { protocolRefusalHttpStatus, validateProtocolOfferOn, type IProtocolOffer } from '../protocol.js';
24
30
  import {
25
31
  canonicalizeStrictJson,
@@ -242,6 +248,8 @@ interface ISparkSwarmObservationBase {
242
248
  observedAt: number;
243
249
  /** Available in every observation state; exact page membership proves an empty node subset. */
244
250
  fleetCutoverHeldVolumeFences?: IFleetCutoverHeldVolumeFenceEvidencePage;
251
+ /** Available in every observation state too: this describes the host, not its Docker daemon. */
252
+ fleetCutoverLegacyRetirement?: ISparkLegacyRuntimeRetirement;
245
253
  }
246
254
 
247
255
  export type TSparkSwarmObservation =
@@ -280,7 +288,17 @@ export type TSparkSwarmObservationRejectionCode =
280
288
  | 'observation-too-old'
281
289
  | 'observation-from-future'
282
290
  | 'observation-time-not-advancing'
283
- | 'acceptance-race';
291
+ | 'acceptance-race'
292
+ /**
293
+ * An evidence member of this observation is refused, for this body and every resend of it.
294
+ *
295
+ * The whole observation is rejected because the refused content is what the body states, and a
296
+ * node that keeps resending it would never be recorded. It is the answer for a member whose
297
+ * content this server has decided against; a server-side condition that a later attempt could
298
+ * pass is `acceptance-race` instead, and telling the two apart while accepting evidence is the
299
+ * server's own work.
300
+ */
301
+ | 'evidence-refused';
284
302
 
285
303
  export const sparkSwarmObservationContract = Object.freeze({
286
304
  digestDomain: 'serve.zone/spark-swarm-observation',
@@ -297,6 +315,7 @@ export const sparkSwarmObservationContract = Object.freeze({
297
315
  'observation-from-future': false,
298
316
  'observation-time-not-advancing': false,
299
317
  'acceptance-race': true,
318
+ 'evidence-refused': false,
300
319
  } satisfies Record<TSparkSwarmObservationRejectionCode, boolean>),
301
320
  } as const);
302
321
 
@@ -344,6 +363,15 @@ export interface ISparkSwarmObservationAcceptedReceipt {
344
363
  acceptedAt: number;
345
364
  /** Runtime target generation observed immediately after this acceptance. */
346
365
  targetGenerationAtAcceptance: number;
366
+ /**
367
+ * What the server did with `fleetCutoverLegacyRetirement`, present exactly when it was stated.
368
+ *
369
+ * A statement about the node's own released runtime is judged against records rather than
370
+ * against this message, so its refusal is answered here instead of rejecting an observation
371
+ * whose liveness and census are true either way. Absent on the rejected receipt: an observation
372
+ * that was not accepted never reached the statement.
373
+ */
374
+ fleetCutoverLegacyRetirement?: TSparkLegacyRetirementOutcome;
347
375
  /** The answering server's offer; see `sparkSwarmNodeContracts`. */
348
376
  protocol: IProtocolOffer;
349
377
  }
@@ -628,6 +656,8 @@ export const validateSparkSwarmObservation = async (
628
656
  'state',
629
657
  ...(observation.fleetCutoverHeldVolumeFences === undefined
630
658
  ? [] : ['fleetCutoverHeldVolumeFences']),
659
+ ...(observation.fleetCutoverLegacyRetirement === undefined
660
+ ? [] : ['fleetCutoverLegacyRetirement']),
631
661
  ];
632
662
  const expectedKeys = observation.state === 'unknown'
633
663
  ? [...commonKeys, 'reason']
@@ -676,6 +706,11 @@ export const validateSparkSwarmObservation = async (
676
706
  errorArg => `Spark Swarm observation ${errorArg}`,
677
707
  ));
678
708
  }
709
+ if (observation.fleetCutoverLegacyRetirement !== undefined) {
710
+ errors.push(...validateSparkLegacyRuntimeRetirement(
711
+ observation.fleetCutoverLegacyRetirement,
712
+ ).map(errorArg => `Spark Swarm observation ${errorArg}`));
713
+ }
679
714
  if (observation.state === 'member') {
680
715
  if (!isRuntimeIdentifier(observation.localSwarmNodeId)
681
716
  || typeof observation.controlAvailable !== 'boolean') {
@@ -955,6 +990,8 @@ export const validateSparkSwarmObservationResponse = async (
955
990
  'observationDigest',
956
991
  'acceptedAt',
957
992
  'targetGenerationAtAcceptance',
993
+ ...(responseArg.fleetCutoverLegacyRetirement === undefined
994
+ ? [] : ['fleetCutoverLegacyRetirement']),
958
995
  'protocol',
959
996
  ];
960
997
  const rejectedKeys = [
@@ -1004,6 +1041,18 @@ export const validateSparkSwarmObservationResponse = async (
1004
1041
  || (responseArg.targetGenerationAtAcceptance as number) < 0) {
1005
1042
  errors.push('Spark Swarm observation accepted receipt fields must be canonical');
1006
1043
  }
1044
+ // Stated and unanswered, or answered and never stated: both are a server that judged
1045
+ // something other than the observation this receipt binds.
1046
+ if ((responseArg.fleetCutoverLegacyRetirement !== undefined)
1047
+ !== (expectedRequestArg.observation.fleetCutoverLegacyRetirement !== undefined)) {
1048
+ errors.push(
1049
+ 'Spark Swarm observation accepted receipt must answer the legacy retirement it was sent',
1050
+ );
1051
+ } else if (responseArg.fleetCutoverLegacyRetirement !== undefined) {
1052
+ errors.push(...validateSparkLegacyRetirementOutcome(
1053
+ responseArg.fleetCutoverLegacyRetirement,
1054
+ ).map(errorArg => `Spark Swarm observation response ${errorArg}`));
1055
+ }
1007
1056
  } else {
1008
1057
  const retryability = sparkSwarmObservationContract.rejectionRetryability;
1009
1058
  if (!isPositiveSafeInteger(responseArg.rejectedAt)