@serve.zone/interfaces 32.14.0 → 32.16.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.
@@ -1093,19 +1131,61 @@ current Spark bundle/unit/configuration and old-mode refusal evidence. This is a
1093
1131
  owner; it is never represented as a Pallet assignment. Downstream service holds and storage fences
1094
1132
  therefore depend on a live relay without requiring assignment authority that cannot exist yet.
1095
1133
 
1096
- The storage bootstrap has two different authorities. `IFleetCutoverVolumeHandoffIntent` is
1097
- Cloudly's accepted pre-switch statement of exact service, volume, source-lineage and storage-request
1098
- identity. It also binds the exact persistent `mountIdentityDigest` and accepted source-node
1099
- observation. It is not a ready `IRuntimeStorageClaim`. Pallet persists a root-local
1100
- `TFleetCutoverVolumeFence` in `held` state while the cluster is draining. That state carries no
1101
- claim, session, assignment, attachment, reservation, confirmation or execution authority, and the
1102
- storage owner must block all those operations for the volume. After the cluster reaches `pallet`,
1103
- Corestore performs its one-shot initial lineage/ReadWriteOnce CAS for the same mount identity under
1104
- that held native fence and returns `IFleetCutoverCorestoreAdoptionReceipt`, which for the first time
1105
- binds the real ready
1106
- `IRuntimeStorageClaim` reference. Only that exact accepted receipt can consume the Pallet fence.
1107
- This separation prevents a pre-switch fence from fabricating provider readiness merely to satisfy
1108
- cutover ordering.
1134
+ The storage bootstrap separates desired identity, observed legacy identity and provider-proved
1135
+ physical lineage. `IFleetCutoverPreparedVolumeHandoffIntent` is Cloudly's accepted pre-switch
1136
+ statement. It carries the full sealed `IRuntimeStorageClaimIntent` and the exact persistent
1137
+ `observedMount`, including Docker source, target and combined identity digests. The Docker source
1138
+ digest commits only to Docker's exact source bytes; it is never physical lineage. The prepared
1139
+ handoff deliberately has no `sourceLineageDigest`. Corestore creates that independent commitment
1140
+ from its private device, inode and content proof while the native attachment fence is held.
1141
+
1142
+ An administrator changes prepared handoffs through
1143
+ `prepareFleetCutoverVolumeHandoffs`. Each compare-and-swap mutation contains 1..32 combined upserts
1144
+ and explicit removals. An upsert supplies only `{ nodeId, serviceId, mountIdentityDigest, request }`,
1145
+ where `request` is the full canonical retained `ReadWriteOnce` filesystem request. Cloudly derives
1146
+ the volume, controller, organization, namespace, claim identity, evidence references and request
1147
+ digest from current owner rows. It re-derives the complete required set from the admitted manager
1148
+ task inventory and accepted local observations. The maximum possible physical set is
1149
+ `maximumTasks * maximumMountsPerOwner`, or `4096 * 32 = 131072`; node placement is already carried
1150
+ by each admitted task, so a service-count bound would undercount replicated placement.
1151
+
1152
+ `IFleetCutoverPreparedVolumeHandoffAggregate` is a revisioned header with required/actual counts,
1153
+ state, page count and a complete set digest. It does not inline page references. Each immutable
1154
+ `IFleetCutoverPreparedVolumeHandoffPage` contains 1..32 sorted handoff references, with the owning
1155
+ `nodeId` committed beside every exact handoff revision, plus its content digest and the exact
1156
+ aggregate revision. Readers pin one aggregate, fetch every consecutive page and reject missing,
1157
+ duplicate, stale or mixed-revision pages. The set digest covers the ordered page-content digests. A
1158
+ 32-entry mutation is therefore a wire bound, not a fleet volume ceiling.
1159
+
1160
+ Spark reads one pinned page from
1161
+ `/spark/swarm-nodes/fleet-cutover-prepared-volume-handoffs`. The response carries the full immutable
1162
+ page and the authenticated node's complete full-handoff subset, including an explicit empty subset.
1163
+ Spark forwards held fences through the optional `fleetCutoverHeldVolumeFences` member available in
1164
+ every Swarm-observation state. The page's node-bearing references determine the exact subset; fetch
1165
+ responses and fence evidence must match every committed reference in page order, so omission is
1166
+ rejected and an empty report is valid only for a node with no entry on that page. The request binds
1167
+ the evidence node to the authenticated reporter. That evidence has a dedicated 128 KiB encoded
1168
+ budget and never displaces the existing census snapshots. One node's page report proves only that
1169
+ node's subset; Cloudly establishes global completeness across accepted reports for every page and
1170
+ node.
1171
+
1172
+ Pallet persists `TFleetCutoverVolumeFence` in `held` state while draining. A hold grants no claim,
1173
+ session, assignment, attachment, reservation, confirmation or execution authority. After the real
1174
+ current runtime reaches `pallet`, Pallet obtains `IFleetCutoverStorageAdoptionGrant` through its live
1175
+ authenticated session. The grant binds the exact prepared handoff, held fence, current pallet
1176
+ runtime, session generation/epoch and observed mount. Pallet keeps its per-volume lane while it
1177
+ invokes the isolated Corestore one-shot. Corestore performs physical-lineage proof and the initial
1178
+ ready-claim CAS under its native attachment fence, without calling back into Pallet, and returns the
1179
+ full claim with `IFleetCutoverCorestoreAdoptionReceipt`. Pallet revalidates the live session, runtime
1180
+ phase and grant after that potentially long operation before consuming the durable hold. A stale
1181
+ grant leaves the hold intact.
1182
+
1183
+ `bindFleetCutoverAdoptionToStorageClaim` joins the provider receipt and full ready claim to the
1184
+ prepared desired intent. `bindFleetCutoverStorageAdoptionBundle` additionally joins the current
1185
+ grant and consumed fence. Neither binder equates the provider physical-lineage digest with the
1186
+ observed Docker source digest or requires the provider's opaque `resourceRef` to equal Cloudly's
1187
+ volume ID. They are content checks; current owner dereferencing, peer authentication and native
1188
+ attachment fencing remain mandatory.
1109
1189
 
