Design: the replicator's authority, revision 2 (retained descriptors instead of os.Root; a descriptor-rooted VFS instead of a /proc pathname; the admission as a row before the send with settlement, completeness waiting on it and successors acknowledging; owner holds as a ledger change with box-wide the default; claims by (account, app) and identity; a uid producer under the witness; tests named against the counterfactuals)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 file changed,
+47
−58
docs/superpowers/specs/2026-10-03-replicator-pinned-roots-design.md+47 −58
CI cancelled — superseded by a newer push — 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| index 5f6262031..4d6a213c8 100644 |
| --- a/docs/superpowers/specs/2026-10-03-replicator-pinned-roots-design.md |
| +++ b/docs/superpowers/specs/2026-10-03-replicator-pinned-roots-design.md |
| @@ -1,97 +1,86 @@ |
| -# The replicator's authority: opens beneath pinned roots, ownership state that survives exit, claims by identity |
| +# The replicator's authority: retained descriptors, a durable admission, 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. |
| +Date: 2026-10-03. Status: design, revision 2, for review (revision 1 was rejected by Astra on five P1s: SQLite canonicalises a `/proc` pathname before it opens anything and this driver passes no `nofollow`; `os.Root` confines but re-resolves every name, so a swap between a listing and an inspection still moves the answer; a `Start` row is not an admission, since the meter sends without one; the attempt-derived window lost a request completed before the change was noticed and the days between an admission and a finish; `closed_at` was not a handover; plus the directory open before `O_DIRECTORY`, per-owner holds the ledger cannot store, a uid with no producer, I3 against Part D, and tests). Branch `design-replicator-pinned-root`, off `feat/carloku-metered-replicator-20261001` at `e1280811`. It answers the eleven findings the twentieth review deferred (`DISPOSITIONS-metered-replicator.md`, review twenty) and keeps decisions 10 to 13 and approval H (`DECISIONS-FOR-PAUL.md`, section D, on the launch branch) as parameters. Nothing is built, wired or applied. |
| -## What stands today, in one paragraph |
| +## What stands today |
| -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. |
| +The replicator (`internal/ops/litestream_metered.go`, `litestream_owners.go`; path B of `docs/superpowers/plans/2026-10-01-durability-attestation-path.md`) reads routes under the registry's ownership witness (`RouteObservation.OwnershipUnchanged`: a generation that moves when a route is added or removed or its host, account, app, database path, backing or hibernation changes; it witnesses nothing on the filesystem), lists each claimed tree by path (`strictTreeDBs`), checks each owned file's chain by path (`checkOwnedFile`; `resolvePlatformLinks` is an ownership and structure predicate on a link and its parents, not a list of layouts), reads a candidate's header by path with `O_NOFOLLOW|O_NONBLOCK|O_NOCTTY` and an inode check, and hands litestream paths. Every replica request asks the witness (`admit`) and then the meter writes a `Start` row with a bounded budget and sends whether or not that write succeeded in time (a late `Start` is abandoned while the transport may still run). A changed or missing owned file at a later look makes the fleet stale (an inventory-only change is drift, replication continues); stale holds the UTC days within `ownershipMargin` of the earlier of the last registry proof and the last clean look's start, through the change (`goStaleFrom`), and the next fleet inherits that window from an in-memory chain. The fork (`third_party/litestream`) opens the database, WAL, LTX and temporary files by path in `db.go` and `replica.go`, and its SQLite connection is opened by the driver by path; SQLite's `xFullPathname` canonicalises that path, links included, before the open. |
| -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, made precise |
| -## Three invariants, and what each costs |
| +**I1, identity through retained descriptors.** (a) *Containment*: no operation of the replicator or of litestream on a tenant's files resolves a pathname above the directory the fleet accepted for that file; (b) *retention*: the descriptor that listed a directory is the descriptor against which its entries are inspected, its links read, their targets classified and its files opened, for that look, so nothing between a listing and an inspection can move the answer; (c) *acceptance*: an owned file is opened by the descriptor of its accepted directory with no link followed in the last component, and the opened file's `(dev, ino)` equals the accepted facts before any use. What polling discovers later (a swap put back between two looks) is a change the fleet holds for; what (a) to (c) refuse is refused before any effect. |
| -**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, a durable admission.** No request is sent until a row naming it (fleet, owner, admission instant) is durable; a day is not published complete while any admission that could intersect it is unsettled; the uncertainty interval of a change runs from the earlier of today's lower bound (the registry proof, the last clean look's start) and the earliest unsettled admission, minus the margin, until every admission of the fleet is settled, plus the margin; the hold a change creates is durable in the same transaction as its discovery; and a successor acknowledges a predecessor's window in a transaction of its own before it admits anything. |
| -**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, one billing owner by file identity.** A file is replicated once and billed to one `(account, app)`; any number of routes of that one owner may claim it through any spelling; two owners on one `(dev, ino)`, whether the file is listed or skipped, whether a claimant is live or hibernating, make it unowned with every claimant named; every claimant's own authority check must pass; a claim with no identity yet (a spelling that resolves to nothing) is recorded as a claim and reconciled when it resolves; a required platform database that cannot be reached is an incomplete box. |
| -**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. |
| +## Part A. Retained descriptors (Astra 2, 5, 6; Opus P2-2, P3-5) |
| -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. |
| +**The primitive is a hand-walked, retained directory descriptor, not `os.Root`.** `os.Root` gives containment and re-resolves every name beneath its root; it follows links inside the root, `os.OpenRoot(path)` follows links in the path it is given, Go 1.26.5 walks per component with `openat` and there is no public constructor over a descriptor one has already verified. I1(b) and (c) need the descriptor itself. A small package, `internal/authority`: |
| -## Part A. Pinned roots and descriptor opens (Astra 1 in part, 2, 5, 6; Opus P2-2, P3-5) |
| +- `Dir`: a retained descriptor opened with `openat(parent, name, O_DIRECTORY|O_NOFOLLOW|O_RDONLY|O_CLOEXEC|O_NOCTTY)` (the kernel refuses a FIFO or device with `ENOTDIR` at lookup, before any open effect, which is the one existing directory-open test kept through the new shape), with its `(dev, ino, uid, mode)` read by `fstat` at acquisition and kept. Methods, each relative to this descriptor and none re-resolving a path: `Child(name) (*Dir, error)`; `List() ([]Entry, error)` (`fdopendir` on a `dup`, then `fstatat(fd, name, AT_SYMLINK_NOFOLLOW)` per entry); `Readlink(name)` (`readlinkat`); `Target(spelling)`, which classifies a captured link target by walking its components from this descriptor with `Child` (and `..` only while the walk stays at or beneath the root's depth, counted along the retained chain; a `..` that would leave the root is "a link out of the tree"), ending in `fstatat(..., AT_SYMLINK_NOFOLLOW)` of the last component, so a target that is itself a link is a link, not followed; `OpenRegular(name, looked)` (`openat(fd, name, O_RDONLY|O_NOFOLLOW|O_NONBLOCK|O_NOCTTY|O_CLOEXEC)` then `fstat`, refused unless regular and equal to `looked`'s `(dev, ino)`); `Facts()`. |
| +- `Root`: a `Dir` reached by a walk from `/` (`open("/")` then `Child` per component), each component required to be the platform's (root's or this process's user's), not writable by group or others; the walk's `(dev, ino)` list is the root's *ancestry*. A root is built once per fleet and kept; `lookNow` rebuilds the ancestry from `/` and compares it, and a difference is a change the fleet goes stale on. |
| +- Tenant roots: a unit route's tree is reached from a platform root through the link shape `resolvePlatformLinks` accepts today (a link whose own directory and whose target's parent are the platform's; the predicate is unchanged, it is only evaluated on descriptors: the link read with `Readlink` from its directory's `Dir`, the target walked with `Target` from a platform root, and the resulting `Dir` is the tenant root); a file claim's chain top is found as today (upward by ownership from the file's directory) but every directory of the chain is then acquired downward from the platform root as `Dir`s, and the chain's `(dev, ino)` list is kept with the file's facts. Astra 2's layout (a tenant-owned component above a platform-owned one) fails the platform walk; no root is built over it. |
| -**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: |
| +**Every inventory operation runs on `Dir`s**: `strictTreeDBs` takes a `*Dir`, lists with `List`, classifies a link's captured spelling with `Target` on the directory that listed it, descends with `Child`, and reads a header with `OpenRegular`. The `..` and namespace question for an isolated instance (its own mount namespace may resolve a spelling elsewhere) is unchanged in kind and stated in the inventory's rendering as before. |
| -- 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. |
| +**Owned files keep their chain.** `fileFacts` gains the chain's `(dev, ino)` list and the retained `Dir` of the file's own directory lives for the fleet's life (one descriptor per owned file; a box has tens of databases, not thousands); every later look compares the chain through the retained descriptors and `fstatat` of the name, and litestream's opens (Part B) take that directory descriptor. |
| -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. |
| +**What A does not claim.** A bind mount or a device node already inside a tenant's tree is reachable within containment; the design relies on unit routes having no `CAP_MKNOD` and no mount authority, which the bundled app template gives today and which is a stated property of supported units, not a proof for an arbitrary unit (a verification item and a line in the inventory's rendering per route). A swap put back between two looks is not detected by polling and is not claimed to be; what litestream has open meanwhile is bounded by Part B's identity checks. |
| -**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. |
| +## Part B. Litestream beneath the accepted directory (Astra 1) |
| -**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). |
| +Two layers, and the second is the one revision 1 got wrong. |
| -**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". |
| +1. **The fork's own file operations** (`db.go`: the long-lived descriptor, WAL reads, shadow WAL, checkpoints, the `Stat`s of the database and its directory; `replica.go:186` and every LTX, temporary and cleanup path; enumerated in the plan's first task by reading, not by sampling) take two directory descriptors, `SetDataDir(*authority.Dir, name)` for the database's directory and `SetMetaDir(*authority.Dir)` for the platform's shadow-state directory, and become `openat`/`fstatat`/`renameat`/`unlinkat` relative to them with `O_NOFOLLOW`. `db.f` is opened first through `OpenRegular` against the accepted facts and its identity is the database's for the life of the `DB`; before every reopen or sidecar open the fork compares `fstatat(dirfd, name)` with that identity and refuses on a difference (an error the fleet treats as a change). |
| +2. **SQLite's own opens.** The library opens the database, journal, WAL and SHM by pathname through its VFS, after `xFullPathname` has canonicalised the pathname it was given; a `/proc/self/fd` spelling is canonicalised away, and the driver's URI handling passes no `nofollow`. So the connection is given a **descriptor-rooted VFS**: a VFS registered by the fork (the generated SQLite in `modernc.org/sqlite` exposes `sqlite3_vfs_register`; its `vfs` package already registers a Go-implemented VFS, which shows the route exists) whose `xFullPathname` returns the name unchanged, whose `xOpen` is `openat(dirfd, name, flags|O_NOFOLLOW|O_CLOEXEC|O_NOCTTY)` for the main database, the journal, the WAL and the SHM (each derived name resolved against the same descriptor), whose `xAccess` and `xDelete` are `faccessat` and `unlinkat` on it, and whose locking, `xSync` and the rest delegate to the default Unix VFS's file methods on the descriptor it opened. The DSN is `file:<name>?vfs=carlos-dirfd-<id>`, and the `id` names the retained descriptor the fork registered for that `DB`. What this closes: every SQLite open of the database and its sidecars happens beneath the accepted directory with no link followed, including reopens, checkpoints and the first open's initialisation writes. What it leaves, stated: within its own directory the owner can still replace `<name>` or a sidecar with another regular file it owns between two of the library's opens; the fork's identity comparison before each of its own opens, and the VFS's `xOpen` comparing the opened file's `(dev, ino)` with the accepted facts for the main database (refusing otherwise), bound that to the sidecars, and a sidecar swapped under a live connection is a corruption the owner does to its own replica, which the design records as a verification item for litestream's own consistency checks rather than a billing question. Feasibility of the VFS on this driver is the plan's first task, a spike with a thrown-away result, before anything else of B is written. |
| -**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 C. The durable admission and the hold that survives exit (Astra 3, 4) |
| -## Part B. Litestream beneath the same roots (Astra 1) |
| +**The admission is a row, written before the send.** The gate (`lease.RequestGate`) stops returning only an error: it returns an *admission*, `(fleet_id, owner, token, admitted_at)`, after committing a row in the durability ledger's own database (`admissions`: `token`, `fleet_id`, `owner account and app`, `admitted_at`, `settled_at` null, `how` null) with `synchronous=FULL`, within a bounded budget; if that write fails or times out, the request is refused (`ErrAdmissionNotDurable`), and litestream sees a failed upload and does not advance, as it does for a stale fleet. The meter's `Start` row references the token; `Finish` and `Abandon` settle the admission (`settled_at`, `how` = finished or abandoned) in the same transaction as the attempt's outcome. The transport's end is a settlement too: the SDK middleware's finalize step marks `transport_done_at` on the admission whether or not the recorder's workers are still running, and an admission is *settled* only when both the transport is done and the recorder has finished or abandoned. Admissions are the pending state the ledger already has for started attempts, moved to before the send and given an owner. |
| -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`): |
| +**Completeness waits on admissions.** The ledger's completeness for a day already fails while a started attempt of that day is unfinished; an unsettled admission admitted on or before day `D` makes `D` not complete for its owner, and for the box when the hold is box-wide (Part C2), until it settles; a settled admission holds, for its owner, every UTC day from its `admitted_at` through its `transport_done_at` and `settled_at`, written at settlement in that transaction. The multiple-midnights sequence (admitted on the 2nd, sent on the 3rd, finished on the 4th) holds the 2nd, 3rd and 4th, because the 3rd was not publishable while the admission was unsettled and the settlement writes the whole interval. |
| -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. |
| +**The window of a change.** `goStaleFrom` keeps its lower bound (the earlier of the registry proof and the last clean look's start) and widens it: `from = min(that, earliest unsettled admitted_at of this fleet) - margin`; `to` is not a fixed instant but *open* until the fleet's admissions are all settled, and then `max(detection, latest transport_done_at and settled_at) + margin`. A request completed before the change was noticed (admitted at 23:58, finished at 23:58:20, the change at 23:58, detected at 00:02) is inside today's lower bound (the last clean look at 23:57 minus the margin) and is held as today; the design does not replace that bound, it widens it. |
| -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. |
| +**The fleet journal and the handover.** One row per fleet in the same database: `fleet_id`, `box`, `witness_generation`, `owners`, `opened_at`, `stale_from`, `stale_to` (null while open), `state` (`open`, `stale`, `closing`, `closed`), `predecessor`, `inherited_by` (null until a successor acknowledges). Transitions and what each writes, each a transaction: build writes the row `open` before any admission; `goStaleFrom` writes `stale` with `stale_from`, in the transaction that writes the recorders' holds for the days of `[from, now]`; `closeNow` writes `closing` when litestream has stopped, and `closed` only when every admission of the fleet is settled (workers quiesced: the recorder exposes a wait on its settlement, and `closeNow` waits, bounded, and if the bound passes the row stays `closing` with the late admissions unsettled, which recovery treats as open); a settlement that lands after the fleet is `stale` or `closing` widens `stale_to` and writes the interval's holds in its own transaction. A successor, at its build, reads every row of this box with `inherited_by` null (`closed` or not), holds each one's window (`[stale_from or opened_at - margin, stale_to or now + margin]`) for that fleet's owners and for its own, writes those holds and sets `inherited_by` to its own `fleet_id` in one transaction, and only then admits; a predecessor that is still `open` is also read at the successor's close, as today, and a window that grows after acknowledgement (a late settlement) is itself a row the next reader sees because the settlement writes the holds directly, not through the chain. Process start is the same reader with no predecessor pointer: every unacknowledged row is inherited. This closes Astra 4's exit and Astra 5-of-this-review's closed-but-undelivered row and late worker: the chain is on disk, acknowledgement is explicit, and settlements write holds themselves. |
| -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 C2, who is held.** The ledger's failure rows and `Incomplete` are box-wide today (`DurabilityClient.Incomplete` discards the client's identity; `CompletenessFor(account, …)` fails on any box failure). Per-owner holds are a ledger change: a `holds` table `(day, account, app or empty for the box, reason, written_at)`, the publisher's snapshot carrying them, and the reader treating a box-wide row as failing every owner and an owner row as failing that owner alone. The design specifies the table and the reader and keeps **the box-wide behaviour as the default**: decision 10 is whether a tenant's unacceptable file holds its own days or the box's, and until it is taken every hold this design writes is box-wide. It is not a switch today, and the design no longer says it is; it is the ledger change above plus the decision. |
| ## 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. |
| +**Claims.** The inventory records claims as `(claimant route, spelling, kind)` and resolves each to an identity through the roots of Part A (a live unit's tree through its tenant root; a hibernating route's private spelling through the platform root with the same link predicate). Identity-bearing files (listed and skipped) are grouped by `(dev, ino)`; a skipped link contributes its own entry (for acceptance, as today) and, when its target resolves within a root, a claim on the target's identity. One identity with claimants of one `(account, app)` is owned by it and replicated once, every claimant's own authority check passing (as today); claimants of two owners make it unowned with all named; a claim whose spelling resolves to nothing is kept as a claim without identity, rendered, and reconciled on the look at which it resolves (it then conflicts or not like any other). This is I3 as written, and it keeps today's arrangement of several routes of one owner over one file. |
| -**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. |
| +**Required coverage.** The platform's required databases are a list: the registry this process has open, the console database when the box is a console box (the authoritative input is the agent's role configuration, not a glob), `CONSOLE_DB` when set. A required database unreachable through its root is a problem that makes the inventory incomplete and is written as a durable box-wide hold for the day, before and independent of whether a fleet is built. Before any cutover, a read-only operator command lists this inventory beside the external replicator's configuration. |
| -**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). |
| +**Route identity (Astra 10, approval H).** Numeric uid equality is not authority; what is needed is a producer and a lifecycle. The design names them without choosing the policy: the activator, which starts units and knows the user each runs as, writes `route_uid` and `unit_incarnation` on the route row when a unit starts and clears them when it stops, under the ownership witness (the generation covers the new columns, so a change is a change); a tree or file claim is honoured only when the claim root's owning uid equals the route's recorded uid for a live incarnation; a hibernating route has none and claims only through the platform root's private spelling; which uids the platform's own workloads run as, and whether a root-run unit may claim, are `trustedUIDs` (empty) and approval H. The mechanism is design; the exceptions are the parameters. |
| -## The parameters this design does not set |
| +## The parameters this design keeps open |
| -| Parameter | Decision | Default in the build until decided | |
| +| Parameter | Decision | What the build does 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. | |
| +| A tenant's unacceptable file or link: refuse that tenant's replication and hold its days, or the box's. | 10 | Box-wide, as today; Part C2's owner holds are built so either answer is implementable, and the design does not call the change small. | |
| +| A tenant's database in a platform-owned directory. | 11 | Unowned and refused, as today; an operator `Accept` applies to skipped entries today, not to unowned ones, so acceptance by name is new mechanism to build if 11 says so. | |
| +| Another user's directory inside a tenant's tree. | 12 | Today's tree behaviour (walked; its databases listed under the tenant's claim); refusing it is a change decision 12 makes, not this design. | |
| | 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. | |
| +| The link shapes a tenant root may be reached through. | compatibility, not policy | Today's predicate, unchanged. | |
| +| An isolated instance's own mount namespace. | open (isolated exec, #126) | Classified in the agent's namespace through the root, stated per route. | |
| -None of the above blocks Parts A to D, which hold under any answer. |
| +## Tests, named against review twenty's counterfactuals (Astra 11) |
| -## Test boundaries |
| +Each surviving counterfactual becomes an observable assertion with a controlled hook: the watch's `lookAgain` removed is caught by a test that advances a controlled clock through a watch tick and asserts the look ran (a counter on the fake clock's tick, not a direct call); shutdown is asserted by the recorder's settlement wait returning and by the count of open descriptors under `/proc/self/fd` matching the fleet's retained set before and after; both inode comparisons by a rename under the look at each of the two sites, through a hook between `List` and `OpenRegular`; the admission and latch interleavings by two goroutines on a barrier; a synchronised concurrent `Close`; the SQLite identity by a write through the connection followed by a checkpoint and a read through `db.f`, not by expecting bytes in the main file before a checkpoint. Part A: a platform ancestry with a tenant-owned, group-writable or linked component refused; a directory swapped between `List` and `Target` changes nothing; a listed subdirectory replaced by a FIFO fails `Child` with `ENOTDIR` and nothing blocks; a `..` out of the root refused. Part B: the VFS spike's own tests (every derived name opened beneath the descriptor; a WAL replaced by a link refused; a database replaced within its directory refused at reopen). Part C: an admission write that times out refuses the request and sends nothing; a `Start`-less send is impossible by construction (the middleware order); completeness of a day with an unsettled admission; the three-midnight interval; the fleet rows across build, stale, closing with a late settlement, closed, and inheritance after a kill at each transition, with the ledger reopened by a second process. Part D: the identity cases; a required database missing writes the hold without a fleet. Mutants in a tree copy with the killing test named, per part, after the assertions above exist. |
| -- **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 |
| -## Plan, by dependence on the parameters |
| +0. **Two spikes, results kept as notes and thrown-away code:** the descriptor-rooted VFS on `modernc.org/sqlite` (register, open a database and WAL through a `dirfd`, checkpoint, reopen; what the generated API allows and costs), and the admission protocol's cost on the request path (a `synchronous=FULL` commit per request against the ledger's current write rate). Each a day, each reviewed as a note. |
| +1. `internal/authority` (`Dir`, `Root`, the walk, the tests above) and the inventory on it (Parts A and D's identity grouping), the header read through `OpenRegular`. |
| +2. The admission and the fleet journal (Part C), the holds table and reader (C2, box-wide by default), the settlement wait at close. |
| +3. Claims by identity and required coverage (Part D). |
| +4. The fork beneath the accepted directories (Part B), on the spike's result. |
| +5. The route uid producer (the activator's columns under the witness), the parameter-dependent pieces last. |
| -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. |
| +Each task is its own reviewed commit with the full `internal/ops`, `internal/lease`, `internal/usage` and `internal/registry` run; the branch is reviewed by both readers; approval H stands before any wiring. Nothing is wired by this plan. |
| ## 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. |
| +A. Is the `Dir`/`Root` contract (retained descriptors, `Target` by component walk, `..` bounded by the chain's depth) sufficient for I1, and is anything in it a re-resolution in disguise? B. The descriptor-rooted VFS: is the method set (`xFullPathname` identity, `xOpen`/`xAccess`/`xDelete` on the descriptor, locking delegated) complete for SQLite's WAL mode on Linux, and does anything in the library's open sequence still take a pathname outside the VFS? C. Part C: is "admission before send, settlement after transport and recording, completeness waits on unsettled admissions, settlements write their own holds, successors acknowledge" closed under a kill at any instant, or name the UTC sequence it misses. D. Part C2: is the owner-scoped `holds` table the right shape for the reader, given box-wide failures must still fail every owner? E. Part D: a case the identity grouping misreads. F. The plan's order, and whether spike 0 should gate the rest. |