@serve.zone/interfaces 32.13.0 → 32.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/readme.md CHANGED
@@ -1093,19 +1093,61 @@ current Spark bundle/unit/configuration and old-mode refusal evidence. This is a
1093
1093
  owner; it is never represented as a Pallet assignment. Downstream service holds and storage fences
1094
1094
  therefore depend on a live relay without requiring assignment authority that cannot exist yet.
1095
1095
 
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.
1096
+ The storage bootstrap separates desired identity, observed legacy identity and provider-proved
1097
+ physical lineage. `IFleetCutoverPreparedVolumeHandoffIntent` is Cloudly's accepted pre-switch
1098
+ statement. It carries the full sealed `IRuntimeStorageClaimIntent` and the exact persistent
1099
+ `observedMount`, including Docker source, target and combined identity digests. The Docker source
1100
+ digest commits only to Docker's exact source bytes; it is never physical lineage. The prepared
1101
+ handoff deliberately has no `sourceLineageDigest`. Corestore creates that independent commitment
1102
+ from its private device, inode and content proof while the native attachment fence is held.
1103
+
1104
+ An administrator changes prepared handoffs through
1105
+ `prepareFleetCutoverVolumeHandoffs`. Each compare-and-swap mutation contains 1..32 combined upserts
1106
+ and explicit removals. An upsert supplies only `{ nodeId, serviceId, mountIdentityDigest, request }`,
1107
+ where `request` is the full canonical retained `ReadWriteOnce` filesystem request. Cloudly derives
1108
+ the volume, controller, organization, namespace, claim identity, evidence references and request
1109
+ digest from current owner rows. It re-derives the complete required set from the admitted manager
1110
+ task inventory and accepted local observations. The maximum possible physical set is
1111
+ `maximumTasks * maximumMountsPerOwner`, or `4096 * 32 = 131072`; node placement is already carried
1112
+ by each admitted task, so a service-count bound would undercount replicated placement.
1113
+
1114
+ `IFleetCutoverPreparedVolumeHandoffAggregate` is a revisioned header with required/actual counts,
1115
+ state, page count and a complete set digest. It does not inline page references. Each immutable
1116
+ `IFleetCutoverPreparedVolumeHandoffPage` contains 1..32 sorted handoff references, with the owning
1117
+ `nodeId` committed beside every exact handoff revision, plus its content digest and the exact
1118
+ aggregate revision. Readers pin one aggregate, fetch every consecutive page and reject missing,
1119
+ duplicate, stale or mixed-revision pages. The set digest covers the ordered page-content digests. A
1120
+ 32-entry mutation is therefore a wire bound, not a fleet volume ceiling.
1121
+
1122
+ Spark reads one pinned page from
1123
+ `/spark/swarm-nodes/fleet-cutover-prepared-volume-handoffs`. The response carries the full immutable
1124
+ page and the authenticated node's complete full-handoff subset, including an explicit empty subset.
1125
+ Spark forwards held fences through the optional `fleetCutoverHeldVolumeFences` member available in
1126
+ every Swarm-observation state. The page's node-bearing references determine the exact subset; fetch
1127
+ responses and fence evidence must match every committed reference in page order, so omission is
1128
+ rejected and an empty report is valid only for a node with no entry on that page. The request binds
1129
+ the evidence node to the authenticated reporter. That evidence has a dedicated 128 KiB encoded
1130
+ budget and never displaces the existing census snapshots. One node's page report proves only that
1131
+ node's subset; Cloudly establishes global completeness across accepted reports for every page and
1132
+ node.
1133
+
1134
+ Pallet persists `TFleetCutoverVolumeFence` in `held` state while draining. A hold grants no claim,
1135
+ session, assignment, attachment, reservation, confirmation or execution authority. After the real
1136
+ current runtime reaches `pallet`, Pallet obtains `IFleetCutoverStorageAdoptionGrant` through its live
1137
+ authenticated session. The grant binds the exact prepared handoff, held fence, current pallet
1138
+ runtime, session generation/epoch and observed mount. Pallet keeps its per-volume lane while it
1139
+ invokes the isolated Corestore one-shot. Corestore performs physical-lineage proof and the initial
1140
+ ready-claim CAS under its native attachment fence, without calling back into Pallet, and returns the
1141
+ full claim with `IFleetCutoverCorestoreAdoptionReceipt`. Pallet revalidates the live session, runtime
1142
+ phase and grant after that potentially long operation before consuming the durable hold. A stale
1143
+ grant leaves the hold intact.
1144
+
1145
+ `bindFleetCutoverAdoptionToStorageClaim` joins the provider receipt and full ready claim to the
1146
+ prepared desired intent. `bindFleetCutoverStorageAdoptionBundle` additionally joins the current
1147
+ grant and consumed fence. Neither binder equates the provider physical-lineage digest with the
1148
+ observed Docker source digest or requires the provider's opaque `resourceRef` to equal Cloudly's
1149
+ volume ID. They are content checks; current owner dereferencing, peer authentication and native
1150
+ attachment fencing remain mandatory.
1109
1151
 
