# Census: the offline operator move

Status: PROPOSED. Every row carries `rev3=PROPOSED-moves-offline`. The ruling
is asked of the claim owner, platform-1c, as census owner for this branch.

The branch is `moves-offline` (the plan is
`docs/superpowers/plans/2026-10-20-offline-operator-move.md`). It adds two
census overlays:

- `internal/provision/testdata/plan14-root-reads-moves-offline.tsv`, loaded
  last in `TestPlan14RootReadCensus`, after bootstrap-carlos-follows-316.
  It has 45 rows: 39 new units and 4 changed legacy units, all proposed
  accepted legacy (43 `@legacy` frozen declarations), and 2 authority units
  re-pinned at main's class and task. It also re-pins ten source envelopes:
  `.`, `internal/cgroup`, `internal/console`, `internal/host`,
  `internal/layout`, `internal/lease`, `internal/lease/leasetest`,
  `internal/moves`, `internal/moves/archive` and `internal/provision`.
- `internal/provision/moves_offline_census_test.go`, the record-write (9B8)
  overlay on claim-step6-artifacts' inventory: 9 new sites and 4 changed
  ones. `TestPlan9B8RecordWriteCensus` now reads
  `plan14MovesOfflineWriteCensusInventory`.

The legacy seal moves from `53b3cbb6…5b2d7` (main, after 316) to
`833ae5ae924d4e26453bcf04d8b6572e1449d792fb456f5a19026bc591dff196`, because
the accepted-legacy rows add 43 frozen declarations. The test checks main's
seal first and then the new one, so a change on main's side still shows on
its own.

## What the ruling is asked to decide

1. **The footing.** Every new root-read unit runs as the box agent
   (`/opt/carlos/carlos agent`, carlos-edge.service, `User=carlos`), the
   console (cmd/console serve, user carlos-console) or the carlos CLI on an
   operator's machine. None makes a root decision. So each is proposed
   accepted legacy on census-ruling-carlos-run's footing, at the
   disposition main gives its moves siblings: J1 (root-route) for registry
   and route reads, J4 (root-input) for file reads, and J1,J4 where a unit
   does both, as main's `sourceFinal.run`.
2. **Root reachability.** The offline code is reached only through the
   armed moves worker. `armMovesPass` is called only from `runAgent`
   (`internal/host/agent.go:600`). The two `reconcileSerialized` callers
   are the agent's loops (`agent.go:2682`, `agent.go:3267`). In a root
   process (`carlos adopt`, `carlos system apply`) `armedMovesWorker` is
   nil, so `reconcileSerializedWith` adds no offline hook. One thing needs
   a ruling: `agentRunsAsRoot` (`agent.go`) admits an agent run as root, as
   on older boxes. On such a box every agent row below runs as root. The
   proposal treats that as main already does for the Phase 1 moves units:
   the identity is the shipped unit's (`User=carlos`).
3. **The 9B8 classes** below: three typed record writers and five
   non-record Put sites.

## Root-read census

### New root-read units (39), each proposed accepted legacy