1110
1190
  `IFleetCutoverCoreflowRetirementEvidence` binds the real pre-switch Docker Coreflow32 relay by exact
1111
1191
  service ID, container ID, spec version/spec digest and image digest to accepted manager and local
@@ -1154,9 +1234,14 @@ application release may cite that authority. The legacy Docker Coretraffic insta
1154
1234
  remains through `switching`; the new ingress runs without public ports before exact port ownership
1155
1235
  moves, and applications remain held until the new authoritative route is read back.
1156
1236
 
1157
- The owner methods live under `requests.fleetcutover`: administrator scope classification, ingress
1158
- preparation and forward phase CAS, root-local Pallet volume-fence hold/read/
1159
- consume, one-shot Corestore adoption, ingress-authority read, and ingress/application spec release.
1237
+ The owner methods live under `requests.fleetcutover`: administrator scope classification, storage
1238
+ handoff preparation, ingress preparation and forward phase CAS; root-private prepared-fence hold,
1239
+ read and owner-triggered adoption; isolated Corestore prepared-volume adoption; ingress-authority
1240
+ read; and ingress/application spec release. Current-session adoption authorization, accepted bundle
1241
+ reporting and full claim readback live under `requests.runtimesession`. The published 32.x
1242
+ `holdFleetCutoverVolumeFence`, `consumeFleetCutoverVolumeFence` and `adoptFleetCutoverVolume`
1243
+ declarations remain for type compatibility only. Production owners do not register or adapt those
1244
+ incomplete operations; they are marked for removal at the next protocol major.
1160
1245
  Spark transports local and manager evidence through its existing authenticated Swarm-observation
1161
1246
  route, and Cloudly assembles census and Coreflow-retirement records from accepted owner state; there
1162
1247
  is no caller-supplied census or retirement proof endpoint.
@@ -1671,6 +1756,15 @@ or sender addresses itself.
1671
1756
 
1672
1757
  ### Runtime filesystem storage claims
1673
1758
 