1110
1152
  `IFleetCutoverCoreflowRetirementEvidence` binds the real pre-switch Docker Coreflow32 relay by exact
1111
1153
  service ID, container ID, spec version/spec digest and image digest to accepted manager and local
@@ -1119,8 +1161,34 @@ erasure are not cutover requirements. Its exact owner generation is retained wit
1119
1161
  Retained recovery evidence is valid only while the released
1120
1162
  owner cannot consume it into live mode and the old credential remains revoked.
1121
1163
 
1122
- The post-pallet order is represented without a second route or publication model. First Cloudly
1123
- releases only the new ingress spec under an accepted empty published-port policy. It then accepts
1164
+ Before any post-pallet assignment exists, an administrator calls
1165
+ `requests.fleetcutover.prepareFleetCutoverIngress` with the exact server-created service ID,
1166
+ current `pallet` runtime, complete accepted scope and target set, held exact-cluster spec revision,
1167
+ and `expectedDesignation`. Null creates the first designation; an exact current designation
1168
+ reference permits only same-service credential rotation. Replacing the designated service is a
1169
+ different owner flow and is refused here. Cloudly's one owning transaction verifies the admin,
1170
+ same-organization live service, current references, held exact-cluster spec, zero configured and
1171
+ actual published/public ports, and absence of a conflicting designation. It generates the managed
1172
+ launcher-environment bearer privately, stores its active version and hash authority, advances the
1173
+ held spec if its secret input changed, and accepts `IFleetCutoverIngressDesignation`. The public
1174
+ designation exposes only credential ID/generation and the active `ISecretVersionReference`; neither
1175
+ it nor the response exposes the bearer, hash, private key, or any other secret value. Preparation
1176
+ requires no assignment, runtime session, ingress registration, legacy absence, publication,
1177
+ port-transfer, readback, or `IFleetCutoverIngressAuthority`.
1178
+
1179
+ The preparation request is a closed compare-and-swap body. A replay requires the same mutation ID
1180
+ and exactly the original normalized request payload and returns the recorded designation/spec
1181
+ result; it does not renew generation, acceptance time, freshness, or authority. Every downstream
1182
+ authorization must dereference the current designation, held spec, runtime, scope, and target set.
1183
+ A historical replay response never grants current authority. `snapshotFleetCutoverIngressPreparationRequest`
1184
+ and `snapshotFleetCutoverIngressDesignation` check exact structure, reference relationships and the
1185
+ designation's canonical digest. They cannot establish admin identity, currentness, organization,
1186
+ service liveness, held state, zero ports, or absence of a conflicting designation; Cloudly owns all
1187
+ of those acceptance checks.
1188
+
1189
+ The remaining post-pallet order is represented without a second route or publication model. First
1190
+ Cloudly releases only the designated new ingress spec under an accepted empty published-port
1191
+ policy. It then accepts
1124
1192
  the existing cluster-ingress registration and route-table revision, exact Spark evidence that the
