# Census: parallel mirror rounds and the offline move's stage door

Status: RULED. census-ruling-moves-offline-precopy, by the coordinator as census
owner for this branch, 2026-10-21: accepted as proposed. Every row carries
`rev3=census-ruling-moves-offline-precopy`.

The branch is `moves-offline-precopy`, from main 3d444a1b. It was ruled at
21cd69a4 (after Astra's round 1 fixes, 692d41cf), then re-ruled at 981294cf
(round 2, 0a6bb375) merged with main f550afa2 (see "Round 2 and the merge of
main" below). It does two things. It runs a mirror
round's copies and deletes side by side in `lease.MirrorPrefixKeep`. And it
adds `carlos move offline stage`, which pre-copies the replica while the
instance keeps serving, as a new record state, offline-staging.

The ruling adds:

- `internal/provision/testdata/plan14-root-reads-moves-offline-precopy.tsv`,
  loaded last in `TestPlan14RootReadCensus`, after design-plans-and-caps, once
  design-plans-and-caps' seal has been checked;
- `internal/provision/moves_offline_precopy_census_test.go`, the record-write
  (9B8) overlay on design-plans-and-caps' inventory, which
  `TestPlan9B8RecordWriteCensus` now reads;
- one line in `internal/console/testdata/claim-routes.txt`.

No earlier overlay is edited.

## The footing

Every unit here runs in the box agent (`/opt/carlos/carlos agent`,
carlos-edge.service, `User=carlos`) or the console (cmd/console serve, user
carlos-console). None makes a root decision. So each stays accepted legacy at
the disposition census-ruling-moves-offline gave it or its sibling: J1
(root-route) for registry and route reads, J4 (root-input) for file reads.

## Root-read census

| unit | change | executing identity | disposition |
|---|---|---|---|
| `internal/host/movesoffline.go` `movesWorker.offlineSourceRevert` | changed legacy: calls `offlineUnstage` before it reports reverted. Read sites unchanged; `whole:Route@701` moved to `@843` (the same `rt` read, shifted by the inserted lines) | the agent, on abort, through the armed moves worker | re-frozen, J1 |
| `internal/provision/offline_move.go` `offlineBegin` | new: OfflineStart's body moved unchanged, with the state as a parameter. Its one site, `file:Open@198`, is `rec.ActiveRun.Open()`, i.e. `RunState.Open()` (queued, picked or cancel), a method, not a file read | the console, through `SetRecordMove` | accepted legacy, J4 (as OfflineStart) |
| `internal/provision/offline_move.go` `OfflineStart` | retired: still declared, reads nothing. It calls `offlineBegin(…, OfflineStopping)` and is behaviour-identical to main: same refusals in the same order, same stamps, and the `StagedAt` stamp fires only for staging | the console | H3 census-ruling removal (the carlos-run `getJSON` form) |

### What offlineUnstage reads and writes

It does nothing unless the record carries `staged_at` and this box is the
stager the stage door pinned on the record (`move.stager`). On that one box it
reads only the object store:

- its own stager report (`OfflineReportKey`);
- this box's receipts object.

It then:

- deletes `moves/<host>/` in the staging bucket the stage door fixed on the
  record (`move.stage_bucket`), through a mirror for `move.stage_region`
  (round 2). It refuses a bare `moves/`, a missing bucket and this box's own
  bucket, and in those cases acknowledges nothing. `offlineSealRevert`
  already deletes the same prefix the same way;
- clears `move-receipts/<host>.json` in `cfg.OwnBucket`, the same bucket the
  stage round writes them to (`tombstoneAt`);
- writes its stager report as reverted.

It reads no local file. The host comes from the record. The stored bucket and
region come from the target box's edges file at stage time, written to the
record by the console through `SetRecordMove`. The operator names only a
region code, and the tenant controls none of these paths.

### Envelopes re-pinned (at 21cd69a4; superseded by the round 2 table below)

None of these changes adds a read site. Every changed production file was
read for this ruling.

| envelope | what changed | hash |
|---|---|---|
| `.` | move.go: the stage subcommand and its status and abort lines; no os, file, exec or env use | `44fc0f3e…8bdd` |
| `internal/console` | the stage door; `offlineBeginChecks`, shared by start and stage; the stager pin; the finish gate on the stager's reverted report; the status view | `6ae90250…92d6` |
| `internal/host` | `movesoffline_stage.go`, new: reads only the object store, and imports `os` only for `os.ErrNotExist`. `edgesfile.go` publishes `offline-stage-1`. offlineRound has a staging branch | `e9234db8…8f47` |
| `internal/lease` | mirror.go: `forEachParallel`; copies, then deletes, side by side; the receipts book read before the workers start | `980f38c4…62a14` |
| `internal/moves` | the stager role, the staged phase, the report's progress fields, `OfflineStager` | `33c7b507…e071` |
| `internal/provision` | `OfflineStage` (which needs a stager), `OfflineRestage`, `OfflineStartStaged`; `MoveState.StagedAt` and `MoveState.Stager` | `8c868695…4b64` |

## Record-write census (9B8)

Two sites change, each keeping main's class. No site is added.

| file:function | kinds | class | why |
|---|---|---|---|
| `internal/console/authcli.go:Server.registerCLIRoutes` | lit:instances,lit:sidecars | key-site (main's) | one route line for the stage door, beside the six offline doors; no write added. Digest `2aee959d…c459` |
| `internal/lease/mirror.go:MirrorPrefixKeep` | sel:CopyVersioned | versioned-transport (main's) | see below. Digest `dff4d03f…fb19` |

MirrorPrefixKeep plans its skips in key order first. It then runs the copies
on up to `DefaultMirrorWorkers` (8) goroutines and, only once every copy has
landed, the deletes. Its writes are the same as before:

- Copy or CopyVersioned into the destination prefix;
- Delete of destination keys the source no longer has;
- receipts through the book (`set`, `drop`, `save`). Every update is under one
  mutex, and `receiptLedger.save` is still the one Put, unchanged.

After round 1, the book is read once, before the workers start, whenever the
round has copies to make. So `set` and `drop` touch memory only, and no
request is made under the mutex.

The stager pin is a new field on `MoveState`. It is written through the typed
`provision.SetRecordMove` (`offlineApply`), like every other move field, under
the same one-move lock. That is not a new write site.

## Claim route census

`POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/stage` joins
`claim-routes.txt`.

- **Access.** The door is operator-only: `offlineGate` calls
  `moveOperatorGate`, and any token that is not an operator's gets a 404.
- **Claims consoles.** With host claims or Phase 1 on, it is refused with
  MV-O20.
- **Tests.** Its claim-refusal fixture and its `svcroutes_test.go` exemption
  match the six sibling offline doors exactly.

## Round 2 and the merge of main

### The merge (d5b020ba)

Main (f550afa2) added two overlays, run-lifecycle-logs-325 and
design-plans-and-caps, and a 9B8 inventory, `plansCapsWriteCensusInventory`.
They touch none of this branch's units. The merge keeps both of main's
overlays and loads this branch's last, after design-plans-and-caps, whose seal
(`4f5e0ed6…df17`) is checked first. This branch's 9B8 overlay now sits on
`plansCapsWriteCensusInventory`.

Three envelopes (`.`, internal/console, internal/host) and registerCLIRoutes
are changed by both sides. Because this branch's overlay loads last, it pins
them on the merged tree.

### Round 2 (0a6bb375): what changed, and the census reading

- **The stage door fixes the staging destination on the record:**
  `MoveState.StageBucket` and `StageRegion`, from the one target box's edges
  file. The door refuses a source box's own bucket (MV-O191).
  `provision.OfflineStage` takes a `StageTarget` and refuses an empty stager
  or bucket. These are record fields written through the typed
  `SetRecordMove` (`offlineBegin`, `offlineApply`), so there is no new write
  site.
- **The stager re-reads the record** before a round, and once before its
  delete phase (`stageDeleteGate`). `provision.GetRecord` is an object-store
  read, not a local file, and the scan finds no root-read unit in
  `movesoffline_stage.go`. A move that has left staging starts no round and
  deletes nothing.
- **The stager acknowledges it has stopped** (`offlineStagerStopped`). It
  writes its own stager report through `writeOfflineReport`, an existing
  nonrecord site, and is called from `offlineSourceStep` in stopping through
  landed.
- **offlineUnstage deletes from the stored destination:** `moves/<host>/` in
  `move.stage_bucket`, through a mirror for `move.stage_region`. It refuses,
  and acknowledges nothing, when no bucket is stored or the stored bucket is
  this box's own. It reads only the object store.
- **offlineTakeSeal** gains one refusal before its mirror: a staged move
  seals only into the stored bucket and region. This is a comparison of
  record and edges-file fields. Its 16 read sites are unchanged, each shifted
  by 487 bytes. Its Puts (nonrecord, under the move's `moves/@offline/`
  prefix) are unchanged.
- **The console:**
  - Start and seal refuse when the pinned stager is missing from the fleet
    (MV-O190), and stage, start and seal refuse changed coordinates (MV-O192).
  - Seal waits for a fresh acknowledgement from the stager (M-C10, or MV-O178
    when it is stale).
  - These checks read only the fleet and the report evidence the doors
    already read.
- **internal/edgesfile:** `PlacementView` gains `DataBucketRegion`, a JSON
  field. No read site.

### Rows and sites changed by round 2 and the merge

| census | unit or envelope | ruling |
|---|---|---|
| Plan 14 | `internal/host/movesoffline_seal.go` `movesWorker.offlineTakeSeal` | re-frozen at J1, census-ruling-moves-offline's disposition; digest `f606873f…cdaa` |
| Plan 14 | `movesWorker.offlineSourceRevert` | basis updated: offlineUnstage now uses the stored destination; digest and sites unchanged |
| Plan 14 | envelopes `.`, internal/console, internal/edgesfile, internal/host, internal/provision | re-pinned on the merged tree; internal/lease and internal/moves unchanged since round 1 |
| 9B8 | `movesWorker.offlineTakeSeal` | nonrecord (census-ruling-moves-offline's), sel:Put; digest `f606873f…cdaa` |
| 9B8 | `Server.registerCLIRoutes` | key-site, pinned on the merged tree; digest `a0aff01b…2e87` |

| envelope | hash |
|---|---|
| `.` | `649bb6a5…eabd` |
| `internal/console` | `7bdd3caa…81c9` |
| `internal/edgesfile` | `71692438…811e` |
| `internal/host` | `d58c8e60…e219` |
| `internal/lease` | `980f38c4…62a14` |
| `internal/moves` | `33c7b507…e071` |
| `internal/provision` | `210b1740…34dc` |

## The seal

Three accepted-legacy rows add frozen declarations (offlineSourceRevert,
offlineBegin and offlineTakeSeal), so the legacy seal moves from
`4f5e0ed6…df17` (main, after design-plans-and-caps) to
`940ed1bba8d911e0f57af83ecac253c77ba5e14dad590258e0ee03ec98ccb818`. A removal
is not in the seal. The test checks design-plans-and-caps' seal before it
loads this overlay. Each seal was read from a temporary `t.Logf` of
`rootReadLegacySeal(inv)`, which was then removed.

Earlier seals of this overlay:

- `965fda6e…c81`, on its own after scan-demand-revival;
- `8ef09f41…1a4d`, after the merge, before round 2.

## Ruling (the coordinator, 2026-10-21): census-ruling-moves-offline-precopy, ACCEPTED as proposed

1. offlineSourceRevert and offlineUnstage: ACCEPTED. Object-store reads only,
   on the pinned stager only, deleting only in the staging destination fixed
   on the record; there is no root read of an operator- or tenant-controlled
   path.
2. offlineBegin: ACCEPTED (J4, as OfflineStart). OfflineStart is retired by
   the H3 removal, since it is behaviour-identical.
3. offlineBegin's `file:Open@198` is `rec.ActiveRun.Open()`: ACCEPTED.
4. The envelopes: ACCEPTED, re-pinned on the merged tree (981294cf with main
   f550afa2), internal/edgesfile included.
5. MirrorPrefixKeep: ACCEPTED at versioned-transport. Its record writes still
   go through the book.
6. registerCLIRoutes: ACCEPTED at key-site.
7. The stage route: ACCEPTED. It is operator-only, refused with MV-O20 on
   claims consoles, and has its fixture.

8. offlineTakeSeal (round 2): ACCEPTED, re-frozen at J1 and nonrecord in
   9B8. It adds one refusal and no read or write.
9. The round 2 record fields and reads (`StageBucket`, `StageRegion`, the
   record re-reads, the stop acknowledgement): ACCEPTED. The fields go through
   `SetRecordMove`, the reads go to the object store, and the report write
   goes through `writeOfflineReport`.
10. The merge with main f550afa2: ACCEPTED. Both overlays are kept, this
    branch's is loaded last, and the seal is recomputed.

The first verification was at 25c0db26. It was re-verified at 21cd69a4 for the
round 1 fixes (the stager pin, cleanup by `staged_at`, the receipts preload),
then at 981294cf merged with main f550afa2 for the round 2 fixes (the stager
in the seal barrier, the fixed staging destination, the record re-reads).
