| 1 | # Region HA contracts for the claim and moves sessions |
| 2 | |
| 3 | Branch `region-ha-contracts`, 2026-10-07. Three contracts that the claim |
| 4 | session (L13) and the moves session (L15, L17 in |
| 5 | `2026-10-06-moves-region-ha-conflicts.md`) asked region HA for. All three |
| 6 | are dormant: no production path calls them yet. |
| 7 | |
| 8 | ## 1. Origin-qualified object references (claim L13) |
| 9 | |
| 10 | Package `internal/hostfiles`. |
| 11 | |
| 12 | The manifest format is now version 2 (`hostfiles.ManifestVersion = 2`). |
| 13 | `Entry.Object` is the object's full key from the bucket root: |
| 14 | |
| 15 | instances/<host>/<inc>/files/<gen>/objects/<16-digit seq>-<64 hex sha256> |
| 16 | |
| 17 | The publisher writes it under its own prefix. Readers fetch exactly that |
| 18 | key and never join it to their own prefix. Version 1 held a key relative |
| 19 | to the manifest's prefix. The field's meaning changed, so the version was |
| 20 | bumped. Any manifest that is not version 2 (including 1) is refused by |
| 21 | `Load`, `Open`, `Restore` and `CopyManifest` with "manifest <prefix> is |
| 22 | version N; this binary reads 2". No version 1 manifest exists in |
| 23 | production, so nothing needs migrating. |
| 24 | |
| 25 | Signatures: |
| 26 | |
| 27 | ```go |
| 28 | func Restore(ctx context.Context, s release.Store, prefix, dir string, origins []string) (Manifest, RestoreStats, error) |
| 29 | func ReadPlatform(ctx context.Context, s release.Store, prefix string, m Manifest, name string, origins []string) ([]byte, error) |
| 30 | func LoadWake(ctx context.Context, s release.Store, prefix string, m Manifest, origins []string) (WakeState, bool, error) |
| 31 | func CopyManifest(ctx context.Context, s release.Store, fromPrefix, toPrefix string, expectSeq int64, origins []string) (Manifest, error) |
| 32 | var ErrOrigin = errors.New("hostfiles: object origin not admitted") |
| 33 | var ErrManifestExists = errors.New("hostfiles: the target generation already has a manifest") |
| 34 | var ErrSourceMoved = errors.New("hostfiles: the source manifest is not at the expected seq") |
| 35 | ``` |
| 36 | |
| 37 | `prefix` must be a clean relative bucket key ending in `/`. Restore, |
| 38 | ReadPlatform (and so LoadWake), Open and CopyManifest refuse any other |
| 39 | prefix before reading anything. |
| 40 | |
| 41 | `origins` is the set of generation prefixes that the caller's pin admits. |
| 42 | nil or empty means the manifest's own prefix only. The set is used exactly |
| 43 | as passed: a caller that passes a set must include the manifest's own |
| 44 | prefix if it wants objects published there. Each origin must be a clean |
| 45 | relative bucket key ending in `/`. If one is not, the call fails before |
| 46 | anything is read. |
| 47 | |
| 48 | What Restore guarantees: it validates every path and every published |
| 49 | entry before it touches `dir`. If any entry fails, Restore writes and |
| 50 | removes nothing. An entry's object must: |
| 51 | |
| 52 | - be a clean bucket key: relative, no `..`, no `.`, no `//`, no NUL; |
| 53 | - be exactly `<origin>objects/<%016d Seq>-<SHA256>`, using that entry's |
| 54 | own Seq and SHA256, where origin is one of the admitted origins. A key |
| 55 | nested deeper under an origin, a key under another host's or another |
| 56 | generation's prefix, a relative key in the version 1 style, an absolute |
| 57 | key, or a wrong seq or sha is refused. If the key is well formed but its |
| 58 | origin is not admitted, the error wraps `ErrOrigin`; |
| 59 | - hash and size-check as before (`checkEntry`, then `fetch`). |
| 60 | |
| 61 | ReadPlatform and LoadWake apply the same rules to platform items. |
| 62 | |
| 63 | CopyManifest reads `fromPrefix`'s manifest and validates every entry |
| 64 | against `origins` (nil means `fromPrefix` alone). A source built by an |
| 65 | earlier copy references earlier generations, so its caller passes the |
| 66 | whole chain. CopyManifest then writes the same manifest (entries and Seq), |
| 67 | with the same qualified references, to `toPrefix`'s manifest. The write is |
| 68 | create-only. No objects are copied. |
| 69 | |
| 70 | `expectSeq` is the source revision the caller's pin admitted. When it is |
| 71 | not 0 and the source is at any other Seq, the call returns |
| 72 | `ErrSourceMoved` and writes nothing, on a first attempt or a retry. 0 |
| 73 | copies whatever the source holds. |
| 74 | |
| 75 | Errors: |
| 76 | |
| 77 | - an absent source is `os.ErrNotExist`, never an empty copy; |
| 78 | - a source not at a non-zero `expectSeq` is `ErrSourceMoved`; |
| 79 | - a target already holding exactly the manifest this call would write is |
| 80 | success, so a retry is safe; |
| 81 | - any other target is `ErrManifestExists`, and the target is left as it |
| 82 | was; |
| 83 | - a source equal to the target is refused. |
| 84 | |
| 85 | Retry-safety requires a source that cannot commit between attempts: the |
| 86 | source generation must be fenced or frozen. The claim's admission provides |
| 87 | this. Against a live source, a retry can find the target holding an older |
| 88 | revision. It then reports `ErrManifestExists`, or `ErrSourceMoved` when |
| 89 | `expectSeq` is set. It never reports success for a revision the caller |
| 90 | did not admit. |
| 91 | |
| 92 | The new generation's `Open(toPrefix)` continues the sequence from the |
| 93 | copy. An unchanged file keeps its old-generation reference. A changed file |
| 94 | becomes a new object under `toPrefix`. |
| 95 | |
| 96 | For the claim's live set: the objects a manifest keeps alive are exactly |
| 97 | the `Object` keys of its entries and platform items that are neither |
| 98 | deleted nor skipped. Those keys may lie under earlier generations' |
| 99 | prefixes. |
| 100 | |
| 101 | ## 2. Files publish entry point (moves L17) |
| 102 | |
| 103 | Moves asked for `files.Publish(ctx, store, location, generation, dir, |
| 104 | envPath) (manifestVersion string, err error)`. The closest stable call is: |
| 105 | |
| 106 | ```go |
| 107 | func Publish(ctx context.Context, s release.Store, prefix, dir string, skip func(rel string, isDir bool) bool) (int64, SyncStats, error) |
| 108 | ``` |
| 109 | |
| 110 | - `prefix` is the generation's files prefix, `<location>files/<generation>/`. |
| 111 | The caller builds it. |
| 112 | - It runs one `Open`, one `SyncDir` and one `Commit`, and returns the |
| 113 | committed manifest sequence number. That number is the manifest version. |
| 114 | - On success, every regular file in `dir` that `skip` does not exclude is |
| 115 | in the manifest. A file larger than `MaxFileBytes` is recorded as |
| 116 | skipped and listed in `SyncStats.TooLarge`. Anything that is not a |
| 117 | regular file is left out and listed in `SyncStats.NotRegular`. |
| 118 | - Publish is an install-time contract: it commits a complete and |
| 119 | consistent image or nothing. It returns an error and commits nothing |
| 120 | when any file could not be read (`SyncStats.Unreadable`), was replaced |
| 121 | between the look and the open (`SyncStats.Retry`), or changed while it |
| 122 | was read (`SyncStats.Torn`). The caller stops writers and calls Publish |
| 123 | again. Objects uploaded by a refused attempt are referenced by no |
| 124 | manifest. |
| 125 | - A prefix that already has a manifest is continued, not replaced. A |
| 126 | repeat with nothing changed returns the same sequence number. If another |
| 127 | writer's manifest appears between Open and Commit, the error is |
| 128 | `ErrSuperseded`. |
| 129 | - The env file is not part of this call. A caller that wants platform |
| 130 | items in the same revision uses `Open`, `StagePlatform`, `SyncDir` and |
| 131 | `Commit` directly. |
| 132 | |
| 133 | ## 3. Eligible-boxes read (moves L15) |
| 134 | |
| 135 | New package `internal/boxchoice`. Console's unexported `eligibleBoxes` and |
| 136 | `chooseBox` are now thin adapters over it, and their behaviour is |
| 137 | unchanged. The predicate and choice tests moved to the new package. |
| 138 | Console keeps tests that check its snapshot conversion carries every |
| 139 | field. |
| 140 | |
| 141 | ```go |
| 142 | const Capability = "region-ha-1" |
| 143 | type Headroom struct{ MemFreeMiB, DiskFreeMiB int64; Awake int } |
| 144 | type Box struct { |
| 145 | Box, BoxID, EIP, Region, Placement string |
| 146 | HibernationConfigured, ServesSharedPool, AcceptsInbound *bool |
| 147 | SecretsKeyFP string |
| 148 | Capabilities []string |
| 149 | Draining bool |
| 150 | Headroom *Headroom |
| 151 | Fresh bool // not on the wire: the caller's liveness verdict |
| 152 | } |
| 153 | type Snapshot struct{ Boxes []Box; Complete bool } |
| 154 | type RosterBox struct{ Placement string; Cloud boxid.Cloud } |
| 155 | type Candidate struct{ BoxID, Region string; MemFreeMiB, DiskFreeMiB int64; Awake int } |
| 156 | type Load struct{ Hosts int; Apps map[string]int } |
| 157 | |
| 158 | func Eligible(snap Snapshot, roster map[string]RosterBox, placement, awsRegion, homeName, expectedFP string, lost map[string]bool) []Candidate |
| 159 | func Choose(host string, cands []Candidate, running map[string]Load, app string) (Candidate, bool) |
| 160 | ``` |
| 161 | |
| 162 | `Box` uses the edges file's JSON tags, so an edges file unmarshals |
| 163 | straight into it. The caller sets `Fresh` from the box's status snapshot. |
| 164 | It sets `Snapshot.Complete` only when every listed edges file was read |
| 165 | and every box's liveness is known. |
| 166 | |
| 167 | What Eligible guarantees: |
| 168 | |
| 169 | - It returns nothing when the snapshot is incomplete, `expectedFP` is |
| 170 | empty or `awsRegion` is empty. |
| 171 | - It judges each box on that box's own evidence. Two partial boxes never |
| 172 | combine into one eligible box. |
| 173 | - Identity and AWS region come from the roster. The edges file supplies |
| 174 | liveness and configuration. |
| 175 | - The result is sorted by box id. |
| 176 | |
| 177 | What Choose guarantees: it prefers a box that does not run `app` (it never |
| 178 | refuses on that), then the fewest hosts, then the greatest SHA-256 of |
| 179 | `host + "\x00" + boxID`, then the smallest box id. The answer does not |
| 180 | depend on the order of the candidates. It returns false only when there |
| 181 | are no candidates. |
| 182 | |
| 183 | Moves' door can call `Eligible` then `Choose` in place of its "lexically |
| 184 | first box" fallback. |
| 185 | |