@serve.zone/interfaces 32.15.0 → 32.17.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
@@ -1056,6 +1056,24 @@ sequence, observation time and fixed `freshUntil`; assembling a later census can
1056
1056
  observation. It never transports environment, arbitrary labels, mount paths, volume names,
1057
1057
  registry credentials or secret values.
1058
1058
 
1059
+ `data.computeFleetCutoverDockerServiceSpecDigest` hashes the complete open-schema Docker
1060
+ `Service.Spec`, including response properties unknown to this Interfaces version, under
1061
+ `serve.zone/fleet-cutover-docker-service-spec`. The helper returns only the digest: the full spec,
1062
+ including any environment values or secret references it contains, never belongs in the
1063
+ observation or logs. `data.computeFleetCutoverDockerConstraintSetIdentity` sorts exact constraint
1064
+ strings in UTF-16 code-unit order, preserves duplicates, and binds both the resulting list and its
1065
+ count. `data.snapshotFleetCutoverDockerDesiredTaskCount` admits only the non-negative safe integer
1066
+ from `ServiceStatus.DesiredTasks`; the producer must request service status and must not derive a
1067
+ replacement from task state. These helpers consume only the Docker response fields they name, so
1068
+ additional response properties do not make a newer daemon unreadable.
1069
+
1070
+ The only established historical service-identity ownership label is
1071
+ `data.fleetCutoverDockerLabelNames.serviceId`, whose exact value is `serve.zone.serviceId`.
1072
+ `ownershipLabels.platformOwner: null` means there is no known Docker-label evidence of a platform
1073
+ owner. It does not classify the service as an application, and neither a service name nor an
1074
+ unrecognized label supplies that classification. Cloudly's accepted administrator classification
1075
+ remains the authority for unlabeled legacy platform services.
1076
+
1059
1077
  Mount identity has three explicit canonical domains. `serve.zone/fleet-cutover-mount-source-identity`
1060
1078
  hashes `{ type, source }`, where `source` is the exact UTF-8 `Mount.Source` returned by Docker,
1061
1079
  including an empty string. `serve.zone/fleet-cutover-mount-target-identity` hashes `{ target }`,
@@ -1068,6 +1086,26 @@ ownership also prevents completion rather than omitting the container. Structura
1068
1086
  not establish freshness, authentication, manager consensus, ownership or Docker truth; Cloudly
1069
1087
  establishes those policy facts while dereferencing the accepted observations.
1070
1088
 
1089
+ Docker exposes two different volume sources. A service-spec `Mount.Source` is the logical volume
1090
+ name, while a local container `MountPoint.Source` is its physical host path and `MountPoint.Name`
1091
+ is the logical name. `data.createFleetCutoverDockerServiceMountDescriptor` and
1092
+ `data.createFleetCutoverDockerLocalMountDescriptor` therefore keep their exact raw-source mount
1093
+ identities distinct. They never substitute `Name` for `Source`. The local-only
1094
+ `data.createFleetCutoverDockerPhysicalMountEvidence` adds a value-free correlation from the
1095
+ physical `mountIdentityDigest` to the service-spec identity derived from exact `MountPoint.Name`
1096
+ for volumes, or exact `Source` for bind and tmpfs mounts. Its independently domain-separated
1097
+ correlation digest binds that pair. When a physical container states `mountCorrelations`, the list
1098
+ must be sorted, unique, bounded, and a complete one-to-one mapping of its mounts. The property is
1099
+ optional only so observations created before this evidence existed still parse; absence proves no
1100
+ join, and a cutover consumer requiring a physical/service match must refuse it. Unsupported local
1101
+ mount types and volume mounts without a non-empty `Name` cannot produce correlation evidence.
1102
+
1103
+ The shared exact classifier recognizes only a writable
1104
+ `/var/run/docker.sock` to `/var/run/docker.sock` bind as `docker-socket`, a read-only
1105
+ `/sys/fs/cgroup` to `/host/cgroup` bind as `cgroup`, every volume as `persistent-volume`, other
1106
+ binds as `bind`, and tmpfs as `tmpfs`. Other Docker types remain `unknown`; no path normalization
1107
+ or service-name inference is performed.
1108
+
1071
1109
  `data.IFleetCutoverScope` is the server-owned authoritative service set. It starts `incomplete`;
1072
1110
  every `complete` entry binds an exact Docker service id to the current service authority and an
1073
1111
  accepted classification authority. `unknown` cannot appear in a complete structural snapshot.
@@ -1160,6 +1198,10 @@ source is identified by SHA-256 and may be `retained-inert` or `removed`; deleti
1160
1198
  erasure are not cutover requirements. Its exact owner generation is retained with that disposition.
1161
1199
  Retained recovery evidence is valid only while the released
1162
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).
1163
1205
 