| file:function | reads (scan kinds) | executing identity | disposition | why |
|---|---|---|---|---|
| `internal/cgroup/hostprocs_linux.go:countProcs` | file:Lstat, file:ReadDir | box agent as carlos | root-input J4 | through moveCfg.Procs (armOffline): counts the pids in the host's exec cgroup and nested slice in cgroupfs, never following a link; the count goes in the source report (-1 when unknown) and never starts, stops or kills anything |
| `internal/cgroup/hostprocs_linux.go:readProcsFile` | file:Open, file:Stat | box agent as carlos | root-input J4 | counts the lines of one cgroup.procs for countProcs; a file gone mid-listing lists nothing |
| `internal/console/apiinstancemove_offline.go:Server.offlineClaimEvidence` | file:Read | console (carlos-console) | root-input J4 | reads the host claim, the record's acquisition, a domains claim and the app's admission only to refuse an offline start (MV-O29, MV-O20); a failed read refuses with a 502, never reads as absence; deny-only |
| `internal/host/movesoffline.go:heldBy` | registry:HoldOp, registry:Status, whole:Route | box agent as carlos | root-route J1 | reads a registry row's hold op and status to answer whether this move holds it; an absent row counts as held, since nothing can serve a host with no row |
| `internal/host/movesoffline.go:isDir` | file:Lstat | box agent as carlos | root-input J4 | an Lstat that never follows a link, for the source's local-state report and retire |
| `internal/host/movesoffline.go:lexists` | file:Lstat | box agent as carlos | root-input J4 | an Lstat that never follows a link, for the source's local-state report and retire |
| `internal/host/movesoffline.go:moveCfg.flatDBPath` | registry:DBPath x2, whole:Route x2 | box agent as carlos | root-route J1 | a flat row's database path from its registry row, else the default |
| `internal/host/movesoffline.go:movesWorker.localState` | file:ReadDir, registry:DBPath x2, whole:Route x3 | box agent as carlos | route-and-input J1,J4 | reads what this box keeps for the host (database, litestream meta, files directory, env files) under both layouts, for the source report; reads only |
| `internal/host/movesoffline.go:movesWorker.offlineSourceGone` | whole:Route | box agent as carlos | root-route J1 | reads the row at offline-finishing and reports gone once reconcile has removed it; lifts this move's own fence |
| `internal/host/movesoffline.go:movesWorker.offlineSourcePark` | whole:Route x4 | box agent as carlos | root-route J1 | reads the row, then renames the host's local paths into the move's park directory under the fence; deletes nothing |
| `internal/host/movesoffline.go:movesWorker.offlineSourceRevert` | registry:HoldOp, whole:Route x2 | box agent as carlos | root-route J1 | reads the row's hold and releases only this move's hold, under reconcileMu, on abort |
| `internal/host/movesoffline.go:movesWorker.offlineSourceStop` | whole:Route | box agent as carlos | root-route J1 | installs this move's fence, reads the row and rewrites the source report every pass |
| `internal/host/movesoffline.go:movesWorker.retirePaths` | file:ReadDir, whole:Route x3 | box agent as carlos | route-and-input J1,J4 | lists the paths the source retire parks, from the row's layout and the nested instance directory's entries |
| `internal/host/movesoffline.go:movesWorker.sourceReport` | whole:Route x3 | box agent as carlos | root-route J1 | reads the row for the source report's evidence |
| `internal/host/movesoffline_land.go:extractSealed` | file:Open | box agent as carlos | root-input J4 | opens the sealed archive the landing staged and extracts it as Phase 1's openAndExtract does |
| `internal/host/movesoffline_land.go:hexFileSHA256` | file:ReadFile | box agent as carlos | root-input J4 | the sha256 of a landed file for a seal check; empty when unreadable, which fails the check |
| `internal/host/movesoffline_land.go:landRun.config` | whole:Route | box agent as carlos | root-route J1 | reads the held row to render its configuration through RenderHeld, which admits only a row held by this move |
| `internal/host/movesoffline_land.go:landRun.env` | file:ReadFile x2 | box agent as carlos | root-input J4 | reads the landed env file and rendered configuration to compare with the seal's sha256s |
| `internal/host/movesoffline_land.go:landRun.envPin` | whole:Route | box agent as carlos | root-route J1 | reads the held row for LaunchLayers' composed environment, compared with the seal's portable digest |
| `internal/host/movesoffline_land.go:landRun.nextDue` | registry:NextDue x2, whole:Route x2 | box agent as carlos | root-route J1 | sets the held row's NextDue to the seal's and reads it back |
| `internal/host/movesoffline_land.go:landRun.release` | whole:Route | box agent as carlos | root-route J1 | reads the held row's channel to compare its build with the seal's |
| `internal/host/movesoffline_land.go:landRun.replication` | registry:Replication x2, whole:Route x2 | box agent as carlos | root-route J1 | sets the held row's replication rung to the seal's and reads it back |
| `internal/host/movesoffline_land.go:landRun.reread` | whole:Route x3 | box agent as carlos | root-route J1 | re-reads the row after a column write |
| `internal/host/movesoffline_land.go:landRun.row` | registry:HoldOp x3, registry:Status, whole:Route x3 | box agent as carlos | root-route J1 | reads the reconciled row, its hold and status, and requires it held landing by this move at flat paths |
| `internal/host/movesoffline_land.go:landRun.timers` | whole:Timer x5 | box agent as carlos | root-route J1 | reads the held row's timer rows and replaces them from the digest-checked snapshot |
| `internal/host/movesoffline_land.go:landRun.warmPaths` | registry:DBPath x3, whole:Route x4 | box agent as carlos | root-route J1 | the row's database artifact paths, checked as a possible warm cache before extract |
| `internal/host/movesoffline_land.go:movesWorker.landReport` | registry:HoldOp, registry:NextDue, registry:Replication, registry:Status, whole:Route x9 | box agent as carlos | root-route J1 | ensures each landing item against the held row and returns the target report |
| `internal/host/movesoffline_land.go:movesWorker.offlineTargetOpen` | registry:HoldOp x3, registry:Status x4, whole:Route x4 | box agent as carlos | root-route J1 | releases this move's hold on the landed row only from this box's own all-OK report |
| `internal/host/movesoffline_land.go:movesWorker.offlineTargetRevert` | registry:HoldOp x3, registry:Status x2, whole:Route x5 | box agent as carlos | root-route J1 | parks what the move extracted and deletes only a row this move holds |
| `internal/host/movesoffline_land.go:nonEmptyOrNotDir` | file:Lstat, file:ReadDir | box agent as carlos | root-input J4 | an Lstat and ReadDir of the files directory, refusing a non-empty one before extract |
| `internal/host/movesoffline_seal.go:movesWorker.offlineArchive` | file:Create x2, file:ReadFile | box agent as carlos | root-input J4 | builds the sealed archive of the files directory and env file in temporary files and puts it in the target bucket |
| `internal/host/movesoffline_seal.go:movesWorker.offlineFilesOf` | file:ReadDir, registry:Host, whole:Route x3 | box agent as carlos | route-and-input J1,J4 | reads the row and lists the nested instance directory for the files, env and config paths; an app entry beside the files directory refuses the seal |
| `internal/host/movesoffline_seal.go:movesWorker.offlineTakeSeal` | registry:DBPath, registry:NextDue x2, registry:Replication x2, whole:Route x6, whole:Timer x5 | box agent as carlos | root-route J1 | seal steps 1 to 9 from the row: database path, next due, replication rung and timer rows, read for the seal |
| `internal/host/movesoffline_seal.go:readOwnFile` | file:Lstat, file:Open, file:ReadAll, file:Stat | box agent as carlos | root-input J4 | reads a regular file without following a link, as the agent reads every file a nested tenant can write |
| `internal/host/movespass.go:armOffline` | whole:Route | box agent as carlos | root-route J1 | arms the offline fields of the moves worker's config at agent start; closes over the registry and reads nothing itself |
| `internal/host/movespass.go:channelVersion` | registry:App, registry:Channel, whole:Route | box agent as carlos | root-route J1 | resolves the row's channel pointer under the release key for the landing's release check; reads only |
| `internal/layout/plan.go:NestedPlatformNames` | data-literal | box agent as carlos | root-input J4 | called only from the agent's offline sealer and source retire (IsNestedPlatformName): a fixed list of names, reads nothing |
| `internal/lease/tombstone.go:readLock` | file:ReadAll | box agent as carlos | root-input J4 | through lease.Tombstone, Reassert and LiftTombstone (the offline sealer and landing): reads the litestream lock object with its ETag; a garbled or expiry-less lock is an error, never free |
| `internal/provision/offline_move.go:OfflineStart` | file:Open | console (carlos-console) | root-input J4 | the record transition the start door applies through SetRecordMove; its one site is rec.ActiveRun.Open(), a method, not a file read |

