# 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, expectSeq int64, 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")
var ErrSourceMoved = errors.New("hostfiles: the source manifest is not at the expected seq")
```

`prefix` must be a clean relative bucket key ending in `/`. Restore,
ReadPlatform (and so LoadWake), Open and CopyManifest refuse any other
prefix before reading anything.

`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 manifest (entries and Seq),
with the same qualified references, to `toPrefix`'s manifest. The write is
create-only. No objects are copied.

`expectSeq` is the source revision the caller's pin admitted. When it is
not 0 and the source is at any other Seq, the call returns
`ErrSourceMoved` and writes nothing, on a first attempt or a retry. 0
copies whatever the source holds.

Errors:

- an absent source is `os.ErrNotExist`, never an empty copy;
- a source not at a non-zero `expectSeq` is `ErrSourceMoved`;
- a target already holding exactly the manifest this call would write 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.

Retry-safety requires a source that cannot commit between attempts: the
source generation must be fenced or frozen. The claim's admission provides
this. Against a live source, a retry can find the target holding an older
revision. It then reports `ErrManifestExists`, or `ErrSourceMoved` when
`expectSeq` is set. It never reports success for a revision the caller
did not admit.

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`.
- Publish is an install-time contract: it commits a complete and
  consistent image or nothing. It returns an error and commits nothing
  when any file could not be read (`SyncStats.Unreadable`), was replaced
  between the look and the open (`SyncStats.Retry`), or changed while it
  was read (`SyncStats.Torn`). The caller stops writers and calls Publish
  again. Objects uploaded by a refused attempt are referenced by no
  manifest.
- 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.
