docs: the region HA contracts note records the origin-qualified manifest, Publish and boxchoice signatures for the claim and moves sessions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+159
−0
docs/superpowers/notes/2026-10-07-region-ha-contracts.md+159 −0
diff --git a/docs/superpowers/notes/2026-10-07-region-ha-contracts.md b/docs/superpowers/notes/2026-10-07-region-ha-contracts.md| new file mode 100644 |
| index 000000000..e06ceb98d |
| --- /dev/null |
| +++ b/docs/superpowers/notes/2026-10-07-region-ha-contracts.md |
| @@ -0,0 +1,159 @@ |
| +# Region HA contracts for the claim and moves sessions |
| + |
| +Branch `region-ha-contracts`, 2026-10-07. Three contracts that the claim |
| +session (L13) and the moves session (L15, L17 in |
| +`2026-10-06-moves-region-ha-conflicts.md`) asked region HA for. All three |
| +are dormant: no production path calls them yet. |
| + |
| +## 1. Origin-qualified object references (claim L13) |
| + |
| +Package `internal/hostfiles`. |
| + |
| +The manifest format is now version 2 (`hostfiles.ManifestVersion = 2`). |
| +`Entry.Object` is the object's full key from the bucket root: |
| + |
| + instances/<host>/<inc>/files/<gen>/objects/<16-digit seq>-<64 hex sha256> |
| + |
| +The publisher writes it under its own prefix. Readers fetch exactly that |
| +key and never join it to their own prefix. Version 1 held a key relative |
| +to the manifest's prefix. The field's meaning changed, so the version was |
| +bumped. Any manifest that is not version 2 (including 1) is refused by |
| +`Load`, `Open`, `Restore` and `CopyManifest` with "manifest <prefix> is |
| +version N; this binary reads 2". No version 1 manifest exists in |
| +production, so nothing needs migrating. |
| + |
| +Signatures: |
| + |
| +```go |
| +func Restore(ctx context.Context, s release.Store, prefix, dir string, origins []string) (Manifest, RestoreStats, error) |
| +func ReadPlatform(ctx context.Context, s release.Store, prefix string, m Manifest, name string, origins []string) ([]byte, error) |
| +func LoadWake(ctx context.Context, s release.Store, prefix string, m Manifest, origins []string) (WakeState, bool, error) |
| +func CopyManifest(ctx context.Context, s release.Store, fromPrefix, toPrefix string, origins []string) (Manifest, error) |
| +var ErrOrigin = errors.New("hostfiles: object origin not admitted") |
| +var ErrManifestExists = errors.New("hostfiles: the target generation already has a manifest") |
| +``` |
| + |
| +`origins` is the set of generation prefixes that the caller's pin admits. |
| +nil or empty means the manifest's own prefix only. The set is used exactly |
| +as passed: a caller that passes a set must include the manifest's own |
| +prefix if it wants objects published there. Each origin must be a clean |
| +relative bucket key ending in `/`. If one is not, the call fails before |
| +anything is read. |
| + |
| +What Restore guarantees: it validates every path and every published |
| +entry before it touches `dir`. If any entry fails, Restore writes and |
| +removes nothing. An entry's object must: |
| + |
| +- be a clean bucket key: relative, no `..`, no `.`, no `//`, no NUL; |
| +- be exactly `<origin>objects/<%016d Seq>-<SHA256>`, using that entry's |
| + own Seq and SHA256, where origin is one of the admitted origins. A key |
| + nested deeper under an origin, a key under another host's or another |
| + generation's prefix, a relative key in the version 1 style, an absolute |
| + key, or a wrong seq or sha is refused. If the key is well formed but its |
| + origin is not admitted, the error wraps `ErrOrigin`; |
| +- hash and size-check as before (`checkEntry`, then `fetch`). |
| + |
| +ReadPlatform and LoadWake apply the same rules to platform items. |
| + |
| +CopyManifest reads `fromPrefix`'s manifest and validates every entry |
| +against `origins` (nil means `fromPrefix` alone). A source built by an |
| +earlier copy references earlier generations, so its caller passes the |
| +whole chain. CopyManifest then writes the same bytes, with the same Seq and |
| +the same qualified references, to `toPrefix`'s manifest. The write is |
| +create-only. |
| +No objects are copied. Errors: |
| + |
| +- an absent source is `os.ErrNotExist`, never an empty copy; |
| +- a target already holding identical bytes is success, so a retry is safe; |
| +- any other target is `ErrManifestExists`, and the target is left as it |
| + was; |
| +- a source equal to the target is refused. |
| + |
| +The new generation's `Open(toPrefix)` continues the sequence from the |
| +copy. An unchanged file keeps its old-generation reference. A changed file |
| +becomes a new object under `toPrefix`. |
| + |
| +For the claim's live set: the objects a manifest keeps alive are exactly |
| +the `Object` keys of its entries and platform items that are neither |
| +deleted nor skipped. Those keys may lie under earlier generations' |
| +prefixes. |
| + |
| +## 2. Files publish entry point (moves L17) |
| + |
| +Moves asked for `files.Publish(ctx, store, location, generation, dir, |
| +envPath) (manifestVersion string, err error)`. The closest stable call is: |
| + |
| +```go |
| +func Publish(ctx context.Context, s release.Store, prefix, dir string, skip func(rel string, isDir bool) bool) (int64, SyncStats, error) |
| +``` |
| + |
| +- `prefix` is the generation's files prefix, `<location>files/<generation>/`. |
| + The caller builds it. |
| +- It runs one `Open`, one `SyncDir` and one `Commit`, and returns the |
| + committed manifest sequence number. That number is the manifest version. |
| +- On success, every regular file in `dir` that `skip` does not exclude is |
| + in the manifest. A file larger than `MaxFileBytes` is recorded as |
| + skipped and listed in `SyncStats.TooLarge`. Anything that is not a |
| + regular file is left out and listed in `SyncStats.NotRegular`. |
| +- A file that exists but cannot be read is an error, and nothing is |
| + committed. |
| +- A prefix that already has a manifest is continued, not replaced. A |
| + repeat with nothing changed returns the same sequence number. If another |
| + writer's manifest appears between Open and Commit, the error is |
| + `ErrSuperseded`. |
| +- The env file is not part of this call. A caller that wants platform |
| + items in the same revision uses `Open`, `StagePlatform`, `SyncDir` and |
| + `Commit` directly. |
| + |
| +## 3. Eligible-boxes read (moves L15) |
| + |
| +New package `internal/boxchoice`. Console's unexported `eligibleBoxes` and |
| +`chooseBox` are now thin adapters over it, and their behaviour is |
| +unchanged. The predicate and choice tests moved to the new package. |
| +Console keeps tests that check its snapshot conversion carries every |
| +field. |
| + |
| +```go |
| +const Capability = "region-ha-1" |
| +type Headroom struct{ MemFreeMiB, DiskFreeMiB int64; Awake int } |
| +type Box struct { |
| + Box, BoxID, EIP, Region, Placement string |
| + HibernationConfigured, ServesSharedPool, AcceptsInbound *bool |
| + SecretsKeyFP string |
| + Capabilities []string |
| + Draining bool |
| + Headroom *Headroom |
| + Fresh bool // not on the wire: the caller's liveness verdict |
| +} |
| +type Snapshot struct{ Boxes []Box; Complete bool } |
| +type RosterBox struct{ Placement string; Cloud boxid.Cloud } |
| +type Candidate struct{ BoxID, Region string; MemFreeMiB, DiskFreeMiB int64; Awake int } |
| +type Load struct{ Hosts int; Apps map[string]int } |
| + |
| +func Eligible(snap Snapshot, roster map[string]RosterBox, placement, awsRegion, homeName, expectedFP string, lost map[string]bool) []Candidate |
| +func Choose(host string, cands []Candidate, running map[string]Load, app string) (Candidate, bool) |
| +``` |
| + |
| +`Box` uses the edges file's JSON tags, so an edges file unmarshals |
| +straight into it. The caller sets `Fresh` from the box's status snapshot. |
| +It sets `Snapshot.Complete` only when every listed edges file was read |
| +and every box's liveness is known. |
| + |
| +What Eligible guarantees: |
| + |
| +- It returns nothing when the snapshot is incomplete, `expectedFP` is |
| + empty or `awsRegion` is empty. |
| +- It judges each box on that box's own evidence. Two partial boxes never |
| + combine into one eligible box. |
| +- Identity and AWS region come from the roster. The edges file supplies |
| + liveness and configuration. |
| +- The result is sorted by box id. |
| + |
| +What Choose guarantees: it prefers a box that does not run `app` (it never |
| +refuses on that), then the fewest hosts, then the greatest SHA-256 of |
| +`host + "\x00" + boxID`, then the smallest box id. The answer does not |
| +depend on the order of the candidates. It returns false only when there |
| +are no candidates. |
| + |
| +Moves' door can call `Eligible` then `Choose` in place of its "lexically |
| +first box" fallback. |