1759
+ `data.IRuntimeStorageClaimIntent` is the non-ready Cloudly-owned precursor to a claim. It fixes the
1760
+ controller epoch, organization, cluster, node, runtime namespace, service, claim ID/generation and
1761
+ predecessor, plus `IRuntimeStorageClaimRequestedFilesystemBinding`. That requested binding fixes the
1762
+ service, full normalized retained `ReadWriteOnce` filesystem request and its canonical bare digest.
1763
+ It structurally cannot state a provider binding ID/generation, policy selection, status, observed
1764
+ generation, resource reference or granted capability. The intent is exact-schema and digest-bound
1765
+ under `serve.zone/runtime-storage-claim-intent`; it grants no assignment, session, execution or
1766
+ attachment authority.
1767
+
1674
1768
  `data.IRuntimeStorageClaim` is the portable, Cloudly-issued authority for one
1675
1769
  ready, retained `ReadWriteOnce` filesystem binding on one cluster node. It
1676
1770
  names the organization, service, cluster, node, runtime namespace, controller
@@ -1695,6 +1789,14 @@ provider readiness and policy, durably admit the assignment, and obtain a
1695
1789
  fenced native attachment from the storage owner before a CRI mount. An absent
1696
1790
  claim or an unverified opaque resource reference never authorizes a mount.
1697
1791
 
1792
+ `data.bindRuntimeStorageClaimToIntent(claim, intent)` verifies both canonical digests and requires
1793
+ the ready claim to preserve every Cloudly-owned identity and requested filesystem fact: service,
1794
+ request ID/digest, mount path, access mode and reclaim policy. Only the full claim may add the
1795
+ storage provider's binding identity/generation, selected policy, matching observed generation,
1796
+ `ready` status, opaque resource reference and persistent single-node capabilities. This is a content
1797
+ join: callers still authenticate Cloudly and the provider, check current readiness and retain the
1798
+ native fence.
1799
+
1698
1800
  `platform.storagemigration` defines the provider-neutral cutover contract for a
1699
1801
  named object-storage binding. Corestore atomically owns and fences the source
1700
1802
  binding after validating a distinct, unfenced active-object snapshot. Onebox
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/interfaces',
6
- version: '32.14.0',
6
+ version: '32.16.0',
7
7
  description: 'Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.'
8
8
  }
@@ -1,11 +1,24 @@
1
1
  import * as plugins from '../plugins.js';
2
- import type { TSha256Digest, TImmutableContainerPlatform } from './immutableimage.js';
2
+ import { isSha256Digest, type TSha256Digest, type TImmutableContainerPlatform } from './immutableimage.js';
3
3
  import type { ISparkNodeReportRecord } from './sparknodereport.js';
4
4
  import {
5
+ fleetCutoverContract,
6
+ snapshotFleetCutoverPreparedVolumeHandoffAggregate,
7
+ snapshotFleetCutoverPreparedVolumeHandoffPage,
8
+ snapshotFleetCutoverPreparedVolumeHandoffIntent,
9
+ validateFleetCutoverHeldVolumeFenceEvidencePage,
10
+ validateFleetCutoverPreparedVolumeHandoffAggregate,
11
+ validateFleetCutoverPreparedVolumeHandoffPage,
12
+ validateFleetCutoverPreparedVolumeHandoffIntent,
5
13
  validateFleetCutoverLocalNodeSnapshot,
6
14
  validateFleetCutoverManagerSnapshot,
15
+ type IFleetCutoverAcceptedEvidenceReference,
16
+ type IFleetCutoverHeldVolumeFenceEvidencePage,
7
17
  type IFleetCutoverLocalNodeSnapshot,
8
18
  type IFleetCutoverManagerSnapshot,
19
+ type IFleetCutoverPreparedVolumeHandoffAggregate,
20
+ type IFleetCutoverPreparedVolumeHandoffIntent,
21
+ type IFleetCutoverPreparedVolumeHandoffPage,
9
22
  } from './fleetcutover.js';
10
23
  import { protocolRefusalHttpStatus, validateProtocolOfferOn, type IProtocolOffer } from '../protocol.js';