1125
1193
  old ingress is absent, the current source and target publication records and owning port-transfer
1126
1194
  receipt, and a readback of the current route revision as `IFleetCutoverIngressAuthority`. Only an
@@ -1128,9 +1196,14 @@ application release may cite that authority. The legacy Docker Coretraffic insta
1128
1196
  remains through `switching`; the new ingress runs without public ports before exact port ownership
1129
1197
  moves, and applications remain held until the new authoritative route is read back.
1130
1198
 
1131
- The owner methods live under `requests.fleetcutover`: administrator scope classification and
1132
- forward phase CAS, root-local Pallet volume-fence hold/read/
1133
- consume, one-shot Corestore adoption, ingress-authority read, and ingress/application spec release.
1199
+ The owner methods live under `requests.fleetcutover`: administrator scope classification, storage
1200
+ handoff preparation, ingress preparation and forward phase CAS; root-private prepared-fence hold,
1201
+ read and owner-triggered adoption; isolated Corestore prepared-volume adoption; ingress-authority
1202
+ read; and ingress/application spec release. Current-session adoption authorization, accepted bundle
1203
+ reporting and full claim readback live under `requests.runtimesession`. The published 32.x
1204
+ `holdFleetCutoverVolumeFence`, `consumeFleetCutoverVolumeFence` and `adoptFleetCutoverVolume`
1205
+ declarations remain for type compatibility only. Production owners do not register or adapt those
1206
+ incomplete operations; they are marked for removal at the next protocol major.
1134
1207
  Spark transports local and manager evidence through its existing authenticated Swarm-observation
1135
1208
  route, and Cloudly assembles census and Coreflow-retirement records from accepted owner state; there
1136
1209
  is no caller-supplied census or retirement proof endpoint.
@@ -1645,6 +1718,15 @@ or sender addresses itself.
1645
1718
 
1646
1719
  ### Runtime filesystem storage claims
1647
1720
 
1721
+ `data.IRuntimeStorageClaimIntent` is the non-ready Cloudly-owned precursor to a claim. It fixes the
1722
+ controller epoch, organization, cluster, node, runtime namespace, service, claim ID/generation and
1723
+ predecessor, plus `IRuntimeStorageClaimRequestedFilesystemBinding`. That requested binding fixes the
1724
+ service, full normalized retained `ReadWriteOnce` filesystem request and its canonical bare digest.
1725
+ It structurally cannot state a provider binding ID/generation, policy selection, status, observed
1726
+ generation, resource reference or granted capability. The intent is exact-schema and digest-bound
1727
+ under `serve.zone/runtime-storage-claim-intent`; it grants no assignment, session, execution or
1728
+ attachment authority.
1729
+
1648
1730
  `data.IRuntimeStorageClaim` is the portable, Cloudly-issued authority for one
1649
1731
  ready, retained `ReadWriteOnce` filesystem binding on one cluster node. It
1650
1732
  names the organization, service, cluster, node, runtime namespace, controller
@@ -1669,6 +1751,14 @@ provider readiness and policy, durably admit the assignment, and obtain a
1669
1751
  fenced native attachment from the storage owner before a CRI mount. An absent
1670
1752
  claim or an unverified opaque resource reference never authorizes a mount.
1671
1753
 
1754
+ `data.bindRuntimeStorageClaimToIntent(claim, intent)` verifies both canonical digests and requires
1755
+ the ready claim to preserve every Cloudly-owned identity and requested filesystem fact: service,
1756
+ request ID/digest, mount path, access mode and reclaim policy. Only the full claim may add the
1757
+ storage provider's binding identity/generation, selected policy, matching observed generation,
1758
+ `ready` status, opaque resource reference and persistent single-node capabilities. This is a content
1759
+ join: callers still authenticate Cloudly and the provider, check current readiness and retain the
1760
+ native fence.
1761
+
1672
1762
  `platform.storagemigration` defines the provider-neutral cutover contract for a
1673
1763
  named object-storage binding. Corestore atomically owns and fences the source
1674
1764
  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.13.0',
6
+ version: '32.15.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'];