@serve.zone/interfaces 32.42.0 → 32.44.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serve.zone/interfaces",
3
- "version": "32.42.0",
3
+ "version": "32.44.0",
4
4
  "private": false,
5
5
  "description": "Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.",
6
6
  "exports": {
@@ -27,10 +27,10 @@
27
27
  "@tsclass/tsclass": "^9.5.1"
28
28
  },
29
29
  "devDependencies": {
30
- "@git.zone/tsbuild": "^5.1.1",
30
+ "@git.zone/tsbuild": "^5.1.2",
31
31
  "@git.zone/tsdoc": "^3.0.1",
32
32
  "@git.zone/tsrun": "^3.0.2",
33
- "@git.zone/tstest": "6.3.2",
33
+ "@git.zone/tstest": "6.3.4",
34
34
  "@types/node": "26.6.2"
35
35
  },
36
36
  "files": [
package/readme.md CHANGED
@@ -409,7 +409,17 @@ authorities and their digests are validated, and either that does not validate i
409
409
  protection journal separately from projection admission. It binds a complete
410
410
  protected-authority reference, node/controller scope, authenticated reporter,
411
411
  boot identity and exact native journal reference to a monotonic receipt chain.
412
- A null `nativeBarrier` explicitly removes allocation eligibility. A boot or
412
+ A null `nativeBarrier` explicitly removes allocation eligibility. A node sends one
413
+ only in answer to the `retirement` request of a signed projection (see
414
+ [Projection Retirement Requests](#projection-retirement-requests)): its egress
415
+ ownership is being retired with the `withdrawn` disposition, and it mints no
416
+ positive receipt for the chain afterwards
417
+ (`runtimeNetworkProtectionContract.withdrawal`). Nothing else withdraws. The
418
+ node's allocation-pool guard is retained in the kernel when the node process
419
+ ends, and its boot unit restores it before networking starts, so a clean stop, a
420
+ crash, a reboot or being offline leaves the last receipt current. A reconnect
421
+ binds a new reporter session, which the old receipt no longer matches, and the
422
+ node's first positive receipt under that session restores eligibility. A boot or
413
423
  protection change requires new native journal evidence. These portable values
414
424
  do not inspect a kernel or authenticate their own producer.
415
425
 
@@ -682,6 +692,59 @@ reaches only the endpoints it selects. The platform endpoint contract itself
682
692
  (`{ id, address, protocol, port }`) names no node, which is why this statement is
683
693
  per projection.
684
694
 
695
+ #### Projection Retirement Requests
696
+
697
+ The optional `IRuntimeNetworkProjection.retirement`
698
+ (`IRuntimeNetworkProjectionRetirement { disposition: 'withdrawn', protectedAuthority }`)
699
+ is Cloudly's request that a running node withdraw its allocation protection,
700
+ because its egress ownership is being retired with the `withdrawn` disposition.
701
+ It travels in the projection because that is the one authenticated, monotonic
702
+ channel the node already admits, journals and reads on every pass, and whose
703
+ acknowledged head Cloudly already tracks: the controller's signature covers it, so
704
+ a node cannot invent it; the projection chain is bound to the controller epoch,
705
+ node and runtime namespace, so it cannot be carried elsewhere; and a node's
706
+ acknowledged head proves the node admitted it. A retired node never returns
707
+ (`runtimeNetworkEgressRetirementContract.reentry`), so no later chain of the same
708
+ node can meet a stale request.
709
+
710
+ `protectedAuthority` is the authority current when Cloudly first signed the
711
+ request; its `id` names the receipt chain's authority chain.
712
+ `validateRuntimeNetworkProjection` accepts it only on a `cloudly` projection and
713
+ only naming the projection's own authority chain at a generation the projection
714
+ has reached (the current authority itself at the same generation).
715
+ `admitRuntimeNetworkProjection` latches it: it may first appear on any projection,
716
+ including the first, and that first appearance names exactly the projection's
717
+ current `protectedAuthority` (an older generation is refused, because the node's
718
+ positive receipt under the current authority would then be newer than the request
719
+ and the withdrawal could never complete). Afterwards every successor carries it
720
+ unchanged, never dropped and never rewritten, so it stays fixed while the
721
+ authority chain moves on. Omitted, it keeps the canonical bytes and asks for
722
+ nothing; it grants and withdraws no packet flow. Consumers on interfaces earlier
723
+ than 32.44.0 read projections with exact keys and refuse one that carries
724
+ `retirement`, so Cloudly sends the request only to nodes that report a compatible
725
+ interfaces version.
726
+ `snapshotRuntimeNetworkProjectionRetirement` is the exact-key reader and
727
+ `runtimeNetworkProjectionRetirementContract` states the rules.
728
+
729
+ The node answers with the null-barrier successor of its protection receipt chain
730
+ and mints no positive receipt for that chain again. Its allocation-pool guard
731
+ stays enforced: the answer withdraws eligibility, not protection.
732
+
733
+ `bindRuntimeNetworkProtectionReceiptRetirement(receipt, previous, retirement)` is
734
+ the receiver's gate for every report from a node whose projections carry the
735
+ request, beside `admitRuntimeNetworkProtectionReceipt` with the same `previous`
736
+ (the receiver's current receipt for the node, or null). The receipt must belong to
737
+ the retiring authority chain and `previous`, when given, must be the same chain's
738
+ exact predecessor or the receipt itself. The null answer always passes. A positive
739
+ receipt passes only while it can predate the node's admission of the request, so a
740
+ receipt the node minted before it saw the request still drains from its outbox
741
+ and the withdrawal behind it is never wedged: it names no authority newer than the
742
+ request's (a newer one could only come from a projection already carrying the
743
+ request), and its predecessor is not a null withdrawal (once the receiver holds
744
+ the answer, no positive receipt follows). A positive receipt minted after the
745
+ request against the same authority cannot be told apart from one minted before;
746
+ the node's obligation is never to mint one.
747
+
685
748
  `admitRuntimeNetworkProjection` checks exact predecessor/replay identity and
686
749
  rejects changed same-generation content, regressed membership/readiness evidence,
687
750
  changed immutable address/DNS keys, and omitted packet grants without explicit
@@ -796,11 +859,15 @@ exact protected authority the owner is about to leave, once all five hold:
796
859
  (`quarantinedHandoffs`, at most 256, and `quarantinedWorkloads`, at most 512,
797
860
  both in canonical order) and is never handed out again;
798
861
  5. every remaining owner of that authority acknowledged a projection head that
799
- carries no endpoint of the node (`clearedProjections`, one per remaining owner
800
- in canonical owner order, never the node itself).
862
+ carries no endpoint of the node (`clearedProjections`, in canonical owner order,
863
+ never the node itself). A remaining owner whose own `lost` fence is recorded
864
+ against the same authority is left out: it never acknowledges anything again, so
865
+ waiting for it would freeze every other owner's retirement, and it leaves behind
866
+ its own fence. Every other remaining owner has exactly one entry.
801
867
 
802
868
  `lastReceipt` is the node's last accepted protection receipt. A `withdrawn` node
803
- took part in its own retirement, so it always has one (its null withdrawal); a
869
+ took part in its own retirement, so it always has one (its null withdrawal, the
870
+ answer to its projection's `retirement` request); a
804
871
  `lost` node may have none. Neither disposition shortens the fence: a lost node
805
872
  leaves behind exactly the same controller-owned evidence as a withdrawn one.
806
873
  `snapshotRuntimeNetworkEgressRetirement` is the exact-key reader and
@@ -810,19 +877,40 @@ evidence; the controller fences the rows it read together with the authority it
810
877
  stages.
811
878
 
812
879
  `bindRuntimeNetworkProtectedAuthorityRetirement(previous, next, fences,
813
- retiredOwners)` is the pure successor rule for the egress owners and the address
814
- space of any step. `next` must be the exact successor of `previous` under the same
880
+ retiredOwners, exemptions = [])` is the pure successor rule for the egress owners
881
+ and the address space of any step, and answers `{ previous, next, fences,
882
+ exemptions }`. `next` must be the exact successor of `previous` under the same
815
883
  controller, with both digests verified. An owner of `previous` may be absent from
816
884
  `next` only when exactly one fence recorded against the exact `previous` reference,
817
885
  verified by its digest, names it; a fence for an owner `next` still names, or one
818
- `previous` never named, refuses the step, and every fence's `clearedProjections`
819
- must name exactly the owners the step keeps. `retiredOwners` is the complete,
820
- canonically ordered list of owners earlier steps fenced, which the caller's
821
- durable history supplies; neither side may name any of them, so a fenced owner
822
- never returns. Every pool of `previous` stays unchanged and the prefixes of `next`
823
- cover those of `previous`; a step that removes an owner also keeps every resolver.
824
- Owners may join in the same step, and a step without fences is the additive rule.
825
- One step consumes at most 16 fences (`runtimeNetworkEgressRetirementContract`).
886
+ `previous` never named, refuses the step.
887
+
888
+ `exemptions` are the `lost` fences of owners the step keeps, recorded against the
889
+ same `previous`. They are read and returned, not consumed: each verifies by its
890
+ digest, is recorded under the same controller against the exact `previous`
891
+ reference, and names an owner both `previous` and `next` name that no fence and no
892
+ other exemption names, in canonical owner order and at most 255 per step. A
893
+ withdrawn or stale exemption refuses the step. Every consumed fence's
894
+ `clearedProjections` must name exactly the owners the step keeps less the exempt
895
+ owners, in both directions: a clearance that names an exempt owner or misses a kept
896
+ one refuses the step. An exemption's own clearance is judged only in the step that
897
+ consumes it as a fence. Lost owners can therefore be fenced in parallel against one
898
+ authority and leave 16 per step: each step consumes up to 16 of their fences and
899
+ holds the rest as exemptions, and every later step re-records the remaining fences
900
+ against its own `previous`.
901
+
902
+ `retiredOwners` is the caller's durable history of fenced owners restricted to the
903
+ owners `previous` or `next` names, in canonical order and at most 512 entries. Any
904
+ entry refuses the step, so a fenced owner never returns, and the caller looks up
905
+ only the owners of the two authorities instead of supplying a lifetime list. The
906
+ caller must look up every owner that `previous` and `next` name: the binder cannot
907
+ tell a missed lookup from an empty history, so an omitted entry silently drops the
908
+ guarantee. Every
909
+ pool of `previous` stays unchanged and the prefixes of `next` cover those of
910
+ `previous`; a step that removes an owner also keeps every resolver. Owners may join
911
+ in the same step, and a step without fences is the additive rule, which exemptions
912
+ do not change. One step consumes at most 16 fences
913
+ (`runtimeNetworkEgressRetirementContract`).
826
914
 
827
915
  A retirement step is a new protected authority: every remaining owner reports a
828
916
  receipt citing it before anything new is allocated, while existing leases keep
@@ -1903,8 +1991,10 @@ document. `snapshotNodeRuntimeRetirement` checks the record.
1903
1991
 
1904
1992
  `TNodeRuntimeRetirementDisposition` (`nodeRuntimeRetirementDispositions`,
1905
1993
  `snapshotNodeRuntimeRetirementDisposition`) says how the node leaves. `withdrawn`: the node still
1906
- runs and takes part — its slots are removed against its own removal receipts and its Pallet
1907
- withdraws its protection receipt before its credentials are revoked. `lost`: an administrator
1994
+ runs and takes part — its slots are removed against its own removal receipts, Cloudly asks it
1995
+ through its projection's `retirement` request (see
1996
+ [Projection Retirement Requests](#projection-retirement-requests)), and its Pallet answers by
1997
+ withdrawing its protection receipt before its credentials are revoked. `lost`: an administrator
1908
1998
  states the node will never report again, which authorizes abandoning its slots without removal
1909
1999
  receipts and nothing else; its egress fence is the same as a withdrawn node's.
1910
2000
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/interfaces',
6
- version: '32.42.0',
6
+ version: '32.44.0',
7
7
  description: 'Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.'
8
8
  }
package/ts/data/index.ts CHANGED
@@ -49,6 +49,8 @@ export { runtimeNetworkHostContract } from './networkhost.js';
49
49
  export type { IRuntimeNetworkWorkloadIngress, IRuntimeNetworkWorkloadIngressPacketGrant,
50
50
  IRuntimeNetworkWorkloadIngressProjection } from './networkworkloadingress.js';
51
51
  export { runtimeNetworkWorkloadIngressContract } from './networkworkloadingress.js';
52
+ export type { IRuntimeNetworkProjectionRetirement } from './networkprojectionretirement.js';
53
+ export { runtimeNetworkProjectionRetirementContract, snapshotRuntimeNetworkProjectionRetirement } from './networkprojectionretirement.js';
52
54
  export * from './networkprotection.js';
53
55
  export * from './networksigning.js';
54
56
  export * from './privatenetwork.js';
@@ -39,10 +39,13 @@ export interface IRuntimeNetworkEgressRetirementClearance extends IRuntimeNetwor
39
39
  * 4. every handoff and workload lease the node was granted is quarantined (`quarantinedHandoffs`,
40
40
  * `quarantinedWorkloads`) and is never handed out again;
41
41
  * 5. every remaining owner of `protectedAuthority` acknowledged a projection head that carries no
42
- * endpoint of the node (`clearedProjections`).
42
+ * endpoint of the node (`clearedProjections`). An owner whose own `lost` fence is recorded
43
+ * against the same authority is not a remaining owner here: it never acknowledges anything again,
44
+ * so it can never clear another owner's endpoints, and it leaves behind its own fence.
43
45
  *
44
46
  * `lastReceipt` is the node's last accepted protection receipt. A `withdrawn` node took part in its
45
- * retirement, so it always has one — its null withdrawal; a `lost` node may never have reported.
47
+ * retirement, so it always has one — its null withdrawal, the answer to its projection's
48
+ * `retirement` request; a `lost` node may never have reported.
46
49
  *
47
50
  * A structural snapshot and digest do not authenticate any of it: the controller must fence the
48
51
  * rows it read together with the authority it stages.
@@ -67,7 +70,10 @@ export interface IRuntimeNetworkEgressRetirement extends IRuntimeNetworkNodeScop
67
70
  quarantinedHandoffs: IRuntimeAuthorityReference[];
68
71
  /** Every workload lease the node was granted, each quarantined, in canonical order. */
69
72
  quarantinedWorkloads: IRuntimeAuthorityReference[];
70
- /** One entry per remaining owner of `protectedAuthority`, in canonical owner order. */
73
+ /**
74
+ * One entry per remaining owner of `protectedAuthority` whose own `lost` fence is not recorded
75
+ * against that authority, in canonical owner order.
76
+ */
71
77
  clearedProjections: IRuntimeNetworkEgressRetirementClearance[];
72
78
  digest: TSha256Digest;
73
79
  }
@@ -79,8 +85,16 @@ export const runtimeNetworkEgressRetirementContract = Object.freeze({
79
85
  maximumClearedProjections: runtimeNetworkAuthorityContract.maximumEgressAuthorities - 1,
80
86
  /** Fences one step may consume. Each is read on its own, so one step stays bounded. */
81
87
  maximumFencesPerStep: 16,
82
- /** Owners every earlier step fenced, over the lifetime of one protected authority. */
83
- maximumRetiredOwners: 4096,
88
+ /**
89
+ * Lost fences one step may hold as exemptions: owners it keeps that no consumed fence's clearance
90
+ * has to name. At least one owner is kept, so this is one fewer than the owners an authority has.
91
+ */
92
+ maximumExemptionsPerStep: runtimeNetworkAuthorityContract.maximumEgressAuthorities - 1,
93
+ /**
94
+ * Retired owners one step may be given: the caller's history restricted to owners `previous` or
95
+ * `next` names, which is at most every owner of both.
96
+ */
97
+ maximumRetiredOwners: 2 * runtimeNetworkAuthorityContract.maximumEgressAuthorities,
84
98
  removal: 'absent-owner-requires-fence-bound-to-exact-previous-authority',
85
99
  reentry: 'a-fenced-owner-never-returns',
86
100
  } as const);
@@ -132,28 +146,40 @@ export const computeRuntimeNetworkEgressRetirementDigest = (value: IRuntimeNetwo
132
146
  /**
133
147
  * Whether `next` may succeed `previous`, judged on its egress owners and the address space it keeps.
134
148
  *
135
- * Pure and fail-closed. `fences` are the egress retirements this step consumes; `retiredOwners` is
136
- * the complete list of owners every earlier step fenced, which the caller's durable history must
137
- * supply in canonical owner order. The step is accepted only when:
149
+ * Pure and fail-closed. `fences` are the egress retirements this step consumes. `exemptions` are
150
+ * `lost` fences of owners this step keeps, recorded against the same `previous`: such an owner never
151
+ * acknowledges another projection head, so no consumed fence's clearance can wait for it. They are
152
+ * read, not consumed, and default to none. `retiredOwners` is the caller's durable history of
153
+ * fenced owners restricted to the owners `previous` or `next` names, in canonical owner order. The
154
+ * step is accepted only when:
138
155
  *
139
156
  * - `next` is the exact successor of `previous` under the same controller, and both digests verify;
140
157
  * - an owner of `previous` is absent from `next` only when exactly one fence, recorded against the
141
158
  * exact `previous` reference and verified by its digest, names it — a fence for an owner `next`
142
159
  * still names, or for an owner `previous` does not name, refuses the step;
143
- * - every fence's `clearedProjections` names exactly the owners of `previous` this step keeps;
144
- * - no owner in `retiredOwners` is named by `previous` or `next`, so a fenced owner never returns;
160
+ * - every exemption verifies by its digest, is `lost`, is recorded under the same controller against
161
+ * the exact `previous` reference, and names an owner both `previous` and `next` name, no fence
162
+ * names and no other exemption names; at most `maximumExemptionsPerStep`, in canonical owner order;
163
+ * - every fence's `clearedProjections` names exactly the owners of `previous` this step keeps, less
164
+ * the exempt owners; an exemption's own clearance is not judged here, only when its owner leaves;
165
+ * - `retiredOwners` is empty: any entry is either an owner a side names that was fenced before, or
166
+ * not part of the restriction at all, so a fenced owner never returns;
145
167
  * - every pool of `previous` is kept unchanged and the prefixes of `next` cover those of `previous`;
146
168
  * a step that removes an owner also keeps every resolver of `previous` unchanged.
147
169
  *
148
170
  * Owners that join in the same step are admitted. With no fence this is the additive owner rule:
149
- * nobody leaves. It verifies no fence evidence against live state and fences no record.
171
+ * nobody leaves, and exemptions relax nothing. It verifies no fence evidence against live state and
172
+ * fences no record: the caller fences every fence and exemption it read together with the authority
173
+ * it stages.
150
174
  */
151
175
  export const bindRuntimeNetworkProtectedAuthorityRetirement = async (
152
176
  previousValue: unknown, nextValue: unknown, fencesValue: unknown, retiredOwnersValue: unknown,
177
+ exemptionsValue: unknown = [],
153
178
  ): Promise<{
154
179
  previous: IRuntimeNetworkProtectedAuthority;
155
180
  next: IRuntimeNetworkProtectedAuthority;
156
181
  fences: IRuntimeNetworkEgressRetirement[];
182
+ exemptions: IRuntimeNetworkEgressRetirement[];
157
183
  }> => {
158
184
  try {
159
185
  const contract = runtimeNetworkEgressRetirementContract;
@@ -162,6 +188,9 @@ export const bindRuntimeNetworkProtectedAuthorityRetirement = async (
162
188
  const elements = readDataElements(fencesValue, contract.maximumFencesPerStep);
163
189
  requireNetwork(elements);
164
190
  const fences = networkSorted(elements.map(snapshotRuntimeNetworkEgressRetirement), ownerKey);
191
+ const exemptionElements = readDataElements(exemptionsValue, contract.maximumExemptionsPerStep);
192
+ requireNetwork(exemptionElements);
193
+ const exemptions = networkSorted(exemptionElements.map(snapshotRuntimeNetworkEgressRetirement), ownerKey);
165
194
  const retiredOwners = networkSorted(networkList(captureNetwork(retiredOwnersValue), contract.maximumRetiredOwners,
166
195
  readOwner), ownerKey);
167
196
  requireNetwork(previous.digest === await computeRuntimeNetworkProtectedAuthorityDigest(previous)
@@ -179,17 +208,25 @@ export const bindRuntimeNetworkProtectedAuthorityRetirement = async (
179
208
  && previousOwners.has(ownerKey(fence)) && !nextOwners.has(ownerKey(fence)));
180
209
  }
181
210
  requireNetwork([...previousOwners].every(owner => nextOwners.has(owner) || fenced.has(owner)));
182
- const kept = previous.egressAuthorities.filter(owner => !fenced.has(ownerKey(owner))).map(ownerKey);
211
+ for (const exemption of exemptions) {
212
+ requireNetwork(exemption.digest === await computeRuntimeNetworkEgressRetirementDigest(exemption)
213
+ && exemption.disposition === 'lost' && networkEqual(exemption.controller, previous.controller)
214
+ && networkEqual(exemption.protectedAuthority, reference) && previousOwners.has(ownerKey(exemption))
215
+ && nextOwners.has(ownerKey(exemption)));
216
+ }
217
+ // A fence's owner is never named by `next`, so no exemption names an owner a fence names.
218
+ const exempt = new Set(exemptions.map(ownerKey));
219
+ const cleared = previous.egressAuthorities.map(ownerKey).filter(owner => !fenced.has(owner) && !exempt.has(owner));
183
220
  for (const fence of fences) {
184
- requireNetwork(networkEqual(fence.clearedProjections.map(ownerKey), kept));
221
+ requireNetwork(networkEqual(fence.clearedProjections.map(ownerKey), cleared));
185
222
  }
186
- requireNetwork(retiredOwners.every(owner => !previousOwners.has(ownerKey(owner)) && !nextOwners.has(ownerKey(owner))));
223
+ requireNetwork(retiredOwners.length === 0);
187
224
 
188
225
  requireNetwork(previous.pools.every(pool => next.pools.some(candidate => networkEqual(candidate, pool)))
189
226
  && previous.prefixes.every(prefix => networkCoveredBy(next.prefixes, prefix)));
190
227
  if (fences.length > 0) {
191
228
  requireNetwork(previous.resolvers.every(resolver => next.resolvers.some(candidate => networkEqual(candidate, resolver))));
192
229
  }
193
- return { previous, next, fences };
230
+ return { previous, next, fences, exemptions };
194
231
  } catch { return failNetwork(); }
195
232
  };
@@ -11,6 +11,8 @@ import { hostPacketGrantsOf, snapshotHostProjection,
11
11
  type IRuntimeNetworkHostPacketGrant, type IRuntimeNetworkHostProjection } from './networkhost.js';
12
12
  import { snapshotWorkloadIngressProjection, workloadIngressPacketGrantsOf,
13
13
  type IRuntimeNetworkWorkloadIngressPacketGrant, type IRuntimeNetworkWorkloadIngressProjection } from './networkworkloadingress.js';
14
+ import { requireFirstProjectionRetirement, requireProjectionRetirement, snapshotRuntimeNetworkProjectionRetirement,
15
+ type IRuntimeNetworkProjectionRetirement } from './networkprojectionretirement.js';
14
16
  import { buildPrivateNetworkFqdn, composeServicePrivateNetworkDnsPolicy,
15
17
  snapshotPrivateNetwork, snapshotServicePrivateNetworks } from './privatenetwork.js';
16
18
  import { captureNetwork, failNetwork, networkCanonical, networkContains, networkCounter, networkDigest,
@@ -299,6 +301,15 @@ export interface IRuntimeNetworkProjection extends IRuntimeNetworkNodeScope {
299
301
  * It grants nothing: a workload still reaches only the endpoints it selects.
300
302
  */
301
303
  hostPlatformEndpointIds?: string[];
304
+ /**
305
+ * Present once Cloudly retires this node's egress ownership with the `withdrawn` disposition:
306
+ * the node withdraws its allocation protection with the null-barrier successor of its receipt
307
+ * chain and mints no positive receipt for it again (`IRuntimeNetworkProjectionRetirement`). Only
308
+ * a `cloudly` projection may carry it; it first appears naming exactly the projection's current
309
+ * `protectedAuthority`, and every successor carries it unchanged. Omitted, it keeps the canonical
310
+ * bytes and asks for nothing.
311
+ */
312
+ retirement?: IRuntimeNetworkProjectionRetirement;
302
313
  digest: TSha256Digest;
303
314
  }
304
315
 
@@ -566,10 +577,11 @@ export const snapshotRuntimeNetworkProjection = (value: unknown): IRuntimeNetwor
566
577
  const hasWorkloadIngress = captured !== null && typeof captured === 'object' && Object.hasOwn(captured, 'workloadIngress');
567
578
  const hasHostPlatformEndpoints = captured !== null && typeof captured === 'object'
568
579
  && Object.hasOwn(captured, 'hostPlatformEndpointIds');
580
+ const hasRetirement = captured !== null && typeof captured === 'object' && Object.hasOwn(captured, 'retirement');
569
581
  const item = networkRecord(captured, ['id', 'generation', 'previous', 'controller', 'nodeId', 'runtimeNamespace',
570
582
  'protectedAuthority', 'allocationAuthorities', 'handoff', 'networks', 'memberships', 'endpoints', 'dns', 'withdrawals', 'retiredLeases', 'retiredHandoffs', 'digest',
571
583
  ...(hasRouter ? ['router'] : []), ...(hasHost ? ['host'] : []), ...(hasWorkloadIngress ? ['workloadIngress'] : []),
572
- ...(hasHostPlatformEndpoints ? ['hostPlatformEndpointIds'] : [])]);
584
+ ...(hasHostPlatformEndpoints ? ['hostPlatformEndpointIds'] : []), ...(hasRetirement ? ['retirement'] : [])]);
573
585
  const hostPlatformEndpointIds = hasHostPlatformEndpoints
574
586
  ? networkSorted(networkList(item.hostPlatformEndpointIds, 64, networkId), value => value) : [];
575
587
  requireNetwork(!hasHostPlatformEndpoints || hostPlatformEndpointIds.length > 0);
@@ -585,6 +597,7 @@ export const snapshotRuntimeNetworkProjection = (value: unknown): IRuntimeNetwor
585
597
  ...(hasHost ? { host: snapshotHostProjection(item.host) } : {}),
586
598
  ...(hasWorkloadIngress ? { workloadIngress: snapshotWorkloadIngressProjection(item.workloadIngress) } : {}),
587
599
  ...(hasHostPlatformEndpoints ? { hostPlatformEndpointIds } : {}),
600
+ ...(hasRetirement ? { retirement: snapshotRuntimeNetworkProjectionRetirement(item.retirement) } : {}),
588
601
  retiredLeases: sortedReferences(item.retiredLeases), retiredHandoffs: sortedReferences(item.retiredHandoffs), digest: networkDigest(item.digest) };
589
602
  });
590
603
 
@@ -690,6 +703,7 @@ export const validateRuntimeNetworkProjection = async (value: unknown): Promise<
690
703
  requireNetwork(previous.node !== current.node || previous.last < current.first);
691
704
  }
692
705
  for (const id of projection.hostPlatformEndpointIds ?? []) requireNetwork(current.platformEndpoints.some(platform => platform.id === id));
706
+ if (projection.retirement) requireProjectionRetirement(projection.retirement, projection.controller.kind, current);
693
707
  for (let index = 0; index < projection.endpoints.length; index++) for (let previous = 0; previous < index; previous++) {
694
708
  requireNetwork(!networkOverlaps(projection.endpoints[index].lease.ipv4.linkSubnet, projection.endpoints[previous].lease.ipv4.linkSubnet));
695
709
  }
@@ -792,6 +806,7 @@ export const admitRuntimeNetworkProjection = async (
792
806
  && (projection.router?.withdrawals.length ?? 0) === 0 && (projection.host?.withdrawals.length ?? 0) === 0
793
807
  && (projection.workloadIngress?.withdrawals.length ?? 0) === 0
794
808
  && projection.retiredLeases.length === 0 && projection.retiredHandoffs.length === 0);
809
+ if (projection.retirement) requireFirstProjectionRetirement(projection.retirement, projection.protectedAuthority);
795
810
  return { status: 'accepted', projection };
796
811
  }
797
812
  const previous = await validateRuntimeNetworkProjection(previousInput);
@@ -881,6 +896,10 @@ export const admitRuntimeNetworkProjection = async (
881
896
  }
882
897
  if (previous.handoff && !networkEqual(previous.handoff, projection.handoff)) requireNetwork(projection.retiredHandoffs.some(item =>
883
898
  networkEqual(item, networkReferenceOf(previous.handoff!))));
899
+ // A retirement request first names the current authority, then is never taken back or
900
+ // rewritten: every successor carries it unchanged.
901
+ if (previous.retirement) requireNetwork(networkEqual(projection.retirement ?? null, previous.retirement));
902
+ else if (projection.retirement) requireFirstProjectionRetirement(projection.retirement, projection.protectedAuthority);
884
903
  const protection = projection.protectedAuthority, oldProtection = previous.protectedAuthority;
885
904
  requireNetwork(networkEqual(protection, oldProtection) || (protection.id === oldProtection.id
886
905
  && networkEqual(protection.previous, networkReferenceOf(oldProtection))));
@@ -0,0 +1,72 @@
1
+ import type { IRuntimeAuthorityReference } from './runtimeassignment.js';
2
+ import type { IRuntimeNetworkProtectedAuthority } from './networkauthority.js';
3
+ import { captureNetwork, networkEqual, networkRecord, networkReference, networkReferenceOf, networkSafe,
4
+ requireNetwork } from '../private/networkcontract.js';
5
+
6
+ /**
7
+ * Cloudly's request that a running node withdraw its allocation protection, because its egress
8
+ * ownership is being retired with the `withdrawn` disposition (`INodeRuntimeRetirement`).
9
+ *
10
+ * It travels as `IRuntimeNetworkProjection.retirement`: the one authenticated, monotonic channel a
11
+ * node already admits and journals on every pass. The controller's signature covers it, so a node
12
+ * cannot invent it; the projection chain is bound to the controller epoch, node and runtime
13
+ * namespace, so it cannot be carried to another node or controller; and once a projection carries
14
+ * it, every successor carries it unchanged, so it is never taken back. A retired node never
15
+ * returns (`runtimeNetworkEgressRetirementContract.reentry`): no later chain of the same node can
16
+ * meet a stale signal.
17
+ *
18
+ * The node answers with the null-barrier successor of its protection receipt chain
19
+ * (`IRuntimeNetworkProtectionReceipt.nativeBarrier: null`) and mints no positive receipt for that
20
+ * chain again. Its native allocation-pool guard stays enforced; the answer withdraws eligibility,
21
+ * not protection. Nothing else withdraws: a clean stop, a crash, a reboot or being offline keeps the
22
+ * node's last receipt current.
23
+ */
24
+ export interface IRuntimeNetworkProjectionRetirement {
25
+ /** The retirement row's disposition. A `lost` node never reports again, so it is never asked. */
26
+ disposition: 'withdrawn';
27
+ /**
28
+ * The protected authority current when Cloudly first signed the request: the projection that
29
+ * first carries it names exactly its own `protectedAuthority`. Its `id` names the authority chain
30
+ * the node's receipts belong to; the value then stays fixed while later projections carry a newer
31
+ * authority of the same chain, never an older one.
32
+ */
33
+ protectedAuthority: IRuntimeAuthorityReference;
34
+ }
35
+
36
+ export const runtimeNetworkProjectionRetirementContract = Object.freeze({
37
+ controllerKind: 'cloudly',
38
+ disposition: 'withdrawn',
39
+ firstAppearance: 'names-the-current-protected-authority',
40
+ latch: 'every-successor-carries-it-unchanged',
41
+ answer: 'null-native-barrier-receipt-successor',
42
+ positive: 'never-again-for-the-retiring-receipt-chain',
43
+ } as const);
44
+
45
+ /** Exact-key structural snapshot, detached from its input. */
46
+ export const snapshotRuntimeNetworkProjectionRetirement = (value: unknown): IRuntimeNetworkProjectionRetirement => networkSafe(() => {
47
+ const item = networkRecord(captureNetwork(value), ['disposition', 'protectedAuthority']);
48
+ requireNetwork(item.disposition === runtimeNetworkProjectionRetirementContract.disposition);
49
+ return { disposition: item.disposition, protectedAuthority: networkReference(item.protectedAuthority) };
50
+ });
51
+
52
+ /**
53
+ * The request belongs to a `cloudly` projection and names the projection's own authority chain at
54
+ * a generation the projection has reached: the current authority itself, or one of its predecessors.
55
+ */
56
+ export const requireProjectionRetirement = (
57
+ retirement: IRuntimeNetworkProjectionRetirement, controllerKind: string, current: IRuntimeNetworkProtectedAuthority,
58
+ ): void => {
59
+ const named = retirement.protectedAuthority;
60
+ requireNetwork(controllerKind === runtimeNetworkProjectionRetirementContract.controllerKind
61
+ && named.id === current.id && named.generation <= current.generation);
62
+ if (named.generation === current.generation) requireNetwork(networkEqual(named, networkReferenceOf(current)));
63
+ };
64
+
65
+ /**
66
+ * The request's first appearance names exactly the projection's current authority. An older
67
+ * generation would make the node's positive receipt under the current authority newer than the
68
+ * request, so the receipt gate refuses it and the withdrawal could never complete.
69
+ */
70
+ export const requireFirstProjectionRetirement = (
71
+ retirement: IRuntimeNetworkProjectionRetirement, current: IRuntimeNetworkProtectedAuthority,
72
+ ): void => requireNetwork(networkEqual(retirement.protectedAuthority, networkReferenceOf(current)));
@@ -3,6 +3,7 @@ import type { TSha256Digest } from './immutableimage.js';
3
3
  import type { IRuntimeAuthorityReference } from './runtimeassignment.js';
4
4
  import type { IRuntimeNetworkNodeScope } from './networkprojection.js';
5
5
  import { snapshotRuntimeNetworkNodeScope } from './networkprojection.js';
6
+ import { snapshotRuntimeNetworkProjectionRetirement } from './networkprojectionretirement.js';
6
7
  import type { IRuntimeNetworkProtectedAuthority } from './networkauthority.js';
7
8
  import { computeRuntimeNetworkProtectedAuthorityDigest, snapshotRuntimeNetworkProtectedAuthority } from './networkauthority.js';
8
9
  import type { IRuntimeSessionBinding } from './runtimesession.js';
@@ -16,7 +17,14 @@ import { captureNetwork, failNetwork, networkCanonical, networkDigest, networkEq
16
17
  * The producer owns a joined native journal covering ALL affected local egress
17
18
  * paths. nativeBarrier identifies that complete journal, never an admission ACK,
18
19
  * a single table inspection, a boot event or a caller-selected success flag.
19
- * A null nativeBarrier explicitly withdraws this node's allocation eligibility.
20
+ *
21
+ * A null nativeBarrier withdraws this node's allocation eligibility. A node sends it
22
+ * only in answer to a signed projection's `retirement` request
23
+ * (`IRuntimeNetworkProjectionRetirement`), and mints no positive receipt for the chain
24
+ * afterwards. The retained native guard outlives the node process, and the boot unit
25
+ * restores it before networking starts, so a clean stop, a crash, a reboot or being
26
+ * offline withdraws nothing: the last receipt stays current until a reconnect binds
27
+ * a new reporter session, whose own first positive receipt restores eligibility.
20
28
  */
21
29
  export interface IRuntimeNetworkProtectionReceipt extends IRuntimeNetworkNodeScope {
22
30
  id: string;
@@ -67,6 +75,7 @@ export const runtimeNetworkProtectionContract = Object.freeze({
67
75
  allocation: 'all-current-affected-egress-receipts-before-reserving-any-lease',
68
76
  receiptSource: 'joined-complete-native-protection-journal',
69
77
  unavailable: 'null-native-barrier-removes-allocation-eligibility',
78
+ withdrawal: 'only-in-answer-to-a-signed-projection-retirement',
70
79
  historical: 'retain-for-outbox-replay-never-current-allocation-eligibility',
71
80
  quarantine: 'no-release-authority',
72
81
  maximumHostAddresses: 32,
@@ -223,3 +232,45 @@ export const bindRuntimeNetworkAllocationProtection = async (
223
232
  return { protection, receipts };
224
233
  } catch { return failNetwork(); }
225
234
  };
235
+
236
+ /**
237
+ * Receiver gate for a protection report from a node whose projection chain carries a retirement
238
+ * request, applied beside `admitRuntimeNetworkProtectionReceipt` with the same `previous`: the
239
+ * receiver's exact current receipt for the node, or null while it holds none. `retirement` is the
240
+ * request the receiver signed into that node's projections.
241
+ *
242
+ * The receipt must belong to the retiring authority chain. A null-barrier receipt is the answer
243
+ * and always passes. A positive receipt passes only while it can predate the node's admission of
244
+ * the request, so a receipt the node minted before it saw the request still drains from its
245
+ * outbox and the withdrawal behind it is never wedged:
246
+ *
247
+ * - it names an authority no newer than the one the request names; a newer one could only come
248
+ * from a projection that already carried the request, since every successor carries it;
249
+ * - its predecessor is not a null withdrawal: once the receiver holds the node's answer, no
250
+ * positive receipt follows it.
251
+ *
252
+ * A positive receipt the node minted after admitting the request against the same authority is
253
+ * indistinguishable from one minted before it; the node's obligation is never to mint one.
254
+ */
255
+ export const bindRuntimeNetworkProtectionReceiptRetirement = async (
256
+ receiptValue: unknown, previousValue: unknown | null, retirementValue: unknown,
257
+ ): Promise<IRuntimeNetworkProtectionReceipt> => {
258
+ try {
259
+ const receipt = snapshotRuntimeNetworkProtectionReceipt(receiptValue);
260
+ const previous = previousValue === null ? null : snapshotRuntimeNetworkProtectionReceipt(previousValue);
261
+ const retirement = snapshotRuntimeNetworkProjectionRetirement(retirementValue);
262
+ requireNetwork(receipt.digest === await computeRuntimeNetworkProtectionReceiptDigest(receipt)
263
+ && receipt.protectedAuthority.id === retirement.protectedAuthority.id);
264
+ if (previous) {
265
+ requireNetwork(previous.digest === await computeRuntimeNetworkProtectionReceiptDigest(previous)
266
+ && scopeKey(previous) === scopeKey(receipt) && networkEqual(previous.controller, receipt.controller)
267
+ && previous.id === receipt.id && (networkEqual(receipt, previous)
268
+ || networkEqual(receipt.previous, networkReferenceOf(previous))));
269
+ }
270
+ if (receipt.nativeBarrier !== null) {
271
+ requireNetwork(receipt.protectedAuthority.generation <= retirement.protectedAuthority.generation
272
+ && (previous === null || previous.nativeBarrier !== null));
273
+ }
274
+ return receipt;
275
+ } catch { return failNetwork(); }
276
+ };
@@ -19,7 +19,9 @@ export const nodeRuntimeRetirementDispositions = Object.freeze(['withdrawn', 'lo
19
19
  * How the node leaves.
20
20
  *
21
21
  * `withdrawn`: the node still runs and takes part. Its slots are removed against its own removal
22
- * receipts, and its Pallet withdraws its protection receipt before its credentials are revoked.
22
+ * receipts, Cloudly asks it through its projection's `retirement` request
23
+ * (`IRuntimeNetworkProjectionRetirement`), and its Pallet answers by withdrawing its protection
24
+ * receipt before its credentials are revoked.
23
25
  *
24
26
  * `lost`: an administrator states that the node will never report again — its host is gone, wiped
25
27
  * or unreachable for good. Its slots are abandoned without removal receipts, which is the one thing