Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1188,7 +1188,7 @@ An actor's **effective roles** on a resource = token roles (global) ∪ roles as
Distinct mechanisms, not interchangeable — conflating them is a security hole:

- **App admin** — the schema-registry authority (the app owner / CI). Lives *above* every document: registers schemas, migrations, and the static `@auth` for an `app_id`, and is a **superuser** that may act on every document in the app (bypasses the policy, decision-flow step 0). A credential class (the registration key), **not** a role and **not** an owner; never appears in `@auth` grants.
- **Owner** — a **dynamic, recursive, path-scoped capability** held by an actor over a room or a path within it. An owner has full access to its subtree *and* meta-authority (grant / revoke) over it. The document creator auto-owns the root path `/`; multiple owners per path are allowed. Owners live as **doc-level ACL state** (the CRDT tier), self-organized at runtime — never declared in the schema. The **creator binding itself is not CRDT state** — it is the first authenticated writer, which the document cannot name — so it is server-side **room metadata, replicated with the room** and durable beside it, alongside the room's governing `{app, version}` binding and its op-version high-water: the three facts a replica needs to decide how the state it holds is read, none of which the ops or the snapshot bytes carry. A replica that holds a room must therefore hold the root its redactions resolve against: a node holding only the ACL tuples holds the grants without the authority they are decided under, and evaluates every one of them as inert. The root rides the room's replication frames, and — since it is established by facts the room's ops do not always accompany, a write the room's dedup swallowed whole and a replica whose durable record was lost being the two — on a **metadata-only frame of its own** where no batch is going that way. That frame carries no stream position and so **creates no room**: a node that does not hold the room is converged by the ops or snapshot catch-up, each of which carries the root itself, and a frame that created one would leave an empty replica the node then advertises as servable. A room that has reached no sequence is the one case with neither — nothing to converge a replica with, and no tuples for a root to decide — so it is carried nowhere. Each composes against what the replica already holds rather than replacing it — the root set-once, the high-water as a max, the binding on the incumbent-app rule — because a frame is an assertion by a peer, not an instruction.
- **Owner** — a **dynamic, recursive, path-scoped capability** held by an actor over a room or a path within it. An owner has full access to its subtree *and* meta-authority (grant / revoke) over it. The document creator auto-owns the root path `/`; multiple owners per path are allowed. Owners live as **doc-level ACL state** (the CRDT tier), self-organized at runtime — never declared in the schema. The **creator binding itself is not CRDT state** — it is the first authenticated actor to present the room a write, which the document cannot name — so it is server-side **room metadata, replicated with the room** and durable beside it. **A no-op frame reserves nothing**, and it takes two conditions to say so, because the reservation has two shapes. *The room must have reached a sequence* — it owns `/` and every deny is decided under it, and a room at sequence zero has had nothing put in it to be the authority over — which stops a frame carrying nothing from *minting* a room and owning it. That one is stated at the single seam every actor-asserted root composes through (a client write, a peer's ops frame, and the metadata-only frame below), so a root asserted by a peer is judged like one a write establishes, and stated as a sequence rather than as retention because that is what the seam can check: this node's own count of what it took. *And the batch must have presented an op* — which stops the same frame from taking `/` over a room that already holds content and has no root, a real state left by an anonymous establishing commit or by a replica whose metadata write was lost. That one is a statement about what a client offered, so it lives at the client write seam alone: the replication callers adopt a root established elsewhere, and the metadata-only frame is by construction the one with no batch beneath it. It deliberately does **not** reach the two seams where a root arrives *with a state* — a snapshot install and the durable record read back off the store — because there the sequence came in beside the root rather than being counted here, and no honest catch-up names zero at all: gating those refuses only a sender that would name a nonzero floor instead, while leaving a state full of content **rootless** and dropping a root the node itself established once a transfer has left the room at zero. Both are the inert-deny hole reached from the other side, which is worse than the reservation the rule refuses. The line between the two is what the batch *presented*, not what landed: a write the room's dedup swallows whole still roots a room that already holds ops, because presenting the room's content is something only a replica holding it can do, while an empty frame presents nothing and costs its sender nothing. Refusing the resend as well would buy attributability — the fresh-op route leaves an op in the log under the taker's id and claims that identity, the deduped route leaves no record of who took `/` — and cost an attacker one op; what it would cost the design is the only shape by which a root is established with no batch to replicate it on. It is durable alongside the room's governing `{app, version}` binding and its op-version high-water: the three facts a replica needs to decide how the state it holds is read, none of which the ops or the snapshot bytes carry. A replica that holds a room must therefore hold the root its redactions resolve against: a node holding only the ACL tuples holds the grants without the authority they are decided under, and evaluates every one of them as inert. The root rides the room's replication frames, and — since it is established by facts the room's ops do not always accompany, a write the room's dedup swallowed whole and a replica whose durable record was lost being the two — on a **metadata-only frame of its own** where no batch is going that way. That frame carries no stream position and so **creates no room**: a node that does not hold the room is converged by the ops or snapshot catch-up, each of which carries the root itself, and a frame that created one would leave an empty replica the node then advertises as servable. A room that has reached no sequence is carried nowhere: the frame creates no room, so a node holding none is unreachable by it, and one that holds a zero-sequence room establishes no root there by the rule above. That room is not necessarily empty — a state install takes its floor from its frame and its content from its bytes — so a replica of one that lost its root has a repair nothing currently carries. Each composes against what the replica already holds rather than replacing it — the root set-once, the high-water as a max, the binding on the incumbent-app rule — because a frame is an assertion by a peer, not an instruction.
- **Role** — a static, schema-declared name (`viewer` / `editor`) whose powers are the schema `@auth` grants. Membership is two-source: a **token** claim (global, for app-wide roles) or a **doc-level ACL** assignment to an actor or group (per-doc — the usual case). The schema defines what a role *can do*; who *has* it is a token claim or a per-doc grant, never the schema.

## Ownership (Dynamic Capability Model)
Expand Down
14 changes: 14 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ Log of design changes to [ARCHITECTURE.md](ARCHITECTURE.md) that implementation
The entries below (2026-07-02) are a backfill: design changes made during the v0.1→v0.2 build that predate this log, recovered from the sessions and commit history.


## 2026-08-09 · C99 no-op root reservation · a no-op frame reserves nothing, and saying so takes two conditions — one about the room, one about the batch — which is why they sit at different seams

**Changed:** ARCHITECTURE §Owner gains both conditions on the creator binding, the seam each is stated at, and why they cannot be the same seam. No wire change, no state change — the establishment rule narrowed.

**The defect, reproduced first.** `ensure_creator` fired on any `Ok` from `Hub::ingest`, and an empty batch answers `Ok(vec![])` — while the ingest's `entry(room).or_insert_with(Room::new)` **materialises the room** on the way through. So the first authenticated actor to send a no-op `Ops` frame at an unestablished room became its creator: the doc-ACL authority root that owns `/`, over a room at sequence zero, having authored no byte the room retains. Reproduced at the wire, and with the privilege it actually steals rather than the field: the creator is exempt from every deny in the room, so a squatter that holds `/` reads straight through the room author's `Deny(Read)` aimed at it. The same reservation reached the replication path twice — an empty `Replicate`, which ingests identically and creates the room on the follower, and a `ReplicateMeta` at a room held and empty.

**Two conditions, because the reservation has two shapes, and a falsification pass found the second still open after the first shipped.** The room-level condition — no root over a room at sequence zero — lives in `may_stand_as_root` at `Hub::ensure_creator`, which a client's write, a peer's `Replicate` and the metadata-only `ReplicateMeta` all compose through, so the replication path is judged by it rather than left with the hole. That is the trap the filing named, and it forces the *shape*: "only a batch that landed" (C23's rule one tier down) is not statable at `ReplicateMeta`, which has no batch beneath it by construction (C55). But being a rule about the room, it says nothing once the room has reached a sequence — and **a room holding content with no root is a real state**, left by an anonymous establishing commit or by a replica whose best-effort metadata write was lost, which are C55's own two routes. There a frame carrying nothing satisfied the room rule and took `/` over content its sender had no part in; measured at the wire, with the squatter then reading through the room author's deny, and permanent, since set-once locks the content's authors out afterwards. The condition that refuses it is a statement about what the batch *presented*, so it sits at the client write seam alone: the replication callers adopt a root established elsewhere, and a peer asserting one it did not earn is refused by the replica-set gate, not by this one.

**The line is what the batch presented, not what landed.** A resend the room dedups whole still roots a room that already holds ops — presenting the room's content is something only a replica holding it can do, so an attacker must obtain it, where an empty frame presents nothing and costs its sender nothing. C55 left this open for C99 to rule and built `ReplicateMeta` to replicate the root either way. Refusing the resend as well would buy **attributability** — the fresh-op route leaves an op under the taker's `ClientId` and claims that identity, the deduped route leaves the room's state with no record of who took `/` — at the cost of C55's route 1, whose only shape is a root established by a write the dedup swallowed whole, against an attacker who pays one op to route around it. C23 ruled the other way one tier down, where the act *is* the taking of an identity another replica's historic ops wrote; a room's root is the room's, not another actor's, so the same act does not carry the same theft.

**Where the rule stops was measured, not chosen.** A first cut also applied the room condition at the two seams where a root arrives *with a state* — a snapshot install, and the record read back off the store — for uniformity. Falsification measured both as worse than the defect: a state full of content whose frame names floor zero came up **rootless**, leaving every doc-ACL tuple in it with no authority to be decided under, and a room a state transfer had left at sequence zero lost, on its next reload, a root the node itself established over content it still holds. Both are C29's inert-deny hole reached from the other side, and both were found by falsification rather than by the suite — the first cut's tests all passed. The distinction that survives is *whose number the sequence is*: this node's own count at the write seam, and a number that came in beside the root at the other two, where no honest catch-up even names zero (a `Snapshot` is served only below a room's floor). That last point cuts both ways and is recorded rather than glossed: the only producer of content-at-floor-zero is a buggy or hostile sender, so refusing its root is not obviously a loss — what is not circular is the *reload*, which has no frame at all and drops a root over content the node still holds. C141 owns the floor-zero state itself.

**A rooted room at sequence zero is not the empty thing it reads as.** A state install takes its floor from its frame and its content from its bytes, so such a room can hold a whole document, tuples included. C55's `catch_up_room_frame` guard against dialing a root for a zero-sequence room stands — the frame creates no room, and a node that holds one refuses the root on arrival by the same rule — but its stated reasons ("a follower holds no such room", "no ACL tuples for a root to decide") were both false and are corrected; the residue is filed as C141. The C55 test that built its zero-sequence fixture out of this defect now builds it from the install.

## 2026-08-09 · C47 minority `count` rewrite · a bucket whose members disagree **spends its key** rather than holding — reversing what C3 decided a disagreement means

**Changed:** ARCHITECTURE §Opt-In: Atomic, and the change is an **inversion rather than an extension** — a reader six months out should see that C3's "a disagreement means hold" was overturned deliberately. The sentence "a bucket whose members disagree names no group and is never complete" stood alone as the bound on a rewritten `count`; it now continues into the rule that such a bucket resolves its key at the point it disagrees. Three tests that pinned the hold inverted with it, named below; the C3 entry in this file and the C3 and C21 entries on the board gained forward pointers so the superseded rule is not read as current, and C21's entry here is corrected where this unit overtakes it. The paragraph listing what the C21 record deliberately does not take loses the minority rewrite from its residue and narrows "does not release a bucket that merely *looks* unreachable" to the count a bucket is short of, which is the case that argument was ever about. No wire or state format moves: the key lands in `resolved_tx`, which `STATE_VERSION` 13 already carries.
Expand Down
Loading
Loading