| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "strconv" |
| 7 | |
| 8 | "github.com/carlosframework/platform/internal/hostactors" |
| 9 | "github.com/carlosframework/platform/internal/hostbox" |
| 10 | "github.com/carlosframework/platform/internal/hostclaim" |
| 11 | "github.com/carlosframework/platform/internal/hostguard" |
| 12 | "github.com/carlosframework/platform/internal/moves" |
| 13 | "github.com/carlosframework/platform/internal/registry" |
| 14 | "github.com/carlosframework/platform/internal/release" |
| 15 | ) |
| 16 | |
| 17 | // claimAdmissionHandover is the wake's AfterAdmission hook. A landing |
| 18 | // target's first admission, whose predecessor writer is the move's source on |
| 19 | // another box, goes to crossBox when the moves C1 integration bound one. Every |
| 20 | // other admission keeps main's local cold handover, which needs this box's own |
| 21 | // completed fence receipt for the predecessor session. |
| 22 | func claimAdmissionHandover(box string, local, crossBox func(context.Context, hostclaim.Observed, hostclaim.Observed) error) func(context.Context, hostclaim.Observed, hostclaim.Observed) error { |
| 23 | return func(ctx context.Context, prior, admitted hostclaim.Observed) error { |
| 24 | if crossBox != nil && claimRemotePredecessor(prior, box) { |
| 25 | return crossBox(ctx, prior, admitted) |
| 26 | } |
| 27 | return local(ctx, prior, admitted) |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | // claimCrossBoxHandover authorizes a landing target's first cold admission |
| 32 | // from the sealed cut and the source's stop evidence instead of a local fence |
| 33 | // receipt (claim Plan 9 D2, moves ruling H2). The target never ran the |
| 34 | // source's session, so it can never hold that session's fence receipt. |
| 35 | // |
| 36 | // Authority: op.exec moves only by a CAS citing stop evidence (claim §4, |
| 37 | // §6.4a "AdmitWriter"; moves K3, K4). The flip handed op.exec to this box |
| 38 | // citing stopped-source.json (moves §7.5); the cut journalled the stopped |
| 39 | // session and the seal (moves §7.4, K13). The admission's coverage is sealed |
| 40 | // exactly when the seal was built from the stopped database (claim §6.4a |
| 41 | // "Admissions, coverage and retirement" item 2). The first probe keeps the |
| 42 | // installation stamp (item 4), so the local record this box initialized for |
| 43 | // the landing installation is the one the session runs under. |
| 44 | type claimCrossBoxHandover struct { |
| 45 | RecoveryRebind func(context.Context, recoveryRebindRequest) error |
| 46 | Launches *hostbox.LaunchJournal |
| 47 | afterRecoveryRows func() error |
| 48 | PublicKey ed25519.PublicKey |
| 49 | FenceAuthority hostclaim.FenceAuthority |
| 50 | Directory hostactors.Directory |
| 51 | Guard hostguard.Guard |
| 52 | Evidence release.Store |
| 53 | Inventory *hostbox.StopInventoryDB |
| 54 | Registry *registry.Registry |
| 55 | Identity hostbox.IdentityProvider |
| 56 | Box string |
| 57 | } |
| 58 | |
| 59 | func (h claimCrossBoxHandover) After(ctx context.Context, prior, admitted hostclaim.Observed) error { |
| 60 | if claimRecoveryObservation(prior) { |
| 61 | return h.afterRecovery(ctx, prior, admitted) |
| 62 | } |
| 63 | if h.Evidence == nil || h.Box == "" || prior.Claim == nil || prior.Claim.Holder == nil || admitted.Claim == nil || admitted.Claim.Holder == nil || prior.Host != admitted.Host || prior.Absent || admitted.Absent { |
| 64 | return hostbox.ErrChanged |
| 65 | } |
| 66 | seal, err := h.sourceEvidence(ctx, prior) |
| 67 | if err != nil { |
| 68 | return err |
| 69 | } |
| 70 | op, old := prior.Claim.Op, prior.Claim.Holder |
| 71 | source := op.Intent["source"] |
| 72 | next, ac := admitted.Claim.Holder, admitted.Claim |
| 73 | if ac.Op.ID != op.ID || ac.Op.Kind != op.Kind || ac.Op.Phase != op.Phase || ac.Op.Exec != h.Box || |
| 74 | next.Incarnation != old.Incarnation || next.Stamp != old.Stamp || next.Session <= old.Session || next.WriterBox != h.Box || len(next.Admissions) == 0 { |
| 75 | return hostbox.ErrChanged |
| 76 | } |
| 77 | predecessor := hostclaim.AdmissionRecord{Session: old.Session, Box: old.WriterBox, Generation: old.Generation} |
| 78 | if len(old.Admissions) > 0 { |
| 79 | predecessor = old.Admissions[len(old.Admissions)-1] |
| 80 | } |
| 81 | entry := next.Admissions[len(next.Admissions)-1] |
| 82 | if predecessor.Session != old.Session || predecessor.Box != source || entry.Session != next.Session || entry.Box != h.Box || |
| 83 | entry.Mode != "cold" || entry.Generation != next.Generation || entry.Predecessor != predecessor.Generation { |
| 84 | return hostbox.ErrChanged |
| 85 | } |
| 86 | if (entry.Coverage != "sealed" && entry.Coverage != "") || (entry.Coverage == "sealed") != seal.FromDB { |
| 87 | return hostbox.ErrChanged |
| 88 | } |
| 89 | // The kept stamp is this box's active landing installation, and the |
| 90 | // admitted session is still the claim's under the guard. |
| 91 | return h.Guard.With(ctx, admitted.Host, hostguard.Captured{Incarnation: next.Incarnation, Stamp: next.Stamp}, func(c *hostclaim.Claim) error { |
| 92 | if c.Holder.Session != next.Session || c.Holder.Generation != next.Generation || c.Holder.WriterBox != h.Box || c.Op.ID != op.ID || c.Op.Kind != op.Kind || c.Op.Exec != h.Box { |
| 93 | return hostbox.ErrChanged |
| 94 | } |
| 95 | return nil |
| 96 | }, func() error { return nil }) |
| 97 | } |
| 98 | |
| 99 | func (h claimCrossBoxHandover) sourceEvidence(ctx context.Context, prior hostclaim.Observed) (moves.Seal, error) { |
| 100 | if h.Evidence == nil || h.Box == "" || prior.Absent || prior.Claim == nil || prior.Claim.Holder == nil { |
| 101 | return moves.Seal{}, hostbox.ErrChanged |
| 102 | } |
| 103 | op, old := prior.Claim.Op, prior.Claim.Holder |
| 104 | source := op.Intent["source"] |
| 105 | if op.Kind != hostclaim.KindLanding || op.ID == "" || op.Phase != hostclaim.PhaseLandingProbing || op.Exec != h.Box || |
| 106 | len(op.Participants) != 2 || op.Participants[0] != source || op.Participants[1] != h.Box || op.Intent["target"] != h.Box || |
| 107 | source == "" || source == h.Box || old.WriterBox != source { |
| 108 | return moves.Seal{}, hostbox.ErrChanged |
| 109 | } |
| 110 | cut, err := strconv.ParseUint(op.Journal[hostclaim.JournalCutSession], 10, 64) |
| 111 | if err != nil || cut != old.Session { |
| 112 | return moves.Seal{}, hostbox.ErrChanged |
| 113 | } |
| 114 | seal, err := moves.DecodeSeal(op.Journal[hostclaim.JournalSeal]) |
| 115 | if err != nil || !seal.HasFiles() { |
| 116 | return moves.Seal{}, hostbox.ErrChanged |
| 117 | } |
| 118 | key := op.Journal[hostclaim.JournalEvidenceSource] |
| 119 | if key == "" { |
| 120 | return moves.Seal{}, hostbox.ErrChanged |
| 121 | } |
| 122 | report, err := readStoppedReport(ctx, h.Evidence, key) |
| 123 | if err != nil { |
| 124 | return moves.Seal{}, err |
| 125 | } |
| 126 | if report.Role != "source" || report.Box != source || report.Host != prior.Host || report.OpID != op.ID || report.Incarnation != old.Incarnation || report.FinalTXID != seal.N { |
| 127 | return moves.Seal{}, hostbox.ErrChanged |
| 128 | } |
| 129 | return seal, nil |
| 130 | } |
| 131 | |
| 132 | func (h claimCrossBoxHandover) Before(ctx context.Context, prior hostclaim.Observed) error { |
| 133 | if claimRecoveryObservation(prior) { |
| 134 | return h.beforeRecovery(ctx, prior) |
| 135 | } |
| 136 | if _, err := h.sourceEvidence(ctx, prior); err != nil { |
| 137 | return err |
| 138 | } |
| 139 | old := prior.Claim.Holder |
| 140 | return h.Guard.With(ctx, prior.Host, hostguard.Captured{Incarnation: old.Incarnation, Stamp: old.Stamp}, func(c *hostclaim.Claim) error { |
| 141 | if c.Op.ID != prior.Claim.Op.ID || c.Op.Kind != hostclaim.KindLanding || c.Op.Phase != hostclaim.PhaseLandingProbing || c.Op.Exec != h.Box || c.Holder.WriterBox != old.WriterBox || c.Holder.Session != old.Session || c.Holder.Generation != old.Generation { |
| 142 | return hostbox.ErrChanged |
| 143 | } |
| 144 | return nil |
| 145 | }, func() error { return nil }) |
| 146 | } |
| 147 | |
| 148 | // Cross-box source evidence never substitutes this target's controller tuple |
| 149 | // for a predecessor that ran on another box. All other shapes keep Stop. |
| 150 | func claimPredecessorStop(w *ClaimWakeAssembly, box string, crossBox func(context.Context, hostclaim.Observed) error) func(context.Context, hostclaim.Observed) error { |
| 151 | return func(ctx context.Context, prior hostclaim.Observed) error { |
| 152 | if crossBox != nil && claimRemotePredecessor(prior, box) { |
| 153 | return crossBox(ctx, prior) |
| 154 | } |
| 155 | return w.Stop(ctx, prior) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | // wireClaimAdmission routes the source proof to its operation owner before |
| 160 | // lease acquisition and checks the target installation after admission. |
| 161 | // With completed set, region HA's N6 handover goes in front of it |
| 162 | // (wireCompletedHandover). |
| 163 | func wireClaimAdmission(w *ClaimWakeAssembly, box string, local func(context.Context, hostclaim.Observed, hostclaim.Observed) error, landing claimCrossBoxHandover, relocation *ClaimRelocationOwners, completed *relocationCompletedHandover) { |
| 164 | before := landing.Before |
| 165 | after := landing.After |
| 166 | if relocation != nil { |
| 167 | before, after = relocationBefore(landing, relocation, box), relocationAfter(landing, relocation, box) |
| 168 | } |
| 169 | w.recoveryIntent = landing.prepareRecovery |
| 170 | w.recoveryPrior = landing.pendingRecovery |
| 171 | w.recoveryPrelaunched = landing.completeRecovery |
| 172 | w.BeforeAdmission = claimPredecessorStop(w, box, before) |
| 173 | w.AfterAdmission = claimAdmissionHandover(box, func(ctx context.Context, prior, admitted hostclaim.Observed) error { |
| 174 | if claimInitialSourceRecovery(prior, box) { |
| 175 | return landing.afterRecovery(ctx, prior, admitted) |
| 176 | } |
| 177 | return local(ctx, prior, admitted) |
| 178 | }, after) |
| 179 | if completed != nil { |
| 180 | wireCompletedHandover(w, completed) |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | // wireCompletedHandover is region HA N6 in front of whatever admission |
| 185 | // hooks w already has: the first serving wake after a relocation completed |
| 186 | // on this box hands over from the probe's existing receipt; every other |
| 187 | // prior keeps those hooks unchanged (with no BeforeAdmission, main's |
| 188 | // Wake.Stop). It is wired whenever claim mode is on, not only with region |
| 189 | // HA on (ruling 82's reasoning, Task 9 review): it serves a relocation |
| 190 | // that has already completed, and without a completion record it does |
| 191 | // nothing. A serving prior on this box whose completion record cannot be |
| 192 | // read is refused, never routed to main's stop (absence is never |
| 193 | // evidence): that stop would replace the probe's fence. |
| 194 | func wireCompletedHandover(w *ClaimWakeAssembly, completed *relocationCompletedHandover) { |
| 195 | before, after := w.BeforeAdmission, w.AfterAdmission |
| 196 | completedAfter := completed.After() |
| 197 | w.BeforeAdmission = func(ctx context.Context, prior hostclaim.Observed) error { |
| 198 | if _, ok, err := completed.completedPrior(prior); err != nil { |
| 199 | return err |
| 200 | } else if ok { |
| 201 | return completed.Before(ctx, prior) |
| 202 | } |
| 203 | if before != nil { |
| 204 | return before(ctx, prior) |
| 205 | } |
| 206 | return w.Stop(ctx, prior) |
| 207 | } |
| 208 | w.AfterAdmission = func(ctx context.Context, prior, admitted hostclaim.Observed) error { |
| 209 | if _, ok, err := completed.completedPrior(prior); err != nil { |
| 210 | return err |
| 211 | } else if ok { |
| 212 | return completedAfter(ctx, prior, admitted) |
| 213 | } |
| 214 | if after == nil { |
| 215 | return hostbox.ErrChanged |
| 216 | } |
| 217 | return after(ctx, prior, admitted) |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | // wireProductionAdmission is OpenProductionClaimAssembly's admission |
| 222 | // wiring: with moves C1 on, wireClaimAdmission over the cross-box landing |
| 223 | // handover and, with region HA's owners bound, the relocating routing; |
| 224 | // with it off, main's hooks as they are. N6 goes in front in both, so a |
| 225 | // relocation completed while region HA was on is handed over from its |
| 226 | // probe's receipt at the first serving wake after region HA is turned off. |
| 227 | func wireProductionAdmission(w *ClaimWakeAssembly, box string, movesC1 bool, landing claimCrossBoxHandover, relocation *ClaimRelocationOwners, completed *relocationCompletedHandover) { |
| 228 | if movesC1 { |
| 229 | wireClaimAdmission(w, box, w.AfterAdmission, landing, relocation, completed) |
| 230 | return |
| 231 | } |
| 232 | wireCompletedHandover(w, completed) |
| 233 | } |
| 234 | |
| 235 | func claimRemotePredecessor(prior hostclaim.Observed, box string) bool { |
| 236 | if prior.Claim == nil || prior.Claim.Holder == nil || prior.Claim.Holder.WriterBox == box { |
| 237 | return false |
| 238 | } |
| 239 | return (prior.Claim.Op.Kind == hostclaim.KindRestoring && prior.Claim.Holder.WriterBox != "" && prior.Claim.Holder.WriterBox == prior.Claim.Op.Intent["target"]) || prior.Claim.Op.Kind == hostclaim.KindRelocating || (prior.Claim.Op.Kind == hostclaim.KindLanding && prior.Claim.Holder.WriterBox != "") |
| 240 | } |
| 241 | |
| 242 | // relocationBefore and relocationAfter are wireClaimAdmission's relocating |
| 243 | // routing (Plan 11), unchanged, named so the completed-relocation branch |
| 244 | // (N6) can fall through to them. |
| 245 | func relocationBefore(landing claimCrossBoxHandover, relocation *ClaimRelocationOwners, box string) func(context.Context, hostclaim.Observed) error { |
| 246 | return func(ctx context.Context, prior hostclaim.Observed) error { |
| 247 | if prior.Claim.Op.Kind != hostclaim.KindRelocating { |
| 248 | return landing.Before(ctx, prior) |
| 249 | } |
| 250 | if relocation.BeforeAdmission == nil || prior.Claim.Op.Exec != box || prior.Claim.Op.Phase != "probing" { |
| 251 | return hostbox.ErrChanged |
| 252 | } |
| 253 | return relocation.BeforeAdmission(ctx, prior) |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | func relocationAfter(landing claimCrossBoxHandover, relocation *ClaimRelocationOwners, box string) func(context.Context, hostclaim.Observed, hostclaim.Observed) error { |
| 258 | return func(ctx context.Context, prior, admitted hostclaim.Observed) error { |
| 259 | if prior.Claim.Op.Kind != hostclaim.KindRelocating { |
| 260 | return landing.After(ctx, prior, admitted) |
| 261 | } |
| 262 | if relocation.AfterAdmission == nil || prior.Claim.Op.Exec != box || prior.Claim.Op.Phase != "probing" { |
| 263 | return hostbox.ErrChanged |
| 264 | } |
| 265 | return relocation.AfterAdmission(ctx, prior, admitted) |
| 266 | } |
| 267 | } |
| 268 | |