1164
1206
  Before any post-pallet assignment exists, an administrator calls
1165
1207
  `requests.fleetcutover.prepareFleetCutoverIngress` with the exact server-created service ID,
@@ -1989,12 +2031,13 @@ with `clusterRelayCertificateDomainName(relay)`, which returns the origin's host
1989
2031
  Cloudly issues the certificate for and owns the A record for. An origin naming an address literal
1990
2032
  therefore has no issuable certificate.
1991
2033
 
1992
- `data.clusterRelayUndispatchedRefusals` names the three registration refusals that end a relay's
1993
- custody — `relay-cluster-not-enabled`, `relay-node-foreign` and `relay-sequence-not-advancing` —
1994
- with `data.TClusterRelayUndispatchedRefusal` as their union. Each is Cloudly reading its own
1995
- records and stating that it does not dispatch to this relay, so a relay that hears one drops what
1996
- it holds for its nodes instead of waiting. A refusal that only says Cloudly could not verify the
1997
- relay states nothing about dispatch and is deliberately not in the list.
2034
+ `data.clusterRelayUndispatchedRefusals` names the four registration refusals that end a relay's
2035
+ custody — `relay-cluster-not-enabled`, `relay-node-foreign`, `relay-sequence-not-advancing` and
2036
+ `relay-credential-generation-stale` — with `data.TClusterRelayUndispatchedRefusal` as their union.
2037
+ Each is Cloudly reading its own records and stating that it does not dispatch to this relay, so a
2038
+ relay that hears one drops what it holds for its nodes instead of waiting. A refusal that only
2039
+ says Cloudly could not verify the relay states nothing about dispatch and is deliberately not in
2040
+ the list.
1998
2041
 