11
24
  import {
@@ -131,9 +144,15 @@ export const sparkSwarmNodeContracts = Object.freeze({
131
144
  */
132
145
  swarmObservation: Object.freeze({
133
146
  endpoint: '/spark/swarm-nodes/swarm-observation',
134
- maxRequestBytes: 4_194_304,
147
+ maxRequestBytes: 4_325_376,
135
148
  maxResponseBytes: 2048,
136
149
  } as const),
150
+ /** Pinned immutable aggregate page plus at most 32 full handoffs for this authenticated node. */
151
+ fleetCutoverPreparedVolumeHandoffs: Object.freeze({
152
+ endpoint: '/spark/swarm-nodes/fleet-cutover-prepared-volume-handoffs',
153
+ maxRequestBytes: 4096,
154
+ maxResponseBytes: 262_144,
155
+ } as const),
137
156
  protocolRefusalStatus: protocolRefusalHttpStatus,
138
157
  } as const);
139
158
 
@@ -221,6 +240,8 @@ interface ISparkSwarmObservationBase {
221
240
  reporterSessionId: string;
222
241
  observationSequence: number;
223
242
  observedAt: number;
243
+ /** Available in every observation state; exact page membership proves an empty node subset. */
244
+ fleetCutoverHeldVolumeFences?: IFleetCutoverHeldVolumeFenceEvidencePage;
224
245
  }
225
246
 
226
247
  export type TSparkSwarmObservation =
@@ -288,6 +309,32 @@ export interface ISparkSwarmObservationRequest {
288
309
  observation: TSparkSwarmObservation;
289
310
  }
290
311
 
312
+ /** First read may select current with null; all following page reads pin the exact aggregate. */
313
+ export interface ISparkGetFleetCutoverPreparedVolumeHandoffsRequest {
314
+ nodeId: string;
315
+ nodeToken: string;
316
+ protocol: IProtocolOffer;
317
+ aggregate: IFleetCutoverAcceptedEvidenceReference | null;
318
+ pageIndex: number;
319
+ }
320
+
321
+ export type TSparkGetFleetCutoverPreparedVolumeHandoffsResponse =
322
+ | {
323
+ aggregate: null;
324
+ page: null;
325
+ nodeId: string;
326
+ nodeHandoffs: [];
327
+ protocol: IProtocolOffer;
328
+ }
329
+ | {
330
+ aggregate: IFleetCutoverPreparedVolumeHandoffAggregate;
331
+ page: IFleetCutoverPreparedVolumeHandoffPage;
332
+ nodeId: string;
333
+ /** Exact complete authenticated-node subset committed by this page; may be empty. */
334
+ nodeHandoffs: IFleetCutoverPreparedVolumeHandoffIntent[];
335
+ protocol: IProtocolOffer;
336
+ };
337
+
291
338
  export interface ISparkSwarmObservationAcceptedReceipt {
292
339
  state: 'accepted';
293
340
  nodeId: string;
@@ -579,6 +626,8 @@ export const validateSparkSwarmObservation = async (
579
626
  'observationSequence',
580
627
  'observedAt',
581
628
  'state',
629
+ ...(observation.fleetCutoverHeldVolumeFences === undefined
630
+ ? [] : ['fleetCutoverHeldVolumeFences']),
582
631
  ];
583
632
  const expectedKeys = observation.state === 'unknown'
584
633
  ? [...commonKeys, 'reason']
@@ -619,6 +668,14 @@ export const validateSparkSwarmObservation = async (
619
668
  .includes(observation.reason as string)) {
620
669
  errors.push('Spark Swarm observation unknown reason must be canonical');
621
670
  }
671
+ if (observation.fleetCutoverHeldVolumeFences !== undefined) {
672
+ const evidenceErrors = await validateFleetCutoverHeldVolumeFenceEvidencePage(
673
+ observation.fleetCutoverHeldVolumeFences,
674
+ );
675
+ errors.push(...evidenceErrors.map(
676
+ errorArg => `Spark Swarm observation ${errorArg}`,
677
+ ));
678
+ }
622
679
  if (observation.state === 'member') {
623
680
  if (!isRuntimeIdentifier(observation.localSwarmNodeId)
624
681
  || typeof observation.controlAvailable !== 'boolean') {
@@ -694,6 +751,151 @@ const validateSparkReporterIdentity = (
694
751
  return errors;
695
752
  };
696
753
 
754
+ const readFleetCutoverEvidenceReference = (
755
+ valueArg: unknown,
756
+ ): IFleetCutoverAcceptedEvidenceReference | null => {
757
+ if (valueArg === null) return null;
758
+ if (!isRuntimeRecord(valueArg)
759
+ || !hasExactKeys(valueArg, ['id', 'generation', 'digest'])
760
+ || !isRuntimeIdentifier(valueArg.id)
761
+ || !isPositiveSafeInteger(valueArg.generation)
762
+ || typeof valueArg.digest !== 'string'
763
+ || !isSha256Digest(valueArg.digest)) return null;
764
+ return {
765
+ id: valueArg.id,
766
+ generation: valueArg.generation,
767
+ digest: valueArg.digest,
768
+ };
769
+ };
770
+
771
+ export const validateSparkGetFleetCutoverPreparedVolumeHandoffsRequest = (
772
+ requestArg: unknown,
773
+ ): string[] => {
774
+ try {
775
+ if (!isRuntimeRecord(requestArg)
776
+ || !hasExactKeys(requestArg, [
777
+ 'nodeId', 'nodeToken', 'protocol', 'aggregate', 'pageIndex',
778
+ ])) {
779
+ return ['Spark prepared volume handoff request must use its exact schema'];
780
+ }
781
+ canonicalizeStrictJson(
782
+ requestArg,
783
+ failSparkSwarmObservationCanonicalization,
784
+ 'Spark prepared volume handoff request',
785
+ );
786
+ const errors = validateProtocolOfferOn(
787
+ requestArg,
788
+ 'Spark prepared volume handoff request protocol',
789
+ );
790
+ errors.push(...validateSparkReporterIdentity(
791
+ requestArg,
792
+ 'Spark prepared volume handoff request',
793
+ ));
794
+ if ((requestArg.aggregate !== null
795
+ && readFleetCutoverEvidenceReference(requestArg.aggregate) === null)
796
+ || typeof requestArg.pageIndex !== 'number'
797
+ || !Number.isSafeInteger(requestArg.pageIndex)
798
+ || requestArg.pageIndex < 0
799
+ || requestArg.pageIndex >= fleetCutoverContract.maximumPreparedVolumeHandoffPages) {
800
+ errors.push('Spark prepared volume handoff request pin must be canonical');
801
+ }
802
+ return errors;
803
+ } catch {
804
+ return ['Spark prepared volume handoff request must be safely inspectable'];
805
+ }
806
+ };
807
+
808
+ export const validateSparkGetFleetCutoverPreparedVolumeHandoffsResponse = async (
809
+ responseArg: unknown,
810
+ requestArg: ISparkGetFleetCutoverPreparedVolumeHandoffsRequest,
811
+ ): Promise<string[]> => {
812
+ try {
813
+ if (validateSparkGetFleetCutoverPreparedVolumeHandoffsRequest(requestArg).length) {
814
+ return ['expected Spark prepared volume handoff request must be valid'];
815
+ }
816
+ if (!isRuntimeRecord(responseArg)
817
+ || !hasExactKeys(responseArg, [
818
+ 'aggregate', 'page', 'nodeId', 'nodeHandoffs', 'protocol',
819
+ ])) {
820
+ return ['Spark prepared volume handoff response must use its exact schema'];
821
+ }
822
+ const canonical = canonicalizeStrictJson(
823
+ responseArg,
824
+ failSparkSwarmObservationCanonicalization,
825
+ 'Spark prepared volume handoff response',
826
+ );
827
+ const errors = validateProtocolOfferOn(
828
+ responseArg,
829
+ 'Spark prepared volume handoff response protocol',
830
+ );
831
+ if (new TextEncoder().encode(canonical).byteLength
832
+ > sparkSwarmNodeContracts.fleetCutoverPreparedVolumeHandoffs.maxResponseBytes) {
833
+ errors.push('Spark prepared volume handoff response exceeds its encoded byte bound');
834
+ }
835
+ if (responseArg.nodeId !== requestArg.nodeId) {
836
+ errors.push('Spark prepared volume handoff response must name the authenticated node');
837
+ }
838
+ if (responseArg.aggregate === null) {
839
+ if (requestArg.aggregate !== null || responseArg.page !== null
840
+ || !Array.isArray(responseArg.nodeHandoffs)
841
+ || responseArg.nodeHandoffs.length !== 0) {
842
+ errors.push('Spark empty prepared volume handoff response must be canonical');
843
+ }
844
+ return errors;
845
+ }
846
+ const aggregate = snapshotFleetCutoverPreparedVolumeHandoffAggregate(responseArg.aggregate);
847
+ const page = snapshotFleetCutoverPreparedVolumeHandoffPage(responseArg.page);
848
+ if ((await validateFleetCutoverPreparedVolumeHandoffAggregate(aggregate)).length
849
+ || aggregate.state !== 'complete') {
850
+ errors.push('Spark prepared volume handoff response aggregate must be complete and valid');
851
+ }
852
+ if ((await validateFleetCutoverPreparedVolumeHandoffPage(page)).length) {
853
+ errors.push('Spark prepared volume handoff response page must be valid');
854
+ }
855
+ if (requestArg.aggregate !== null
856
+ && (requestArg.aggregate.id !== aggregate.id
857
+ || requestArg.aggregate.generation !== aggregate.generation
858
+ || requestArg.aggregate.digest !== aggregate.digest)) {
859
+ errors.push('Spark prepared volume handoff response must preserve the aggregate pin');
860
+ }
861
+ if (page.aggregate.id !== aggregate.id
862
+ || page.aggregate.generation !== aggregate.generation
863
+ || page.aggregate.digest !== aggregate.digest
864
+ || page.pageIndex !== requestArg.pageIndex
865
+ || page.pageIndex >= aggregate.pageCount) {
866
+ errors.push('Spark prepared volume handoff response page must bind the requested aggregate page');
867
+ }
868
+ if (!Array.isArray(responseArg.nodeHandoffs)
869
+ || responseArg.nodeHandoffs.length
870
+ > fleetCutoverContract.maximumPreparedVolumeHandoffMutationEntries) {
871
+ errors.push('Spark prepared volume handoff response node subset must be bounded');
872
+ return errors;
873
+ }
874
+ const expectedNodeHandoffs = page.handoffs.filter(
875
+ referenceArg => referenceArg.nodeId === requestArg.nodeId,
876
+ );
877
+ if (responseArg.nodeHandoffs.length !== expectedNodeHandoffs.length) {
878
+ errors.push('Spark prepared volume handoff response must include the complete node subset');
879
+ return errors;
880
+ }
881
+ for (const [index, handoffArg] of responseArg.nodeHandoffs.entries()) {
882
+ const handoff = snapshotFleetCutoverPreparedVolumeHandoffIntent(handoffArg);
883
+ const expected = expectedNodeHandoffs[index];
884
+ if ((await validateFleetCutoverPreparedVolumeHandoffIntent(handoff)).length
885
+ || handoff.nodeId !== requestArg.nodeId
886
+ || handoff.id !== expected.id
887
+ || handoff.generation !== expected.generation
888
+ || handoff.digest !== expected.digest) {
889
+ errors.push('Spark prepared volume handoff response node subset must exactly match page order and authority');
890
+ break;
891
+ }
892
+ }
893
+ return errors;
894
+ } catch {
895
+ return ['Spark prepared volume handoff response must be safely inspectable'];
896
+ }
897
+ };
898
+
697
899
  export const validateSparkSwarmObservationRequest = async (
698
900
  requestArg: unknown,
699
901
  ): Promise<string[]> => {
@@ -720,6 +922,11 @@ export const validateSparkSwarmObservationRequest = async (
720
922
  requestArg,
721
923
  'Spark Swarm observation request',
722
924
  ));
925
+ if (isRuntimeRecord(requestArg.observation)
926
+ && isRuntimeRecord(requestArg.observation.fleetCutoverHeldVolumeFences)
927
+ && requestArg.observation.fleetCutoverHeldVolumeFences.nodeId !== requestArg.nodeId) {
928
+ errors.push('Spark Swarm held volume fence evidence must name the authenticated node');
929
+ }
723
930
  return errors;
724
931
  } catch {
725
932
  return ['Spark Swarm observation request must be safely inspectable'];