| 1 | package host |
| 2 | |
| 3 | // The moves retirement evidence provider (moves Plan 5 Task 5; rulings K10, |
| 4 | // M7 and M8 of the Plan 5 ledger). The claim's regional worker |
| 5 | // (hostbox.RegionalWorker) sweeps instances/<host>/ and moves/<host>/ at rest. |
| 6 | // Before it sweeps it asks this provider twice: |
| 7 | // |
| 8 | // - VerifyRetirement, once per sweep, for the incarnation label of every |
| 9 | // candidate outside an incarnation directory and for the evidence time of |
| 10 | // every instances/<host> candidate. Decide labels the moves/<host> |
| 11 | // candidate with the same incarnation (park/sweep.go), and the engine's |
| 12 | // proof Check requires the proof to name it, so both answers come from one |
| 13 | // proof. |
| 14 | // - VerifyMovesSweep, per moves/<host> candidate (park/move_sweep.go). The |
| 15 | // engine then stamps the listing's end as that chore's evidence, which is |
| 16 | // valid only with the precondition proof (K10): the claim shows no move |
| 17 | // op, and the newest retired move's close is earlier than the listing. |
| 18 | // |
| 19 | // The proof comes from the record's LastMove (M8, fix round 1 ruling 2): the |
| 20 | // durable move id, outcome and close time, bound to the holder's |
| 21 | // incarnation. It holds only when: |
| 22 | // |
| 23 | // - the claim is serving, so no move op is live on the host; |
| 24 | // - the record is the holder's own and carries no live move; |
| 25 | // - LastMove names a move closed at a real time before this clock; |
| 26 | // - every moves/<host>/<op>/ object is that move's own, or was written |
| 27 | // before its close. |
| 28 | // |
| 29 | // The claim's released_move journal is not required: any later serving op |
| 30 | // replaces it (CompleteMetadataPlacement, for one). With M4's record-first |
| 31 | // close, LastMove is written before the claim's release, so it is the |
| 32 | // serving claim, with no move op, that proves the release finished. |
| 33 | // LastMove.At follows every park of the move's lanes, and so every staging |
| 34 | // horizon. A zero time is never evidence. |
| 35 | // |
| 36 | // VerifyRetirement answers for less than the proof covers (M7; fix round 1 |
| 37 | // ruling 1). The worker binds its one time to every instances/<host> |
| 38 | // candidate, so it refuses unless the close is genuine evidence for the |
| 39 | // residue at the live location: |
| 40 | // |
| 41 | // - at the live location the close covers only generations the move |
| 42 | // retired: those superseded at or before the move's own admission. A |
| 43 | // generation admitted from that admission on was written past the |
| 44 | // close, and ordinary retirement owns it. |
| 45 | // |
| 46 | // Residue outside the holder's live location is not judged here (Q4). The |
| 47 | // claim's census places it: ClaimRetirementCensus labels a qualified path by |
| 48 | // its own identity, and the claim's view refuses unmapped bare history per |
| 49 | // location (productionClaimRegional.View), so a refusal of it here would |
| 50 | // only repeat theirs and would skip the lane sweep as well. |
| 51 | // |
| 52 | // The provider is registered by RegisterProductionMovesParks, whose view |
| 53 | // stays closed until movesWakeSourceRegistered opens; the claim reaches |
| 54 | // VerifyMovesSweep by type assertion (claimassembly.go). |
| 55 | |
| 56 | import ( |
| 57 | "context" |
| 58 | "encoding/hex" |
| 59 | "errors" |
| 60 | "fmt" |
| 61 | "strings" |
| 62 | "time" |
| 63 | |
| 64 | "github.com/carlosframework/platform/internal/hostclaim" |
| 65 | "github.com/carlosframework/platform/internal/hostwrite" |
| 66 | "github.com/carlosframework/platform/internal/lease" |
| 67 | "github.com/carlosframework/platform/internal/park" |
| 68 | "github.com/carlosframework/platform/internal/provision" |
| 69 | ) |
| 70 | |
| 71 | // ErrMovesSweepRefused is what every MovesSweepRefusal unwraps to. |
| 72 | var ErrMovesSweepRefused = errors.New("host: the moves sweep is refused") |
| 73 | |
| 74 | // MovesSweepRefusal is the provider's refusal of one observation. Each one |
| 75 | // skips that pass's sweep, and the next pass asks again. Anomaly marks a |
| 76 | // refusal that no ordinary move produces: the history contradicts itself, or |
| 77 | // something wrote where no known move could. M8 asks for one page per |
| 78 | // anomaly; the provider sends none itself, and its wiring (Task 9a) pages. |
| 79 | // Reason is for the agent's log, never shown to a user. |
| 80 | type MovesSweepRefusal struct { |
| 81 | Host, Reason string |
| 82 | Anomaly bool |
| 83 | } |
| 84 | |
| 85 | func (e *MovesSweepRefusal) Error() string { |
| 86 | return "host: the moves sweep of " + e.Host + " is refused: " + e.Reason |
| 87 | } |
| 88 | |
| 89 | func (e *MovesSweepRefusal) Unwrap() error { return ErrMovesSweepRefused } |
| 90 | |
| 91 | // ErrNotMovesEvidence is what every NotMovesEvidence unwraps to. |
| 92 | var ErrNotMovesEvidence = errors.New("host: the move's close is not evidence for this residue") |
| 93 | |
| 94 | // NotMovesEvidence is VerifyRetirement's refusal of residue at the live |
| 95 | // location that the move did not retire: a generation written past the |
| 96 | // move's close, or an object no generation owns. The close is not its |
| 97 | // evidence, and ordinary retirement owns it. The worker binds one evidence |
| 98 | // time to every instances/<host> candidate, so the provider cannot abstain |
| 99 | // for one generation; this refusal skips the pass's sweep while such |
| 100 | // residue stands. Reason is for the agent's log, never shown to a user. |
| 101 | type NotMovesEvidence struct { |
| 102 | Host, Generation, Reason string |
| 103 | } |
| 104 | |
| 105 | func (e *NotMovesEvidence) Error() string { |
| 106 | return "host: the move's close is not evidence for " + e.Host + " generation " + e.Generation + ": " + e.Reason |
| 107 | } |
| 108 | |
| 109 | func (e *NotMovesEvidence) Unwrap() error { return ErrNotMovesEvidence } |
| 110 | |
| 111 | // movesRetirementEvidence implements hostbox.RegionalEvidence and |
| 112 | // park.MoveSweepVerifier over the host's provisioning record. |
| 113 | type movesRetirementEvidence struct { |
| 114 | Records MovesRecordReader |
| 115 | Now func() time.Time |
| 116 | } |
| 117 | |
| 118 | // movesRetired is the proof both methods answer from. |
| 119 | type movesRetired struct { |
| 120 | host, incarnation, location, moveID string |
| 121 | at time.Time |
| 122 | holder hostclaim.Holder |
| 123 | } |
| 124 | |
| 125 | func (e movesRetirementEvidence) VerifyMovesSweep(ctx context.Context, o hostclaim.Observed, versions []lease.ObjectVersion) (park.MoveSweepProof, error) { |
| 126 | r, err := e.retired(ctx, o, versions) |
| 127 | if err != nil { |
| 128 | return park.MoveSweepProof{}, err |
| 129 | } |
| 130 | return park.MoveSweepProof{Host: r.host, ClaimETag: o.ETag, MoveID: r.moveID, Incarnation: r.incarnation, RetiredAt: r.at}, nil |
| 131 | } |
| 132 | |
| 133 | func (e movesRetirementEvidence) VerifyRetirement(ctx context.Context, o hostclaim.Observed, versions []lease.ObjectVersion) (string, time.Time, error) { |
| 134 | r, err := e.retired(ctx, o, versions) |
| 135 | if err != nil { |
| 136 | return "", time.Time{}, err |
| 137 | } |
| 138 | if err := r.retiredGenerations(versions); err != nil { |
| 139 | return "", time.Time{}, err |
| 140 | } |
| 141 | return r.incarnation, r.at, nil |
| 142 | } |
| 143 | |
| 144 | // retiredGenerations refuses unless the close is evidence for every object |
| 145 | // at the live location that the engine could make a candidate (fix round 1, |
| 146 | // ruling 1). The marker key and the holder's current generation are the |
| 147 | // live installation's. Any other generation must be one the move retired: |
| 148 | // the claim must still hold the move's own admission (a prepared landing |
| 149 | // admits the generation named after the move, movesprobeowners.go), the |
| 150 | // generation must not be admitted at or after it (admissions are in claim |
| 151 | // order and carry no time), and every version of it must predate the close. |
| 152 | // A rolled-back or aborted move, or a landing that admitted a fresh name, |
| 153 | // leaves no such admission, so no generation is vouched for. Objects are |
| 154 | // classified as the landing's view classifies them (landingGenerationOf), |
| 155 | // after the lock objects the engine itself skips (movesParkLock, fix round |
| 156 | // 2): the writer's lease stands at every live location. The build state |
| 157 | // beside it (movesHolderBuildState, ruling R-PARK) is the location's own, |
| 158 | // the marker's twin, and needs no vouching either. |
| 159 | func (r movesRetired) retiredGenerations(versions []lease.ObjectVersion) error { |
| 160 | h := r.holder |
| 161 | refuse := func(gen, format string, args ...any) error { |
| 162 | return &NotMovesEvidence{Host: r.host, Generation: gen, Reason: fmt.Sprintf(format, args...)} |
| 163 | } |
| 164 | moveAdmission := -1 |
| 165 | for i := len(h.Admissions) - 1; i >= 0; i-- { |
| 166 | if h.Admissions[i].Generation == r.moveID { |
| 167 | moveAdmission = i |
| 168 | break |
| 169 | } |
| 170 | } |
| 171 | afterTheMove := map[string]bool{} |
| 172 | if moveAdmission >= 0 { |
| 173 | for _, a := range h.Admissions[moveAdmission:] { |
| 174 | afterTheMove[a.Generation] = true |
| 175 | } |
| 176 | } |
| 177 | for _, v := range versions { |
| 178 | if loc, ok := movesInstancesLocation(r.host, v.Key); !ok || loc != r.location || movesParkLock(r.host, v.Key) || movesHolderBuildState(r.location, v.Key) { |
| 179 | continue |
| 180 | } |
| 181 | gen, ok := landingGenerationOf(r.location, v.Key) |
| 182 | switch { |
| 183 | case !ok: |
| 184 | return refuse("", "%s belongs to no generation", v.Key) |
| 185 | case gen == landingMarkerGeneration || gen == h.Generation: |
| 186 | continue |
| 187 | case moveAdmission < 0: |
| 188 | return refuse(gen, "the claim holds no admission of %s to date it by", r.moveID) |
| 189 | case afterTheMove[gen]: |
| 190 | return refuse(gen, "it was admitted after %s's admission, so written past the close", r.moveID) |
| 191 | case v.LastModified.IsZero() || !v.LastModified.Before(r.at): |
| 192 | return refuse(gen, "%s was written after %s closed", v.Key, r.moveID) |
| 193 | } |
| 194 | } |
| 195 | return nil |
| 196 | } |
| 197 | |
| 198 | // movesInstancesLocation is the location park.Decide files an |
| 199 | // instances/<host>/ key under (park's locationOf): a 64-hex first segment |
| 200 | // with a child is its own incarnation location, and everything else is the |
| 201 | // bare instances/<host>. ok is false outside instances/<host>/. |
| 202 | func movesInstancesLocation(host, key string) (string, bool) { |
| 203 | base := "instances/" + host |
| 204 | tail, ok := strings.CutPrefix(key, base+"/") |
| 205 | if !ok { |
| 206 | return "", false |
| 207 | } |
| 208 | first, _, child := strings.Cut(tail, "/") |
| 209 | if _, err := hex.DecodeString(first); child && err == nil && len(first) == 64 { |
| 210 | return base + "/" + first, true |
| 211 | } |
| 212 | return base, true |
| 213 | } |
| 214 | |
| 215 | // retired proves the newest retired move from the claim observation, the |
| 216 | // holder's record and the complete census, or refuses. |
| 217 | func (e movesRetirementEvidence) retired(ctx context.Context, o hostclaim.Observed, versions []lease.ObjectVersion) (movesRetired, error) { |
| 218 | if ctx == nil || e.Records == nil || e.Now == nil { |
| 219 | return movesRetired{}, hostwrite.ErrUnavailable |
| 220 | } |
| 221 | if o.Absent || o.Claim == nil || o.ETag == "" || o.Host == "" || o.Claim.Host != o.Host { |
| 222 | return movesRetired{}, hostwrite.ErrUnavailable |
| 223 | } |
| 224 | c, host := o.Claim, o.Host |
| 225 | refuse := func(anomaly bool, format string, args ...any) (movesRetired, error) { |
| 226 | return movesRetired{}, &MovesSweepRefusal{Host: host, Reason: fmt.Sprintf(format, args...), Anomaly: anomaly} |
| 227 | } |
| 228 | if c.Holder == nil { |
| 229 | // A free or joining claim names no record, so no move history. A free |
| 230 | // claim's lanes are the claim's own proof (claimFreeMoveProof), which |
| 231 | // the claim asks first; a joining parking claim reaches here. It is an |
| 232 | // ordinary state, so it is a refusal, quiet in the agent's log, and |
| 233 | // not an anomaly (Q4). Once the claim has a holder again the next |
| 234 | // pass proves the move. |
| 235 | return refuse(false, "the claim has no holder, so no record to prove a closed move from") |
| 236 | } |
| 237 | if c.Op.Kind != hostclaim.KindServing { |
| 238 | return refuse(false, "the claim is under a %s op", c.Op.Kind) |
| 239 | } |
| 240 | h := c.Holder |
| 241 | if h.Family != "instance" || h.Key != provision.RecordKey(h.Account, h.App, host) { |
| 242 | return refuse(false, "the holder is not an instance record") |
| 243 | } |
| 244 | // A moved holder is at the bare location, the activator's own lease |
| 245 | // (ruling T5-lease, movescut.go writerLease); a scoped holder is at its |
| 246 | // incarnation's. Anything else is a location the census cannot place. |
| 247 | location := strings.TrimSuffix(h.Location, "/") |
| 248 | if location != "instances/"+host && location != "instances/"+host+"/"+h.Incarnation { |
| 249 | return refuse(true, "the holder is at %s, neither the bare nor the incarnation location", h.Location) |
| 250 | } |
| 251 | rec, err := e.Records(ctx, h.Account, h.App, host) |
| 252 | if err != nil { |
| 253 | return movesRetired{}, err |
| 254 | } |
| 255 | if rec == nil { |
| 256 | return refuse(true, "the holder's record is missing") |
| 257 | } |
| 258 | inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt) |
| 259 | if err != nil || rec.Account != h.Account || rec.App != h.App || rec.Host != host || inc != h.Incarnation { |
| 260 | return refuse(true, "the record is not the holder's incarnation") |
| 261 | } |
| 262 | if rec.Move != nil { |
| 263 | return refuse(false, "the record carries the live move %s", rec.Move.ID) |
| 264 | } |
| 265 | last := rec.LastMove |
| 266 | if last == nil || last.ID == "" { |
| 267 | return refuse(false, "the record has closed no move") |
| 268 | } |
| 269 | if last.At.IsZero() || !last.At.Before(e.Now()) { |
| 270 | return refuse(true, "the close of %s has no time before now", last.ID) |
| 271 | } |
| 272 | movesRoot := "moves/" + host + "/" |
| 273 | for _, v := range versions { |
| 274 | if strings.HasPrefix(v.Key, "instances/"+host+"/") { |
| 275 | continue |
| 276 | } |
| 277 | tail, ok := strings.CutPrefix(v.Key, movesRoot) |
| 278 | if !ok { |
| 279 | return refuse(true, "the census holds %s, outside this host", v.Key) |
| 280 | } |
| 281 | if movesParkLock(host, v.Key) { |
| 282 | continue // the moves/<host> park's own lease |
| 283 | } |
| 284 | op, _, inLane := strings.Cut(tail, "/") |
| 285 | if !inLane { |
| 286 | return refuse(true, "%s is outside every lane", v.Key) |
| 287 | } |
| 288 | if op == last.ID { |
| 289 | continue |
| 290 | } |
| 291 | if v.LastModified.IsZero() || !v.LastModified.Before(last.At) { |
| 292 | return refuse(true, "lane %s was written after %s closed", op, last.ID) |
| 293 | } |
| 294 | } |
| 295 | return movesRetired{host: host, incarnation: h.Incarnation, location: location, moveID: last.ID, at: last.At.UTC(), holder: *h}, nil |
| 296 | } |
| 297 | |
| 298 | // movesParkLock mirrors the park engine's lock predicate (park's isLock over |
| 299 | // locationOf, both unexported): a key is a lock exactly when it is the |
| 300 | // lock.json at the root of its own park location, instances/<host>, |
| 301 | // instances/<host>/<incarnation> or moves/<host>. That is the writer's |
| 302 | // lease at a holder location (lease.Acquire) and a park's own lease. |
| 303 | // Decide never makes it a candidate, so the close need not vouch for it. |
| 304 | // TestMovesParkLockMatchesTheEngine keeps the two in step through |
| 305 | // park.Decide. |
| 306 | func movesParkLock(host, key string) bool { |
| 307 | loc, ok := movesInstancesLocation(host, key) |
| 308 | if !ok { |
| 309 | if !strings.HasPrefix(key, "moves/"+host+"/") { |
| 310 | return false |
| 311 | } |
| 312 | loc = "moves/" + host |
| 313 | } |
| 314 | return key == loc+"/lock.json" |
| 315 | } |
| 316 | |