1999
2042
  ```typescript
2000
2043
  const relay: data.IClusterRelay = {
@@ -2071,6 +2114,133 @@ const registrationReasons = data.validateRegisterClusterRelayRequest(registratio
2071
2114
  How the relay reaches each node's Corestore is part of the same document: see
2072
2115
  [Corestore Through The Relay](#corestore-through-the-relay).
2073
2116
 
2117
+ ### Cluster Relay Credential
2118
+
2119
+ A cluster's relay authenticates with its own generation-bearing credential instead of the shared
2120
+ cluster authority every cluster-side component used before it. `IClusterRelayCredential` is public
2121
+ metadata: the cluster, a `generation` that advances by one per accepted mint or rotation, the
2122
+ machine `userId` it authenticates as, and `delivery`, the `ISecretVersionReference` of the
2123
+ immutable encrypted version the bearer was delivered as. No record, receipt, request or response
2124
+ of this contract carries a bearer, a hash or key material, and every reader is exact-keyed, so a
2125
+ peer that adds a secret-looking member is refused rather than copied.
2126
+
2127
+ The credential authenticates the cluster's whole control-plane session, exactly what the shared
2128
+ authority authenticated, so a relay changes one credential and keeps every call it makes. The gain
2129
+ is a generation Cloudly fences each socket against and an authority it can rotate and retire on
2130
+ its own; the scope is not narrower. One cluster has one relay and therefore one credential record,
2131
+ and it never expires by itself: rotation is its lifecycle.
2132
+
2133
+ `mutateClusterRelayCredential { identity, mutation }` takes `TClusterRelayCredentialMutation`.
2134
+ `ensure` requires `expectedGeneration: null` and an absent record; `rotate` requires the exact
2135
+ current generation, never a minimum. The caller retains the complete request and its `mutationId`
2136
+ across retries: the identical body answers with the original result and `replayed: true` without
2137
+ renewing delivery, generation or authority, and a changed body fails closed. The first mint also
2138
+ records the shared authority the cluster still carries as `IClusterLegacyRelayCredential`, so a
2139
+ replacement never exists without a named predecessor. Rotation has no overlap: the previous
2140
+ generation stops authenticating at commit, and a relay still holding it hears
2141
+ `relay-credential-generation-stale`, drops what it holds for its nodes, reads the delivered version
2142
+ and reconnects. There is no revocation of the relay credential, because a rotation already covers
2143
+ a compromised one without a state in which the cluster has no relay authority at all.
2144
+
2145
+ An accepted `registerClusterRelay` answers with two references beside the origin and the offer:
2146
+ `credential`, the generation Cloudly authenticated the socket as, and `acceptedRegistration`, the
2147
+ record that acceptance wrote. The relay states no generation of its own — the registration request
2148
+ is unchanged — because a relay naming its own generation would be asserting its own rank.
2149
+ `IClusterRelayAcceptedRegistration` is that record: the registration facts the served read shape
2150
+ also carries, its own `id`, a `generation` that advances once per acceptance, the `credential`
2151
+ reference and a `digest`. Liveness is not part of it. `IClusterRelayRegistration` keeps exactly its
2152
+ published members, the facts plus `live`.
2153
+
2154
+ `retireLegacyClusterRelayCredential { identity, retirement }` revokes the shared authority, and is
2155
+ the last step of replacing it. `IClusterLegacyRelayCredentialRetirementRequest` carries
2156
+ compare-and-swap inputs only: the exact `expectedLegacyGeneration`, the `replacement` credential
2157
+ reference and the `acceptedRegistration` reference the caller observed. Cloudly dereferences both
2158
+ against its own current records, requires the registration to have been made under that
2159
+ replacement, and requires a verified live socket carrying it; a caller can state no liveness or
2160
+ proof of its own. Acceptance writes the one-shot `IClusterLegacyRelayCredentialInvalidation`
2161
+ (`generation: 1`) and advances the legacy record to `state: 'revoked'` naming it. The invalidation
2162
+ names the legacy record at the generation before the revocation and the record names the
2163
+ invalidation after it, so neither digest covers the other's.
2164
+ `data.clusterRelayCredentialRefusals` names every other answer, with
2165
+ `data.TClusterRelayCredentialRefusal` as their union. `getClusterRelayCredential { identity,
2166
+ clusterId }` reads the chain: the credential, the legacy record and the current accepted
2167
+ registration, each `null` while no record exists.
2168
+
2169
+ Each digest-bearing record has a `snapshot…`, `compute…Digest` and `validate…` function under its
2170
+ own digest domain in `data.clusterRelayCredentialContract` and `data.clusterRelayContract`.
2171
+ `bindClusterLegacyRelayCredentialInvalidation(invalidation, legacyCredential,
2172
+ acceptedRegistration)` joins an invalidation to the legacy record it revoked — the active record
2173
+ it names or that record's revoked successor — and to a registration whose `credential` equals the
2174
+ invalidation's `replacement`. It is a content check; dereferencing each record as current and
2175
+ requiring the live socket remain Cloudly's.
2176
+
2177
+ All three requests read only the verified `IIdentityCredential`, never an identity member a caller
2178
+ states about itself.
2179
+
2180
+ ### Spark Legacy Runtime Retirement
2181
+
2182
+ The Spark-owned half of `IFleetCutoverCoreflowRetirementEvidence` — the refused old mode, the
2183
+ configuration compare-and-swap, and the bundle and unit identity — is transported on the Swarm
2184
+ observation a node already posts. There is no route, request or endpoint for it. That is forced
2185
+ rather than preferred: the evidence requires `relay.nodeObservation` and `spark.observation` to be
2186
+ the identical accepted reference, so these facts must reach Cloudly on the very observation that
2187
+ carries the node's Docker census.
2188
+
2189
+ `data.ISparkLegacyRuntimeRetirement` is the optional observation member, available in every
2190
+ observation state because it describes the host and not its Docker daemon. It is
2191
+ `{ refusedMode, bundle, configuration }` and nothing else. It names no node: Cloudly binds it to
2192
+ the reporter it already authenticated, so there is no node identity on it to disagree with. It
2193
+ carries no digest of its own either — `computeSparkSwarmObservationDigest` canonicalizes the whole
2194
+ observation, so a second domain here would only be a second thing to keep in step.
2195
+
2196
+ `refusedMode` is one of `data.sparkLegacyRuntimeModes`, the two runtimes a released Spark could be
2197
+ put into by its `mode` configuration key. `data.sparkLegacyRuntimeModeServices` is the record over
2198
+ that union naming the Docker services each mode owns, so a refusal states a mode and a reader
2199
+ derives the set. `data.ISparkNodeBundleIdentity { version, sourceCommit, manifestSha256, unitName,
2200
+ unitDigest }` is read back from the installed tree and the installed unit file, never from the
2201
+ running process's own build constants: a build can state its own version, it cannot state that the
2202
+ host will start that same code again. `data.ISparkLegacyConfigurationState { ownerGeneration,
2203
+ sourceSha256, disposition }` names the released key-value source by digest alone. No decoded member
2204
+ of that file is representable in any shape here, because it carries the node's released bearer and
2205
+ its jump code; `sourceSha256` covers the whole file, whose smallest unguessable part is a 256-bit
2206
+ token. `disposition` is `retained-inert` or `removed`; erasure is not a cutover requirement.
2207
+
2208
+ Cloudly accepts an observation first and then advances two of its own records, which is what the
2209
+ evidence's two references point at. `data.ISparkLegacyModeRefusal` and
2210
+ `data.ISparkLegacyConfigurationRetirement` are revisions, not one-shot receipts: a node restates
2211
+ both on every observation, a bundle upgrade changes what the boot path is, and a disposition
2212
+ changes once. Both carry the accepted observation they were derived from, its reporter session and
2213
+ its sequence, and both obey the revision rule every record of this package obeys — a first
2214
+ generation has no predecessor and a later one names exactly the generation before it. The
2215
+ configuration record carries two counters on purpose: its own `generation` is contiguous because it
2216
+ is this record's revision, while `configuration.ownerGeneration` is the node's own and may skip,
2217
+ since an observation that never arrived must not look like a skipped revision.
2218
+
2219
+ `advancesSparkLegacyConfigurationState(current, next)` is the compare-and-swap rule, `null` for a
2220
+ node's first accepted state: the same `sourceSha256`, a strictly higher `ownerGeneration`, and no
2221
+ return from `removed` to `retained-inert`. Restating the accepted generation is a replay and
2222
+ therefore not an advance, which is what keeps `acceptedAt` and the named observation from aging
2223
+ forward on a node that has stopped changing. `data.sparkLegacyRetirementRefusals` names every other
2224
+ answer, with `data.TSparkLegacyRetirementRefusal` as their union.
2225
+ `bindSparkLegacyRetirement(modeRefusal, configurationRetirement)` joins the two receipts a
2226
+ retirement cites, requiring the same cluster, node, reporter session, sequence and accepted
2227
+ observation. It is a content check; whether each receipt is its node's current revision remains
2228
+ Cloudly's.
2229
+
2230
+ An omitted member is not a refusal and advances nothing, so the existence of these records is not
2231
+ what makes a retirement honest — the freshness of the observation they name is. Assembly must
2232
+ require that observation to still be inside `fleetCutoverContract.observationFreshnessMs`, exactly
2233
+ as `IFleetCutoverSparkObservationReference` already does, or a node that stopped refusing would go
2234
+ on looking retired.
2235
+
2236
+ Cloudly reads this member before Spark writes it. Every observation validator in this package is
2237
+ exact-key, so a server built against an earlier minor refuses the whole observation rather than
2238
+ ignoring a member it does not know — the peers of a session kind are upgraded before the side that
2239
+ starts depending on the later minor, and the `minimumPeerVersion` of that session kind is raised in
2240
+ the same commit that starts depending on it. A Spark that begins stating this member therefore
2241
+ raises its `sparkSwarmNode` minimum alone, which requires one minimum per session kind rather than
2242
+ one shared across a deployable's offers.
2243
+
2074
2244
  ### Cluster VPN Hub And Network
2075
2245
 
2076
2246
  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.15.0',
6
+ version: '32.17.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,10 @@ import {
20
20
  type IFleetCutoverPreparedVolumeHandoffIntent,
21
21
  type IFleetCutoverPreparedVolumeHandoffPage,
22
22
  } from './fleetcutover.js';
23
+ import {
24
+ validateSparkLegacyRuntimeRetirement,
25
+ type ISparkLegacyRuntimeRetirement,
26
+ } from './sparklegacyretirement.js';
23
27
  import { protocolRefusalHttpStatus, validateProtocolOfferOn, type IProtocolOffer } from '../protocol.js';
24
28
  import {
25
29
  canonicalizeStrictJson,
@@ -242,6 +246,8 @@ interface ISparkSwarmObservationBase {
242
246
  observedAt: number;
243
247
  /** Available in every observation state; exact page membership proves an empty node subset. */
244
248
  fleetCutoverHeldVolumeFences?: IFleetCutoverHeldVolumeFenceEvidencePage;
249
+ /** Available in every observation state too: this describes the host, not its Docker daemon. */
250
+ fleetCutoverLegacyRetirement?: ISparkLegacyRuntimeRetirement;
245
251
  }
246
252
 
247
253
  export type TSparkSwarmObservation =
@@ -628,6 +634,8 @@ export const validateSparkSwarmObservation = async (
628
634
  'state',
629
635
  ...(observation.fleetCutoverHeldVolumeFences === undefined
630
636
  ? [] : ['fleetCutoverHeldVolumeFences']),
637
+ ...(observation.fleetCutoverLegacyRetirement === undefined
638
+ ? [] : ['fleetCutoverLegacyRetirement']),
631
639
  ];
632
640
  const expectedKeys = observation.state === 'unknown'
633
641
  ? [...commonKeys, 'reason']
@@ -676,6 +684,11 @@ export const validateSparkSwarmObservation = async (
676
684
  errorArg => `Spark Swarm observation ${errorArg}`,
677
685
  ));
678
686
  }
687
+ if (observation.fleetCutoverLegacyRetirement !== undefined) {
688
+ errors.push(...validateSparkLegacyRuntimeRetirement(
689
+ observation.fleetCutoverLegacyRetirement,
690
+ ).map(errorArg => `Spark Swarm observation ${errorArg}`));
691
+ }
679
692
  if (observation.state === 'member') {
680
693
  if (!isRuntimeIdentifier(observation.localSwarmNodeId)
681
694
  || typeof observation.controlAvailable !== 'boolean') {