@serve.zone/interfaces 32.14.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.
@@ -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'];