Design: the replicator's authority (opens beneath pinned roots, litestream beneath the same roots, ownership state that survives exit, claims by identity), answering review twenty's eleven deferred findings with decisions 10-13 and approval H as parameters
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 file changed,
+97
−0
docs/superpowers/specs/2026-10-03-replicator-pinned-roots-design.md+97 −0
CI passed — run details
diff --git a/docs/superpowers/specs/2026-10-03-replicator-pinned-roots-design.md b/docs/superpowers/specs/2026-10-03-replicator-pinned-roots-design.md| new file mode 100644 |
| index 000000000..5f6262031 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-10-03-replicator-pinned-roots-design.md |
| @@ -0,0 +1,97 @@ |
| +# The replicator's authority: opens beneath pinned roots, ownership state that survives exit, claims by identity |
| + |
| +Date: 2026-10-03. Status: design, revision 1, for review. Branch `design-replicator-pinned-root`, off `feat/carloku-metered-replicator-20261001` at `e1280811` (the twentieth build). It answers the eleven findings the twentieth review deferred as needing a design, not another build (`DISPOSITIONS-metered-replicator.md`, review twenty; Astra 1 to 11, with Opus's P2-2 and P3-5 of earlier rounds folded in), and it keeps decisions 10 to 13 and approval H (`implementation/carloku-paid-launch/DECISIONS-FOR-PAUL.md` on the launch branch) as parameters it does not set. Nothing here is built, wired or applied. |
| + |
| +## What stands today, in one paragraph |
| + |
| +The platform's own replicator (`internal/ops/litestream_metered.go`, `litestream_owners.go`; path B of `docs/superpowers/plans/2026-10-01-durability-attestation-path.md`) reads the registry's routes under an ownership witness (`registry.RouteObservation.OwnershipUnchanged`: a generation that moves on any change to which databases there are and who owns each), lists the databases each route claims by walking its tree by path (`strictTreeDBs`), checks each owned file's directory chain by path (`checkOwnedFile`, `resolvePlatformLinks`, `platformOwned`), reads a candidate's SQLite header by path (`openRegularNoFollow`: `O_NOFOLLOW` on the last component, `O_NONBLOCK`, `O_NOCTTY`, then an inode check), hands litestream a list of paths (`lease.NewMeteredStore`), and gates every replica request on the witness (`admit`). Litestream (`third_party/litestream`) opens the database, its WAL and its LTX files by path, with the SQLite driver opening the database by path again. When the witness or a second look at the files shows a change, the fleet goes stale: every recorder is told to hold the UTC days within `ownershipMargin` of the last proven instant and the change (`goStaleFrom`), the caller closes the fleet and builds another, and the new fleet inherits the old one's window through a chain of fleets kept in memory (`inheritHold`). The ledger underneath (`internal/usage/durability*.go`) records a `Start` row before each attempt is sent and a `Finish` row after, so a crash between the two leaves a day that never reads complete. |
| + |
| +Three things that leaves, named by the reviews and true: a path can be re-pointed under any of those opens, by a directory swapped above the file (Astra 1, 2, 5, 6); an attempt admitted before a change and finished after it, or a process that exits between a change and the rebuild, leaves days nobody holds (Astra 3, 4); and claims are matched by spelling, required coverage is not told from optional, and a route's word is taken for which tree is whose (Astra 7, 8, 10). Astra 9 (one tenant's change refusing the box) and 10's uid binding are decisions 10 and 13, not mechanics. Astra 11 is tests. |
| + |
| +## Three invariants, and what each costs |
| + |
| +**I1, identity.** Every file the replicator reads, lists, classifies or hands to litestream is reached through a chain of directory descriptors from a root whose ancestry was verified once and is held open, never through a path the tenant can re-point; a change in any component of that chain between one look and the next is a change the fleet sees and holds for, not one that steers an open. |
| + |
| +**I2, coverage.** For every attempt admitted for an owner, the UTC days on which it could have been sent, recorded or finished are held for that owner and, from the instant ownership changed, for the next one, and that hold is on disk before the attempt is sent and read back before any successor admits anything, so neither a delayed attempt nor a process exit can leave a day unheld. |
| + |
| +**I3, claims.** A file is owned only when exactly one account claims it by file identity (device and inode), listed and skipped files alike, through a root the platform established; a required platform database that cannot be found is an incomplete box, not an absent entry. |
| + |
| +The cost of each is stated with its part. The amounts, the price book and the ledger's rows are untouched; this is about which bytes are counted to whom and when a day is held. |
| + |
| +## Part A. Pinned roots and descriptor opens (Astra 1 in part, 2, 5, 6; Opus P2-2, P3-5) |
| + |
| +**Roots.** At fleet build (`MeteredFleetStore`) the replicator opens and keeps, as `*os.Root` (Go 1.26; `os.OpenRoot` resolves every later name beneath the directory by descriptor and refuses a link that leaves it), one root per authority: |
| + |
| +- the platform roots: `dataDir`, the shadow-state directory, the registry's directory and the console database's directory. Each is reached by a walk from `/` of its own, component by component with `openat(O_DIRECTORY|O_NOFOLLOW)`, each component `fstat`'d and required to be the platform's (root's or this process's user's), not writable by group or others, and not a link; the `(dev, ino)` of every component is recorded as the root's ancestry and the final descriptor is the root. A platform root whose ancestry is not the platform's is a refusal to build. |
| +- one tenant root per claim root: a unit route's tree root as the route spells it, resolved explicitly through the one platform-made link shape the walk accepts today (systemd's `DynamicUser`: `/var/lib/carlos-app/<host>` a root-made link, its directory the platform's, its target under `/var/lib/private/` with the platform's directory above it), with the link read through the platform root and its target opened through the platform root, so the tenant's tree is a descriptor beneath a verified platform directory; and a file claim's chain top, found as today (the tenant's own chain upward from the file's directory, the platform's directory above it) but opened downward from the platform root, never upward from the file. |
| + |
| +A root's ancestry (its `(dev, ino)` list) is part of the fleet's facts: each look (`lookNow`) re-walks the ancestry from `/` and compares; a changed component is a change in ownership's authority and the fleet goes stale, exactly as a changed file does today. |
| + |
| +**Every inventory operation goes through a root.** `strictTreeDBs` lists with `root.Open(dir).ReadDir`, stats with `root.Lstat`, reads a link with `root.Readlink`, and classifies a link's target by `root.Stat` of the captured spelling (relative to the link's directory, within the root): a target that leaves the root is refused by the root itself and classified as "a link out of the tree", which is a problem, never silence; a target inside is a directory, a regular file or absent, as today, and the classification cannot be moved by a swap above it because nothing above it is resolved again. This closes Astra 5 (`statTarget` by path) and the `..` resolution question: a `..` inside a root stays inside or is refused. The isolated-instance namespace question (a route whose instance resolves the same spelling elsewhere in its own mount namespace) is unchanged in kind and stated below as open. |
| + |
| +**The header read** is `root.OpenFile(rel, O_RDONLY|O_NOFOLLOW|O_NONBLOCK|O_NOCTTY)`, then `fstat` against the looked-at facts, as today, with no component above the file re-resolved. A tenant can no longer steer it to `/dev` by swapping a directory: the only way to a device is a device node inside the tenant's tree, which a tenant cannot make. That residual is stated, not hidden (it needs `CAP_MKNOD`, which no unit route has; a root-made device node inside a tenant tree is an operator's act and is outside this design). |
| + |
| +**The owned file's facts** gain the chain: `(dev, ino)` of every directory from the root down to the file, read through the root at the time of the check, kept and compared at each look. Astra 2's layout (`T` owns `/srv/T`, root owns `/srv/T/bridge`, A owns `bridge/A`) is refused at the root walk: a tenant-owned component above a platform-owned one is not a platform ancestry, so no platform root is built there and a link into it is "a link whose target's ancestry is not the platform's". |
| + |
| +**Kernel and unit prerequisites, to verify before wiring (not assumed):** `os.Root` uses `openat2` where the kernel offers it and a per-component `openat` walk otherwise; the agent's unit must allow whichever is used (an `openat2` refused by seccomp with `EPERM` rather than `ENOSYS` would not fall back), and `/proc/self/fd` must be visible in the agent's mount namespace for Part B. |
| + |
| +## Part B. Litestream beneath the same roots (Astra 1) |
| + |
| +Litestream's opens are the one place a verified root does not yet reach. They are of three kinds in the fork (`third_party/litestream/db.go`): |
| + |
| +1. **Its own opens of the database and WAL** (`os.Open(db.path)` for the long-lived descriptor, the WAL reads at the sync, shadow-WAL and checkpoint paths, the `Stat`s of the database and its directory). The fork gains a root: `DB.SetRoot(root *os.Root, rel string)`; with it set, every one of those sites is `root.OpenFile(rel, ...|O_NOFOLLOW)` or `root.Stat(rel)`, and `db.f` (the retained descriptor) is opened first and its `(dev, ino)` kept as the database's identity for the life of the `DB`. |
| +2. **Its LTX files and temporary files** (`LTXPath`, `os.Create(tmpPath)`, `os.Rename`, `os.ReadDir(LTXLevelDir)`), which live under the metadata directory: a second root, the platform's shadow-state root (`SetMetaRoot`), and the same substitution. A tenant has no authority there today and gains none. |
| +3. **The SQLite connection itself** (`sql.Open("sqlite", "file:"+path+"?...")`), which the driver opens by path, and whose journal and WAL the library opens by path from the database's name. Two measures, both required: the DSN names the database through the retained directory descriptor, `file:/proc/self/fd/<dirfd>/<name>?nofollow=1&...`, where `<dirfd>` is the database's directory opened through the tenant root and kept open for the life of the `DB` (the kernel resolves the magic link to that open directory and then `<name>` and `<name>-wal` beneath it, so nothing above the directory is walked again), and `nofollow=1` (`SQLITE_OPEN_NOFOLLOW`, SQLite 3.31) refuses a database, journal or WAL whose last component is a link. What that leaves, stated: within its own directory the tenant can still replace `<name>` or `<name>-wal` by another regular file it owns between two of the library's opens; the retained `db.f` identity is compared with `root.Stat(rel)` at every sync, and a mismatch makes the `DB` refuse (an error litestream surfaces as a failed sync, which the fleet treats as a change: stale, hold, rebuild). A tenant swapping its own database for its own other database within its own directory is billed for both as its own writes, which is the one outcome this design accepts. |
| + |
| +Initialization's writes (`PERSIST_WAL`, `wal_autocheckpoint`, the `_litestream_seq` and lock tables) happen only after `db.f` was opened through the root and its identity kept; a swap after that is the within-directory case above. |
| + |
| +Verification before wiring: the modernc driver passes `nofollow` through its URI handling (its `_pragma` form is already used; the URI parameter is SQLite's, not the driver's); `/proc/self/fd/<n>/<name>` opens as described on the agent's kernel and namespace; the fork's test suite under the root. |
| + |
| +## Part C. Ownership state that survives exit (Astra 3, 4) |
| + |
| +Today the hold lives in memory: the fleet's window, the chain of fleets, the recorders' latches. The ledger already writes a `Start` row before a send and a `Finish` row after. Two additions, both in the ledger's own SQLite (same directory, same writer, same `synchronous=FULL`): |
| + |
| +1. **A fleet journal**, one row per fleet generation: `fleet_id` (random, this process's), `box`, `witness_generation` (the registry's ownership generation the fleet was built under), `owners` (the accounts and apps the fleet's recorders stand for), `opened_at`, `stale_from`, `stale_at`, `closed_at`, `predecessor`. Written at build (before any admission), updated at `goStaleFrom` (the window, in the same transaction as the recorders' latches become durable) and at close (`closed_at`, the final window). The chain of fleets is this table; `inheritHold` reads the predecessor's row, not a pointer in memory. |
| +2. **The admission on the attempt.** The `Start` row gains `fleet_id` and `admitted_at` (the instant `admit` proved the witness unchanged). The hold window of a change is then computed from the attempts, not from the margin alone: `stale_from` is the earliest `admitted_at` of any attempt of this fleet that is not yet finished or abandoned, minus the margin; and an attempt that finishes after the fleet went stale records its `Finish` with the day held in that same transaction (the recorder's `Incomplete` for that day is durable with the finish, as the `Incompletes` count already travels with the outcome). Astra 3's sequence: A's 23:58 attempt is an unfinished `Start` when the change lands at 23:58:10, so A's `stale_from` is 23:58 minus the margin; when the attempt finishes at 00:02 on the 3rd, the finish holds the 3rd for A's owner, durably, and the successor's inheritance (which reads A's row at its own open and again when A closes) carries the 3rd too. |
| + |
| +On process start, before the first fleet admits anything: every journal row of this box with no `closed_at` is a fleet that died; its window is `[min(stale_from, opened_at, earliest unfinished admitted_at) - margin, now + margin]`, held for every owner in its `owners` and for the owners the new fleet is built for (the change that was missed may be any), written to the new fleet's row as inherited; unfinished `Start` rows are held as today. This closes Astra 4: nothing of the hold is only in memory. The cost is one row per fleet build and two columns per attempt, and a start that reads the journal before replicating. |
| + |
| +What is still not closed and is stated: the registry's writer and the replicator do not share a lock, so a change can land between `admit`'s proof and the send; that is what the margin and the per-attempt window are for, and the design keeps the margin as the bound on that gap, now applied from the attempt's own admission instant. |
| + |
| +## Part D. Claims by identity and required coverage (Astra 7, 8, 10) |
| + |
| +**Claims by identity.** The inventory keys every listed file, every skipped file and every claim by `(dev, ino)` obtained through a root: a live unit's claim through its tenant root, a hibernating route's spelling (`/var/lib/private/carlos-app/<u>/main.db`) through the platform root with the same explicit link resolution. Two claims on one identity, whether the file is listed or skipped and whether either claimant is hibernating, make the file unowned with both claimants named; a tree claim over a tree holding only skipped files is still a claim on each of those files' identities. This closes Astra 7 without a new policy: it names what is already a conflict. |
| + |
| +**Required coverage.** The platform's required databases are a list, not a glob: the registry (wherever this process has it open), the console database when the box runs a console, `CONSOLE_DB` when set. A required database that cannot be found through its root is a problem that makes the inventory incomplete (the fleet is not built; the box's days are held), not an entry that disappears. Before any cutover from the external replicator, an operator command lists this inventory beside the external replicator's configuration and names every database in one and not the other; that command is read-only and is part of the plan, not of the fleet. |
| + |
| +**Route identity (Astra 10, approval H).** A route's claim is honoured only when its claim root's ancestry is the platform's (Part A) and the tree's owning uid is the uid the registry records for that route's unit; where the registry records none, the claim is refused, not inferred. Which uids the platform's own workloads run as, and whether a root-run unit may claim, are approval H and are parameters here (`trustedUIDs`, empty by default: nothing trusted that is not named). |
| + |
| +## The parameters this design does not set |
| + |
| +| Parameter | Decision | Default in the build until decided | |
| +| --- | --- | --- | |
| +| A tenant's unacceptable file or link: refuse that tenant's replication and hold its days, or refuse the box's. | 10 | The box's, as today (fail closed); the per-owner hold machinery of Part C makes the per-tenant answer a switch, not a rebuild. | |
| +| A tenant's database in a platform-owned directory. | 11 | Refused until an operator accepts it by name, as today. | |
| +| Another user's directory inside a tenant's tree. | 12 | Refused, as a named file is today. | |
| +| Which uids are the platform's workloads; a root-run unit's claims. | 13, approval H | `trustedUIDs` empty: refused. | |
| +| An isolated instance's own mount namespace resolving a link's spelling elsewhere. | open (isolated exec, #126) | Classified in the agent's namespace through the root, as today, and stated as such in the inventory. | |
| + |
| +None of the above blocks Parts A to D, which hold under any answer. |
| + |
| +## Test boundaries |
| + |
| +- **Roots:** a platform root whose ancestry has a tenant-owned, group-writable or linked component is refused; the ancestry's `(dev, ino)` list is kept and a renamed component is a change at the next look; the `DynamicUser` link shape is accepted and any other link refused; a tenant root is opened downward from the platform root. |
| +- **Inventory through roots:** every `strictTreeDBs` case of today through a root; a link out of the tree is a problem; a swap of a directory between listing and classification (a hook between `ReadDir` and `Stat`) changes nothing the inventory says, because nothing is resolved again; a `..` that would leave the root is refused. |
| +- **The header read:** a directory swapped for a link to a directory holding a FIFO between the look and the open is refused with no open of the FIFO's target outside the root; the device case is stated as untestable without `CAP_MKNOD` and covered by the root's refusal of the escape. |
| +- **Litestream under the roots:** the fork's suite under a root; a WAL replaced by a link refused by `nofollow`; a database replaced within its directory between syncs refused by the identity check; the DSN form opens the right inode (asserted by `fstat` of the connection's file through `PRAGMA` is not possible, so by a write through the connection seen through `db.f`). |
| +- **Durable state:** a `Start` admitted before a change and finished after it holds the finish's day durably (the ledger file reopened by a second `Store`); a fleet row with no `closed_at` found at start holds its window for its owners and the new ones; the chain across three fleets with the middle one never opened, through the journal; a crash between `goStaleFrom`'s window and its write (the write is the transaction; a test kills before commit and sees the start recover the attempt-derived window). |
| +- **Claims:** two claims on one identity through two spellings (live and hibernating), on a listed and on a skipped file; a tree over skipped files only; a required database missing makes the inventory incomplete; a route whose uid the registry does not record is refused. |
| +- **Mutants** in a tree copy with the killing test named, as the paid-launch branches do, once each part's assertions exist. |
| + |
| +## Plan, by dependence on the parameters |
| + |
| +Independent of every parameter, in order: (1) roots and the inventory through them (Part A without the header read); (2) the header read through the root; (3) the ledger's fleet journal and the admission columns, with start-up recovery (Part C); (4) claims by identity and required coverage (Part D without the uid binding); (5) the litestream fork under the roots (Part B), which is the largest and the one with verification items on the box. Dependent: the per-tenant hold (decision 10), operator acceptance of platform-owned directories (11), the uid binding and trusted workloads (13, approval H). Each task is its own reviewed commit with the full `internal/ops`, `internal/lease` and `internal/usage` run; the branch is reviewed by both readers; nothing is wired, and approval H stands before any wiring, as before. |
| + |
| +## Questions for the reviewer |
| + |
| +A. Is `os.Root` the right primitive for I1, given its own rules (links inside the root followed, links out refused), or must the walk stay hand-made with `openat(O_NOFOLLOW)` per component to refuse links inside the tenant's tree as well? B. Part B's DSN: is the `/proc/self/fd/<dirfd>/<name>` form with `nofollow=1` a sound statement of what the kernel and SQLite do, and what does it leave that the within-directory residual does not already say? C. Part C: is the attempt-derived `stale_from` and the finish-time hold enough for Astra 3, and does the journal's start-up rule over-hold (every owner of a dead fleet, plus the new ones) in a way that matters? D. Part D's identity-keyed claims: a case where two spellings of one file are rightly two claims. E. Anything in the parameters table that is in fact mechanics, or the reverse. F. The order of the plan. |