### Changed legacy units (4), re-frozen at main's disposition

| file:function | reads (scan kinds) | executing identity | what changed |
|---|---|---|---|
| `internal/host/edgesfile.go:edgesFileLoop` | file:Read | box agent as carlos | main's body (census-ruling-seeded-instances) with offline-move-1 joining the published capabilities each pass while the armed moves worker is capable (withOfflineCapability), and headroom asked of region HA's capability alone; read site unchanged, offset moved; a report, not a decision |
| `internal/lease/leasetest/s3server.go:NewS3Server` | file:Query | tests only | main's fixture (census-ruling-FS-R10) with ListObjectsV2 and If-Match on DeleteObject added, as S3 answers them; simulated ETags and conditional writes grant no production root authority |
| `internal/moves/archive/archive.go:walk` | file:Lstat x2, file:WalkDir | box agent as carlos | main's body (census-ruling-moves-overcap-cut-236, root-input J4) with a skip filter that only leaves paths out (nil from every caller today); reads stay on the os.Root, and its binding condition holds: no read falls back to a path-based open |
| `move.go:mover.roundTrip` | file:ReadAll | CLI, as the operator | main's body with the offline status view's waiting sentence read from the reply (WireWaiting); read site unchanged, offset moved |

### Changed authority units (2), re-pinned at main's class and task

| file:function | task | reads (scan kinds) | executing identity | what changed |
|---|---|---|---|---|
| `internal/host/agent.go:fastConvergeLoop` | B3 | whole:Route x2 | box agent as carlos | main's body (census-ruling-carlos-run) with the offline keep view read before the first reconcile pass (refreshOfflineView); read sites unchanged, offsets moved; main's B3 authority retained |
| `internal/host/movespass.go:armMovesPass` | B,C | file:Read, registry:Provisioned, whole:Route | box agent as carlos | main's construction with newMovesWorker, the box label and version store set whatever the switch says, and armOffline called after armPhase1; read sites unchanged, offsets moved; main's B,C authority retained |

## Record-write census (9B8)

| file:function | kinds | class | executing identity | why |
|---|---|---|---|---|
| `internal/console/apiinstancemove_offline.go:Server.offlineApply` | key:SetRecordMove | record-writer | console (carlos-console) | every transition of the move's record goes through the typed `provision.SetRecordMove` |
| `internal/console/apiinstancemove_offline.go:Server.offlineTakeLock` | sel:Put | nonrecord | console (carlos-console) | writes the one-move lock `control/offline-moves/active.json` create-only, and releases it at its etag; never a record key |
| `internal/host/movesoffline.go:putOfflineCreateOnly` | sel:Put | nonrecord | box agent as carlos | create-only Put of the sealer's report under `control/offline-moves/<host>/<op>/`; never a record key |
| `internal/host/movesoffline.go:writeOfflineReport` | sel:PutReplace | nonrecord | box agent as carlos | replaces a box's own report at `moves.OfflineReportKey` (`control/offline-moves/<host>/<op>/<role>/<box>.json`) |
| `internal/host/movesoffline_land.go:movesWorker.offlineLand` | key:SetRecordMove | record-writer | box agent as carlos | the landing's record transition, through the typed `SetRecordMove` |
| `internal/host/movesoffline_seal.go:movesWorker.offlineArchive` | sel:Put | nonrecord | box agent as carlos | puts the sealed archive and its manifest under the move's `moves/@offline/<host>/<op>/` prefix in the target data bucket |
| `internal/host/movesoffline_seal.go:movesWorker.offlineKeepStop` | key:SetRecordMove | record-writer | box agent as carlos | the sealer's record transition, through the typed `SetRecordMove` |
| `internal/host/movesoffline_seal.go:movesWorker.offlineTakeSeal` | sel:Put | nonrecord | box agent as carlos | puts the timers snapshot and the env report beside the archive, under the same `moves/@offline/` prefix |
| `internal/moves/offline.go:var OfflineManifest` | lit:instances | reference | library data (read by the console's status view) | prose naming `instances/<host>/` paths in the carry table; builds no key |
| `internal/console/authcli.go:Server.registerCLIRoutes` | lit:instances,lit:sidecars | key-site (main's) | console (carlos-console) | registers the offline doors beside the move doors; no write added |
| `internal/layout/extras.go:var platformNames` | lit:instances | key-site (main's) | library data | adds `.offline-moves`, the move's park directory, to the platform's names; no write |
| `internal/provision/provision.go:updateRecordSeeding` | key:RecordKey,key:WriteRecord | record-writer (main's) | console and agent, as on main | a mutation that reports the record unchanged (`errRecordUnchanged`) now returns with no write and no sequence advance; every write still goes through `WriteRecord` |
| `move.go:moveMain` | lit:instances | key-site (main's) | CLI, as the operator | dispatches `carlos move offline`; no write |

## How the overlay was made

A throwaway `_test.go` in `internal/provision` ran `rootReadProduction`,
loaded every overlay in the test's own order, and printed each new or
changed unit with its scan kinds and digest, the changed envelopes, and the
dispositions of the sibling moves units. The rows were written from that
output, the `@legacy` fingerprints computed with `rootReadLegacyFingerprint`'s
formula, and the seal read back with the overlay loaded. The throwaway was
deleted. Any change to a unit above, comments included, needs the overlay
regenerated the same way.

## Ruling (platform-1c, 2026-10-20): census-ruling-moves-offline, ACCEPTED as proposed

- **R1 (root-read units).** Accepted. The 39 new units are accepted legacy under their executing identities (the agent as carlos, the console, or the CLI; no root decision). The 4 changed legacy units are re-frozen, the 2 authority units are re-pinned at main's class and task, and the 10 envelopes are re-pinned. The seal moves to 914d2a18…fc6855 (833ae5ae…dff196 before the rev3 tag was rewritten to the ruling).
- **R2 (9B8).** Accepted as classified.
- **R3 (agentRunsAsRoot).** Accepted with a condition. The offline move must never run on a box whose agent actually runs as root. The runbook prechecks, read-only on every source-region box and the target box, that `systemctl show carlos-edge -p User` prints `User=carlos`. Any other value is a STOP.
- **Extension (platform-1c, 2026-10-20, for 8dc2a544's release item).** `landRun.release` is re-frozen at its disposition (whole:Route@368, digest 492cffa9… to d589aea5…). `landRun.liveNotSealed` is accepted legacy under the same tag, on the condition that it compares the live link's Readlink target string with the sealed build's path and follows nothing beyond the link. It used `release.ResolveLiveLink`, which walks the chain, so it now does Lstat, Readlink and a string comparison; its sites are file:Lstat@172, file:Readlink@404, registry:App@537, whole:Route@516, whole:Route@539. The internal/host envelope is re-pinned, and the seal moves to b14f1723…570637.
- **Re-pin (platform-1c, 2026-10-20, accepted with conditions).** Re-pinned for a text-only helper extraction (copy gate), 2026-10-20; no read, class or behaviour change. The five transition refusals MV-O36, 38, 40, 41 and 43 moved into fmt.Sprintf helpers so the copy gate can read them; OfflineStart's diff is the MV-O43 refusal alone. OfflineStart is re-frozen at its disposition (file:Open@184 unchanged, digest 2d3cd134… to a5097e64…), the internal/provision envelope is re-pinned, and the seal moves to 393e7f45…65bec29. `TestOfflineHelperRefusalsMatchTheCopyRecord` (internal/provision/offline_move_copy_test.go) drives each of the five through its real transition and pins it byte for byte to the approved copy-record text, still wrapping ErrMoveConflict.
