From aed76bf085a657fe3e7992da4f3bedece992a010 Mon Sep 17 00:00:00 2001 From: Joep Meindertsma Date: Tue, 1 Sep 2026 20:54:45 +0200 Subject: [PATCH 1/3] docs(planning): five architecture decision documents Add RFC-style decision documents, each with a "Decision needed by maintainer" box and consequences for open PRs: - runtime-boundary-decision.md: Rust decides, TS displays; twins only for pure fixtured functions; first AtomicNode slice. - authority-unit-decision.md: drive stays the identity/replication unit; additive zone chain is the rights unit; what #1254 must change. - commit-retention-floor-decision.md: envelope-on-resource floor; sequence #1274 -> #1313 -> #1254. - trust-model-decision.md: the node that owns the URL is trusted with plaintext; anything that only stores is blind. - schema-routes-decision.md: did:ad:frozen is the on-ramp; optional schema is policy; defaults fingerprint closes the repopulate gap. Index them under "Decisions pending" in planning/README.md. Claude-Session: https://claude.ai/code/session_019asLKBrBWY5ovyeCgtmdSd --- planning/README.md | 15 ++ planning/authority-unit-decision.md | 176 +++++++++++++++ planning/commit-retention-floor-decision.md | 225 ++++++++++++++++++++ planning/runtime-boundary-decision.md | 190 +++++++++++++++++ planning/schema-routes-decision.md | 158 ++++++++++++++ planning/trust-model-decision.md | 179 ++++++++++++++++ 6 files changed, 943 insertions(+) create mode 100644 planning/authority-unit-decision.md create mode 100644 planning/commit-retention-floor-decision.md create mode 100644 planning/runtime-boundary-decision.md create mode 100644 planning/schema-routes-decision.md create mode 100644 planning/trust-model-decision.md diff --git a/planning/README.md b/planning/README.md index 490681a22d..ac734e0dad 100644 --- a/planning/README.md +++ b/planning/README.md @@ -17,6 +17,21 @@ Protocol reference lives in the public docs: discuss how that protocol is used internally, but should not duplicate the wire reference. +## Decisions pending + +Decision documents: one question each, written as an RFC with a recommendation. +Each starts with a **Decision needed by maintainer** box and ends with the +consequences for open PRs. Once decided, fold the outcome into the owning plan +and move the document to `completed/`. + +| Document | Question | Recommendation | Blocks | +| --- | --- | --- | --- | +| [`runtime-boundary-decision.md`](./runtime-boundary-decision.md) | Rust-only runtime vs twinned-by-design between `atomic_lib` and `@tomic/lib`. | Rust owns ingest, auth, sync, hashing, genesis; TS owns cache/reactivity/UI; twins only for pure functions with shared fixtures. First `AtomicNode` slice. | #1273, #1274, #1277, #1241, #1278 | +| [`authority-unit-decision.md`](./authority-unit-decision.md) | Drive vs zone as the unit of authority; additive creator chain vs replace-and-replay. | Drive stays the identity/replication unit; zones only as a rights unit inside a drive, additive. | #1254, #1307, #1310 | +| [`commit-retention-floor-decision.md`](./commit-retention-floor-decision.md) | What must be retained for authorization and audit before commits become envelopes. | Envelope-on-resource floor; sequence #1274 → #1313 → #1254. | #1313, #1250, #1254 | +| [`trust-model-decision.md`](./trust-model-decision.md) | Blind vs trusted server. | The node that owns the URL is trusted with plaintext; the Vault is blind. Close `encryption.md` to at-rest + vault. | #1310, #1307, #1254 | +| [`schema-routes-decision.md`](./schema-routes-decision.md) | Optional schema vs `did:ad:frozen` vs `lib/defaults/*.json`; the `--repopulate-defaults` gap. | One on-ramp; see document for the fate of each PR. | #1316, #1245, #1262, #1209, #1251 | + ## Active Remaining work, not "this file exists." diff --git a/planning/authority-unit-decision.md b/planning/authority-unit-decision.md new file mode 100644 index 0000000000..a910923302 --- /dev/null +++ b/planning/authority-unit-decision.md @@ -0,0 +1,176 @@ +# Unit of authority: drive, zone, or hybrid + +**Status:** Decision requested (2026-09-01). + +> **Decision needed by maintainer** +> +> Question 1: what is the unit of authority — the drive, the zone (nearest ACL-bearing ancestor), or a hybrid? +> Question 2: how are rights derived — additive creator-chain (creator implicit + grants ascend the parent chain, on top of what ships) or replace-and-replay (nearest zone ACL replaces outer ACLs; verifiers replay `AuthImpact` commits)? +> Options: (A) drive-as-authority (B) zone-as-authority (C) hybrid: drive = identity/replication/fan-out/index unit, zone = rights unit within a drive. Axis 2: (A2) additive creator-chain (B2) replace-and-replay. +> Recommendation: **C + A2** — the four shipped drive-keyed systems are all identity/transport, none is a rights model, so drive stays there; rights move to zones but stay additive because replace semantics turns every existing DID resource (creator auto-inserted in `write`) into a zone root with no migration. +> Blocked PRs: #1254 (must change), #1307 (semantics depend on A2), #1310 (creator check must key on root DID). + +## Context + +Four shipped systems are keyed by drive. None of them is the rights model; the rights +model (`check_rights`) merely *consults* one of them as a fast path. + +| # | System | Where (verified) | What it keys on | +| --- | --- | --- | --- | +| S1 | Genesis cert signs `drive` into identity | `lib/src/genesis.rs:57-61` (`GenesisCert.drive`), encoded at `:97-103`; minted in `lib/src/commit.rs:239-305` | Immutable birth drive inside the signed DID. Cannot hold a mutable value (a zone is mutable). | +| S2 | WS fan-out and drive stamp | `server/src/commit_monitor.rs:57` `drive_subscriptions: HashMap` keyed by drive subject; handler matches `resource.get_drive()` via `Subject::is_within_drive` (`:815-829`); `Resource::get_drive` `lib/src/resources.rs:657`; stamp re-derived on genesis and re-parent in `lib/src/commit.rs:866-897` | Mutable `drive` propval, server-derived from the parent, never trusted from the client. | +| S3 | `(drive, property)` watched-query index | `lib/src/db/query_index.rs:31-40` (`QueryFilter.drive` mandatory), routing at `:479-485`; `watched_queries_by_drive` `lib/src/db.rs:304` | Drive prefix for HTTP subjects; **`did:` atoms already fall back to every drive's property bucket** (`query_index.rs:480-481`) — the cert's `drive` is not yet used here. | +| S4 | Deterministic personal drive | `lib/src/genesis.rs:180-200` (`for_private_drive`, `private_drive_subject`); `Db::create_drive` seeds `write`/`read` at `lib/src/db.rs:836-850`; [`deterministic-personal-drive.md`](./deterministic-personal-drive.md) landed | Drive DID derived from the agent key. The drive is a fact about identity. | + +Rights today (`lib/src/hierarchy.rs:223-395`): preludes (sudo, self, server agent, public +agent read) → explicit grant on the resource → **drive-first fast path** on the `drive` +propval (`:325-350`, added for the parent-before-child 401 race, comment at +`lib/src/commit.rs:239-244`) → recursive parent ascent (`:354-366`). Additive: first grant +wins, no deny. `RightsCache` (`hierarchy.rs:121-123`) is per-request, keyed +`(right, subject.pure_id())`, never invalidated — it is dropped with the request. +`apply_commit` still auto-inserts the genesis signer into `write` on every new DID +resource (`lib/src/commit.rs:836-858`), so **every existing DID resource carries an +explicit `write` array**. The browser reimplements the additive walk in +`browser/lib/src/resource.ts:1328-1390` (`canWrite`) with no genesis-signer check. + +`AuthImpact` (`hierarchy.rs:49-90`) classifies genesis/read/write/append/parent/destroy +commits; retention of exactly those is what [`authorization-sync.md`](./authorization-sync.md) +Phase 2 and PR #1313 ("genesis and rights/parent/destroy still are [stored]") rely on. + +What the sources propose: [`zones.md`](./zones.md) — zone = unit of ACL *and* sync, quota, +keys; nested ACL **replaces** outer; `drive` stamp removed from authored state; index +derived. [`authorization-sync.md`](./authorization-sync.md) — implicit creator write +(`effective_write = {genesis_signer} ∪ explicit_write` plus inherited), remove the +auto-insert, replay grant chains. [`genesis-self-verifying.md`](./genesis-self-verifying.md) +— `drive` in the cert because "a resource effectively never moves between drives". +[`partial-sync.md`](./partial-sync.md) — wants subtree/zone scope, but notes it works +"without zones" over `collect_drive_subjects` (its lines 64-70). + +## Options + +### Axis 1 — unit of authority + +| Cost against | (A) Drive-as-authority | (B) Zone-as-authority ([`zones.md`](./zones.md) as written) | (C) Hybrid: drive = identity/replication, zone = rights within a drive | +| --- | --- | --- | --- | +| S1 cert `drive` | Unchanged; the "read the cert's `drive` in `check_rights`" item stays. | Field becomes provenance only. Zone cannot be signed in (mutable). Genesis "gets smaller" per zones.md — a v2 cert layout. | Unchanged; cert `drive` = replication root, still the race-free fast path. | +| S2 fan-out / stamp | Unchanged. | Re-key `drive_subscriptions` to zone; clients subscribe per zone; promote/demote re-keys live subscriptions and re-stamps nothing (index is derived) — but every open tab must re-subscribe. Stamp removed from authored state (`commit.rs:866-897` deleted). | Unchanged. Stamp stays server-derived (as #1254 already keeps it). | +| S3 `(drive, property)` index | Unchanged. | `QueryFilter.drive` → zone; promotion re-buckets every filter in the subtree; DID fallback (`query_index.rs:480`) unchanged either way. | Unchanged; zone index may later shrink the DID fallback (zones.md OQ5). | +| S4 personal drive | Unchanged. | Personal drive = the agent's born zone; fine, but "drive" survives as UX class only — a rename across `Db::create_drive`, `getDrive()`, sync policy (`lib/src/sync/policy.rs:39-43`). | Unchanged. Personal drive is the outermost zone of the agent. | +| Rights model | Keeps the walk; mid-tree grants remain the "defensive code" zones.md names. | Walk-free after a persisted index; until then O(depth) derivation (#1254 bench: deny still walks). | Zone resolution replaces the per-ancestor walk; drive fast path kept for the outermost zone. | +| Verdict | Cheapest, fixes nothing. | Touches all four shipped systems for a benefit only the rights model needs. | Touches none of the four; confines the change to `hierarchy.rs`, `zones.rs`, `resource.ts`. | + +### Axis 2 — how rights are derived + +| Cost against | (A2) Additive creator-chain | (B2) Replace-and-replay | +| --- | --- | --- | +| Existing data | Auto-inserted creator entries (`commit.rs:836-858`) become redundant, harmless. No migration. | **Every existing DID resource is a zone root** (it has a `write` array). Drive-level grants stop applying to all existing children. Requires a store-wide rewrite of signed Loro state to strip creator entries, or a "creator-only ACL is not a zone" special case that reintroduces the hierarchy as security. | +| #1307 apps | `createApp` grants the app agent `write` on the app; "rights ascend the parent chain" (PR #1307 body, `browser/lib/src/plugin-app.ts:183-204`). Works. | The app resource becomes a zone root with ACL `[appAgent]`; drive collaborators lose access to the app subtree unless re-granted there. Silent behaviour change. | +| Narrowing (private folder in shared drive, "un-share") | Impossible — first grant wins. | Native: nested ACL replaces outer. This is the one thing only B2 delivers. | +| Race-free creation | Drive fast path kept. | `resolve_zone` fails when a parent is not materialized ("reject, not quarantine") — the 401 race the stamp was added for (`commit.rs:239-244`) comes back; #1254 removes the fast path (diff, `hierarchy.rs`). | +| Replay / audit | Same `AuthImpact` retention; proof = additive chain at accept time. | Same retention; needs zone state at commit time, i.e. a persisted, versioned zone index (zones.md OQ2, not built). | +| Browser `canWrite` | Add a genesis-signer check. | Full zone-map reimplementation in TS (zones.md impact table), not in #1254. | +| Verdict | Ships now on top of what exists. | Correct end state; blocked on migration, persisted index, and a client rewrite. | + +## Recommendation + +**C + A2.** Rule: *the drive is where a resource lives and replicates; the zone chain is +who may touch it; the creator always may.* + +Effective rights under C + A2: + +```text +zone(R) = nearest ancestor of R (or R) carrying read/write/append, or parentless +zone_chain(R) = zone(R), zone(parent(zone(R))), … up to the drive root +effective(R, r) = {genesis_signer(R)} ∪ ⋃ ACL(z, r) for z in zone_chain(R) (write ⇒ read, append) +``` + +The chain has one entry per ACL-bearing ancestor, not per tree level; `RightsCache` keys +on `(right, zone_root)` and the drive fast path answers the outermost entry first. Replace +semantics (B2) is deferred, not rejected: it becomes an explicit per-zone opt-in flag once +steps 5–6 below exist, so no existing resource changes meaning by accident. + +Migration order: + +1. Implicit creator write, standalone: delete the auto-insert (`lib/src/commit.rs:836-858`), + add `agent_is_resource_creator` (genesis signer via `Resource::genesis_signer`, + `lib/src/resources.rs:148`) as a prelude in `check_rights_impl`, and add the same + check to `browser/lib/src/resource.ts` `canWrite` (`getCreatedBy`). Test: creator of a + guest reply in a shared drive can still edit it in the UI. +2. `check_append` = append-or-write on the parent chain only; drop the fallback to write on + the new child (#1254's "implicit creator write hole" fix). Standalone PR. +3. Zone resolution as an accelerator: `lib/src/zones.rs` (`is_zone_root`, `resolve_zone`), + `check_rights_impl` walks `zone_chain` instead of every parent, drive fast path kept, + `RightsCache` keyed on zone root. Semantics identical to today; `rights_bench` guards it. +4. Store-wide verification that `drive` stamp == derived drive root (zones.md migration + step 1; `drive_stamp_matches_zone` in #1254's `zones.rs`), as a `Db` audit, not a + commit path. +5. Persisted zone index (`subject → zone_root`, `zone_root → enclosing zone`), maintained + from `AuthImpact` commits; browser mirror in the store. Closes zones.md OQ2. +6. Replace semantics behind an explicit zone marker, plus a one-time migration that strips + creator entries equal to the genesis signer from `write`. Only now may B2 be enabled. +7. Zone-scoped sync/quota (`collect_zone_subjects`, [`partial-sync.md`](./partial-sync.md)) + — drive remains the sync unit until then. + +### What PR #1254 must change if zones win as the rights unit (C + A2) + +- `lib/src/hierarchy.rs` (diff): after `agent_in_zone_acl` returns `None`, continue to + `zone(parent(zone))` until parentless — do not return 401 at the first zone. Restore the + deleted drive-first block (`develop` `hierarchy.rs:325-350`) ahead of `resolve_zone`. + Keep the `check_append` change. Keep the creator prelude. +- `RightsCache` key: `(right, zone_root.pure_id())` as the PR has it; the cached deny must + be allowed to short-circuit members (the PR's `Some(false)` branch only short-circuits + when `zone == resource`, so every member re-runs `agent_in_zone_acl`). Still per-request, + no invalidation needed. +- `lib/src/zones.rs`: `resolve_zone` must not error when the chain is broken while a + `drive` stamp exists — fall back to the stamp (the race). `collect_zone_subjects` is + unused by any sync caller in the PR: remove it from this PR (axis 1 keeps drive as sync unit). +- `lib/src/commit.rs` (diff): auto-insert removal stays; stamp re-derivation stays (the PR + already keeps it, "remains a transport/fan-out stamp"). +- Data model: no new authored field. `drive` propval stays authored-but-server-derived; + zone membership stays derived. WS fan-out key: unchanged, `drive_subscriptions` keyed by + drive subject (`server/src/commit_monitor.rs:57`) — the PR does not touch it; keep it so. +- `browser/lib/src/resource.ts` is not in the PR: `canWrite` needs the genesis-signer + check or creators outside `write` render read-only after the auto-insert is gone. +- `docs/src/hierarchy.md` (diff): must say "nearest zone first, then enclosing zones, + additive", not "replaces". +- Split out `lib/src/discovery.rs` agent-keyed pkarr, `didResolve.ts`, DID open/share + hints, Android manifests, `planning/atomic-uris.md`: orthogonal to rights. + +### What PR #1254 must change if drives stay (A) + +- Drop `lib/src/zones.rs`, the `hierarchy.rs` rewrite and `docs/src/hierarchy.md`. Keep + only steps 1–2 above (creator write, `check_append`) plus the discovery split. The + benchmark (`lib/benches/rights_bench.rs`) can land either way. + +## Consequences for open PRs + +- **#1254** — change: restructure to C + A2 per the list above (additive zone chain, drive + fast path restored, `resolve_zone` fallback to stamp, `canWrite` creator check, docs + wording, `collect_zone_subjects` out); split discovery/DID-open into its own PR. + Merge order: after #1313 (retention floor), and after steps 1–2 land as small PRs, or + as those PRs. +- **#1307** — merge-as-is on the rights model; its "rights ascend the parent chain" and + the `write` grant on the app (`browser/lib/src/plugin-app.ts:183-204`, `check_write` in + `server/src/plugins/store_host.rs:88-99`) are exactly A2. Rebase after #1254 step 1: the + app agent is a genesis signer of its rows, so implicit creator write makes the app's + child writes valid even if the grant on the app is later removed — document that. + `AppAgentKey::new(drive, subject)` keys on drive: consistent with C. +- **#1310** — change (planning text): "genesis `write` insert" no longer exists after step + 1; the creator check compares `GenesisCert.signerPubKey` (the session key) with the + agent — under SessionCert it must compare the **root** DID, so either `effective_agent` + remaps before `agent_is_resource_creator` and the cert carries the root pubkey, or + session-created resources lose implicit creator write when the session expires. State + which. Personal-drive derivation from the root key (S4) is unaffected. +- **#1313** — no change from this decision: keeping genesis/rights/parent/destroy commits is + the floor for both A2 and a later B2 replay. It lands **before** #1254 (sequence + #1274 → #1313 → #1254, see + [`commit-retention-floor-decision.md`](./commit-retention-floor-decision.md), which asks + #1313 for an envelope change); #1254 rebases onto it (both touch `lib/src/hierarchy.rs`, + `lib/src/commit.rs`, `browser/lib/src/resource.ts`). +- **#1274** — merge-as-is: `authorize_read` in `server/src/commit_monitor.rs` keeps the + drive as the subscribe-auth unit, which C preserves. Rebase-after #1254 only if #1254 + ever touches `commit_monitor.rs` (it should not). +- **#1243** — no rights impact found (`commit_monitor.rs` diff adds push-notification + helpers only). Merge independently. +- **#1260**, **#1264** — touch `resource.ts` / CMS publish visibility respectively; no + rights-model change verified. Rebase #1260 after #1254's `canWrite` change. diff --git a/planning/commit-retention-floor-decision.md b/planning/commit-retention-floor-decision.md new file mode 100644 index 0000000000..8ae3ca94da --- /dev/null +++ b/planning/commit-retention-floor-decision.md @@ -0,0 +1,225 @@ +# Commit retention floor + +**Status:** Decision requested (2026-09-01). + +> **Decision needed by maintainer** +> +> Question: what MUST a node keep of a signed commit after apply, so that authorization still verifies and audit still attributes — and can #1313 merge on that floor? +> Options: (A) keep the full commit log as today. (B) #1313 as proposed: drop content commits, keep only genesis/rights/parent/destroy rows. (C) envelope-on-resource: every resource keeps its latest signed envelope(s) in a side tree; the Loro oplog is the history; older envelopes are node-policy retention. +> Recommendation: **C** — authorization needs state, not a log; audit needs the signed bytes for the state you are looking at, which (B) throws away and (A) never replicates. +> Blocked PRs: #1313 (change), #1254 (rebase after #1313), #1274 (merge first). + +## Context + +**Storage today.** There is no commit tree. `enum Tree` in `lib/src/db/trees.rs` +has `Resources`, `LoroSnapshots`, `PropValSub`, `ValPropSub`, `QueryMembers`, +`WatchedQueries`, `PluginMeta`, `DriveMapping`, `DidMapping`, `Blobs` — nothing +commit-specific. A commit is stored as an ordinary resource row +`did:ad:commit:` in `Tree::Resources` (`lib/src/db.rs` `add_resource_tx`, +which keeps `loroUpdate` inside the blob only for commit subjects) and every +one of its atoms is indexed into `PropValSub`/`ValPropSub` +(`lib/src/db.rs` ~L3090: `add_resource_tx(&commit_resource)` + `add_atom_to_index` +per atom, unconditionally; same in `lib/src/storelike.rs` `apply_commit`). Commits +get no `LoroSnapshots` row (`db.rs` ~L2980). A `/commits` class collection is +populated (`lib/src/populate.rs` L226). + +**Lookup today.** By subject (`get_resource("did:ad:commit:…")`) or by the +`subject` property index (`Query` on `urls::SUBJECT`). Rights on a commit row +are the rights of its target (`lib/src/hierarchy.rs` ~L270). + +**What consults commits for authorization.** Nothing. `check_rights` +(`lib/src/hierarchy.rs`) reads `read`/`write`/`append` propvals on the +resource projection and walks `parent`. Creator identity comes from the inline +`genesis` certificate propval verified against the DID +(`lib/src/resources.rs` `genesis_signer`, `lib/src/genesis.rs`), not from the +genesis commit row; legacy resources minted before the cert (DID == commit +signature) are the exception. `validate_previous_commit` is off on every +production apply path (`lib/src/sync/engine.rs` `ingest_commit_json`, +`lib/src/sync/ws_apply.rs`). Destroy is remembered as a tombstone in +`Tree::PluginMeta` (`lib/src/sync/tombstones.rs`), not as a commit. + +**What consults commits for audit/UI.** `browser/data-browser/src/components/CommitDetail.tsx` +fetches `lastCommit` to render signer/date (used by `ArticlePage.tsx`, +`ListView.tsx`, `ResourcePageDefault.tsx`). The History page does not: it +reads the Loro oplog (`browser/lib/src/resource.ts` `getLoroHistory` over +`doc.getAllChanges()`, `browser/data-browser/src/routes/History/useVersions.ts`) +and only uses the `lastCommit` propval of a version for a "Show Commit" link +(`HistoryDesktopView.tsx` L67–102). Server `/all-versions` and `/version` are +already Loro-backed (`server/src/plugins/versioning.rs`, `lib/src/history.rs`); +the "Phase 3" blocker in +[`commit-retention-and-state-certificates.md`](./commit-retention-and-state-certificates.md) +is stale. The oplog change message is a random drain token (`browser/lib/src/store.ts` +~L1438 `c-`), so the oplog knows *what/when/peer-hex*, never *which agent*. + +**What replicates commits.** Nothing. `SYNC_PUSH` entries are +`[subject][loro_bytes]` (`lib/src/sync/protocol.rs` `encode_sync_push`); +`UPDATE` carries a `commit_id` string, not the envelope (`decode_update`, +`flags::HAS_COMMIT_ID`); the drive walk skips commits by construction +(`lib/src/sync/engine.rs` `collect_drive_subjects`, BFS over `parent`). The +vault format stores **no commits**: `lib/src/vault/pack.rs` `PackEntry { subject, update }` +is `export_updates_since` bytes per drive subject plus tombstones +(`lib/src/vault/sync.rs` `export_vault_delta`); the encrypted-vault spec +([`encrypted-vault-format.md`](./encrypted-vault-format.md)) never mentions commits. +Only the live `COMMIT` frame (`0x13`, `[request_id][commit_json]`) moves an +envelope, and every receiver discards it after apply except the hub's own +`Tree::Resources` row. A device bootstrapped by bulk sync or vault restore has +zero envelopes today, under every option. + +**The floor as written.** [`commit-retention-and-state-certificates.md`](./commit-retention-and-state-certificates.md) +"Required persistence": Loro snapshot/oplog, projection, tombstones, sync +metadata; genesis always retained. [`authorization-sync.md`](./authorization-sync.md) +§ "Relationship to node-level retention policy": the floor is genesis + +rights-changing (`read`/`write`/`append`) + parent-changing + destroy commits, +"regardless of node policy or class" — and § P2 notes retention is moot until +pruning exists. `hierarchy::AuthImpact::is_critical()` is that classifier, +already on `develop`. PR #1313's branch-only +`planning/auditability-loro-history.md` adds the product bar: History must be +verifiable on every replica ("`git clone` then `git log`"), so envelopes must +travel *with the resource*. + +## The retention floor + +Invariants per resource R. "Authz" = needed to decide or re-verify rights on +this node; "Audit" = needed to attribute a state to a signer. + +| # | Invariant | Needed for | Argument (source) | Carrier today | +| --- | --- | --- | --- | --- | +| F1 | Genesis identity: the inline `genesis` cert (or, legacy, the genesis commit row) | Authz | DID = signature over the cert; creator = implicit writer (`authorization-sync.md` § implicit creator write; #1254 `agent_is_resource_creator`) | `genesis` propval in the Loro doc; `Tree::Resources` row for legacy DIDs | +| F2 | Current rights state: `read`/`write`/`append`/`parent` (+ `drive` stamp) in the projection | Authz | `check_rights` reads only this (`hierarchy.rs`) | `Tree::Resources` + `Tree::LoroSnapshots` | +| F3 | Grant-chain evidence: the signed commits that changed F2 (`AuthImpact::is_critical`) | Authz only for a **replica that does not trust its hub** (`authorization-sync.md` P3/P4, not built); Audit otherwise | Without them a granted replica cannot explain why signer S was allowed at time T | `Tree::Resources` rows, hub only | +| F4 | Destroy evidence: tombstone + signed destroy commit | Authz (anti-resurrection: tombstone); Audit (who destroyed: commit) | `is_tombstoned` gates `import_sync_push` (`tombstones.rs`); vault packs carry tombstones | `Tree::PluginMeta`; commit row | +| F5 | Loro oplog | Audit (history: what/when), sync causality | `getLoroHistory`, `history::versions`; oplog has no agent | `Tree::LoroSnapshots`, vault packs | +| F6 | Latest signed envelope for the current state of R | Audit (who signed what you see, offline-verifiable); echo-dedup needs only the id | `commit_monitor.rs` L242/L556 read `lastCommit` id; verification needs the bytes (`serialize_deterministically_json_ad` + signature, `lib/src/commit.rs` ~L1181) | `lastCommit` id only; bytes in the hub's commit row | +| F7 | Every past envelope of R | Audit (per-change attribution) | `commit-retention…md` "What `retention=none` costs" | `Tree::Resources` rows, hub only | + +Authorization floor = F1 + F2 + F4-tombstone, plus F3 once grant-chain +verification ships. Audit floor = F6 at minimum; F7 is node/resource policy. +"Latest envelope per (resource, signer)" is not needed: rights are state (F2), +and a concurrent second writer's envelope becomes the previous one — keep it +under F7 policy, not the floor. `lastCommit` stays as a stamp; it does not +have to resolve to a resource. + +## Options + +| | (A) Full log (today) | (B) #1313 as proposed | (C) Envelope-on-resource | +| --- | --- | --- | --- | +| Rows per content commit | 1 resource row + ~7 index atoms (`db.rs` ~L3090) | 0 | 1 side-tree row, replaces the previous (F6); older rows per policy | +| Index/`all_resources` cost | Every commit ever signed is a `Tree::Resources` row scanned by `all_resources`/`build_index` | Critical commits only | None in `Tree::Resources`; critical commits as in (B) | +| F1–F4 (authz) | Yes | Yes (`is_critical` gate) | Yes (same gate) | +| F6 (who signed current state) | Hub only, via `did:ad:commit:` fetch | **Lost** — `lastCommit` points at nothing | Yes, on every replica that received it | +| F7 (per-change attribution) | Hub only | Lost | Policy (`recent`/`full` keep N rows per subject) | +| Bulk sync / vault / OPFS clone | No envelopes | No envelopes | Latest envelope travels with the snapshot | +| Wire change | None | None | `SYNC_PUSH` flag + optional entry field; pack format v2 | +| Verdict | Pays for a log nobody replicates or reads for rights | Correct authz floor; audit regresses to "Unattributed" everywhere and makes envelope-on-resource a second migration | Same authz floor; audit is a per-resource fact that syncs, not a class | + +(A) also carries a real footgun: the audit log is indexed as ordinary data +(`ValPropSub` on `Value::LoroDoc`, which #1313 stops), so commits show up in +queries and have to be filtered out by hand (`browser/lib/src/collection.ts` +`did:ad:commit:` strippers, `useGetDriveStructure.ts`, `useTableAggregates.ts`). + +(B) is a strict deletion. It is right that a commit is not a queryable class, +and its `is_critical()` gate is exactly F3/F4. But after it merges the only +node that ever held a content envelope drops it too, `CommitDetail` falls back +to `createdBy` (a forgeable propval, see `genesis_signer` doc comment), and the +follow-up doc on its own branch says the fix is to keep envelopes on the +resource. Merging (B) first means shipping the regression and then adding the +mechanism that (C) is. + +## Recommendation + +**C.** The rule: *a node must keep, per resource, the state the rights are +decided on and the latest signed envelope that produced it; everything older +is retention policy, and the Loro oplog — not commits — is the history.* + +### Minimal mechanism + +- **Where.** New `Tree::Envelopes` in `lib/src/db/trees.rs`, mapped to a + table in `lib/src/db/redb_store.rs` (`table_def`) and the sled/btreemap/OPFS + backends. Key: `pure_id ‖ 0x00 ‖ createdAt(u64 BE) ‖ 0x00 ‖ signature`. + Prefix-scan on `pure_id` lists a resource's retained envelopes in time order; + the last one is F6. Not a propval, not indexed, not a resource: it never + appears in queries or `all_resources`, so nothing needs a `did:ad:commit:` + filter. Critical commits (F3/F4) additionally keep their `Tree::Resources` + row as #1313 does, so `AuthorizationProof` (P3) can still find them by + `subject`. +- **What bytes.** The envelope is the signed JSON-AD exactly as `/commit` and + `COMMIT` accepted it: `commit_resource.to_json_ad()` (`lib/src/commit.rs` + `into_resource`), whose signature covers + `serialize_deterministically_json_ad` (JCS, sorted keys, no `signature`, no + `subject` for genesis). Verifying later = same code path as apply. v1 stores + the full body including `loroUpdate`; a header-only form is a later size win. +- **Write.** One place: after `validate_and_build_response` in + `Db::apply_commit` (`lib/src/db.rs` ~L3090) and `Storelike::apply_commit` + (`storelike.rs` L288), in the same transaction as the snapshot write, then + prune the prefix to `retention` (default `recent`: keep the last N, N≥1). + `lastCommit` stamping (`commit.rs` ~L977) is unchanged. +- **Sync.** Live `COMMIT` (0x13) already delivers the envelope; the receiver + persists instead of discarding. Bulk: add `sync_push_flags::WITH_ENVELOPES` + (`lib/src/sync/protocol.rs` `encode_sync_push`/`decode_sync_push`); when set, + each entry is `[subject][bytes_len u32][loro_bytes][env_len u32][envelope_json]` + with `env_len = 0` for "none". Sender attaches F6 only. Receiver verifies + the signature before storing (`Unattributed` on failure, never a forged + signer). `UPDATE` keeps `commit_id`; it does not need the body. WS and Iroh + share the encoder, so both transports get it at once. +- **Vault.** Bump `PACK_FORMAT` to 2 in `lib/src/vault/pack.rs` and add + `PackEntry.envelope: Option>` (F6 only). A v1 pack restores as + today with no envelopes; a v2 restore writes `Tree::Envelopes`. Older + envelopes (F7) are excluded from the vault: they are node policy, and the + vault's cost argument is "one word for a one-word change". +- **UI.** `CommitDetail` reads the local envelope via a `getLatestEnvelope` + accessor (WASM + JS store) and shows Verified/Unattributed; History rows + map a version's `lastCommit` id to a retained envelope when present. + +### Sequencing + +1. **#1274 first.** It collapses commit ingest to one entry point + (`ingest_commit_json` + `CommitIngestOpts::{hub,peer,replica}` in + `lib/src/sync/ingest.rs`; today `apply_commit` is called from + `sync/engine.rs`, `sync/ws_apply.rs`, `wasm/src/lib.rs`, + `flutter/rust/src/api/simple.rs`, `server/src/plugins/{chatroom,wasm}.rs`, + `db.rs` bootstrap paths). Retention must be one policy applied at one + gate; with six call sites it will drift. +2. **#1313 second, amended.** Keep its deletion (no content rows, no + `/commits`, no `previousCommit`, no `LoroDoc` index keys) and its + `is_critical` gate; add the `Tree::Envelopes` write and `SYNC_PUSH` + `WITH_ENVELOPES`. Then the same PR that stops storing commits starts + storing the F6 envelope — no window where attribution is gone. +3. **#1254 third.** Zones make effective write = `{genesis_signer} ∪ + explicit_write` and remove the auto-insert-into-`write` step. That is a + change to F2 semantics and it relies on F1 (`genesis_signer`) — it must + land on a store whose floor is already fixed, or the "what is auth + evidence" question gets answered twice. +4. Then `authorization-sync.md` P3 (`AuthorizationProof` over F3 rows) and + vault pack v2. + +## Consequences for open PRs + +- **#1313** — change: keep the deletion and the `is_critical()` gate; add + `Tree::Envelopes` + the F6 write in both `apply_commit` bodies, the + `SYNC_PUSH` `WITH_ENVELOPES` flag, and a `getLatestEnvelope` read for + `CommitDetail`/History. Move `auditability-loro-history.md` onto `develop` + with the side-tree decision recorded (its "prefer in-doc" open question 1–2 + is resolved: in-doc makes the envelope sign a doc that contains itself). + Rebase after #1274. +- **#1274** — merge first, as is. Only requirement: the single ingest gate + is where the retention write lands, so no new `apply_commit` call sites. +- **#1250** — merge as is; independent. Note that incremental `loroUpdate` + (157 B vs ~6.5 KB) makes F6/F7 envelopes cheap to keep, which strengthens C. +- **#1254** — rebase after #1313. Its removal of auto-insert-into-`write` is + consistent with F1/F2; its `check_append` fix touches F2 only. No commit + dependency. +- **#1279** — merge as is; no commits needed. `lib/src/git_export.rs` skips + `is_commit_did()` subjects and strips `lastCommit`/`previousCommit` from + sidecars; its docstring already says it is "not a replica of Loro history, + signed commits, or original DIDs". Git export is interchange, not audit. +- **`commit-retention-and-state-certificates.md`** — update after #1313: + Phase 3 (versioning plugin on Loro) is shipped; "genesis commits always + retained" becomes F1 (inline cert, legacy row); add F6 to "Required + persistence". +- **`authorization-sync.md`** — P2 "retention class is moot until pruning + exists" becomes true the day #1313 merges; the `Tree::Envelopes` prefix is + the "subject → retained auth-commit ids" index it asks for. + +Unverified: the exact per-commit index-atom count (depends on propvals +present); whether the browser OPFS DB stores commit rows via +`materializeCommitLocally` (browser side of F6 needs its own check). diff --git a/planning/runtime-boundary-decision.md b/planning/runtime-boundary-decision.md new file mode 100644 index 0000000000..404a96fa23 --- /dev/null +++ b/planning/runtime-boundary-decision.md @@ -0,0 +1,190 @@ +# Runtime boundary: Rust-only vs twinned-by-design + +**Status:** Decision requested (2026-09-01). + +> **Decision needed by maintainer** +> +> Question: Which logic may exist twice (Rust `atomic_lib` and TS `@tomic/lib`), and what do new SDKs bind to? +> Options: (A) Twinned-by-design — both libraries are full peers, parity by golden tests. (B) Rust-only — TS becomes a thin WASM binding, protocol logic leaves JS. (C) Rust-authoritative core, TS owns cache/reactivity/UI, twins only for pure functions bound by a shared fixture and the #1273 gate. +> Recommendation: **C** — Rust already owns every ingest/verify/authorize path and the crossing cost (#1278) rules out B for reads; A has no gate and is already drifting (commit canonical bytes, RBSR vector). +> Blocked PRs: #1277, #1241 (follow-up), #1307 (applier), #1313, #1311. + +## Context + +There is no runtime boundary today. `AtomicNode` and `lib/src/runtime/` from +[`atomic-lib-runtime.md`](./atomic-lib-runtime.md) do not exist: `git grep AtomicNode` +matches only `planning/*.md`; `lib/src/runtime` is absent. Instead there are +**five hand-rolled wrappers of `Db`**, none in `atomic_lib`: + +| Wrapper | Where | Size | +| --- | --- | --- | +| WASM `ClientDb` | `wasm/src/lib.rs` | 992 lines; `applyCommit` builds its own `CommitOpts` with every `validate_*` off (`wasm/src/lib.rs:195-204`) | +| Flutter FRB | `flutter/rust/src/api/simple.rs` | 1444 lines, ~60 `pub fn` mixing store, canvas, WS, Iroh | +| Python PyO3 | `python/src/store.rs` (PR #1277) | 420 added lines | +| Kotlin UniFFI | `ffi/src/store.rs` (PR #1277) | 266 added lines; same method list as Python | +| Actix handlers | `server/src/handlers/commit.rs`, `server/src/commit_monitor.rs` | third commit-ingest path per #1273 | + +#1277's own `planning/kotlin-sdk.md` calls `ffi/` "a thin slice of `atomic-lib-runtime.md` +`AtomicNode`" and lists Flutter `simple.rs` as a fourth caller to fold in later. #1241 lists +"split generic node API out of `simple.rs` → toward `AtomicNode`" as its next step. Both PRs +are building the node surface outside `lib/` because it does not exist inside it. + +### What the TS library actually does + +`@tomic/lib` **signs but never verifies**. `browser/lib/src/commit.ts` exports +`serializeDeterministically` and `CommitBuilder.signAt`; there is no signature check in the +file. Incoming commits go through `applyCommitToResource` → `execLoroUpdateCommit` +(`commit.ts:469-545`), a bare `resource.importLoroUpdate`. Rights checks in TS are +`Resource.canWrite` (`browser/lib/src/resource.ts:1328`), used only by the React hook +(`browser/react/src/hooks.ts:715`) to grey out UI. The authority for all of these is Rust: +`Commit::validate_signature` (`lib/src/commit.rs:363`), `validate_and_build_response` +(`:513`), `hierarchy::check_write/check_read/check_rights` (`lib/src/hierarchy.rs:93-214`). + +The WASM node already carries the browser's persisted copy: `ClientDb.applyCommit` parses +JSON-AD with `parse_json_ad_resource` and calls `Db::apply_commit` +(`wasm/src/lib.rs:178-210`). The JS `Resource`/`Store` is a cache + outbox + TipTap +`LoroDoc` on top of it. #1278 measured the crossing: JS `Resource.get(name)` ~0.07 µs vs +WASM `getResource` → JSON-AD ~75 µs, "~1000×"; native Ed25519 sign 24 µs vs noble JS 284 µs. +Its recommendation: "do not move `Resource.get` / the JS cache into WASM". + +### The twins, verified + +| Job | Rust | TS | Shared fixture | Verdict | +| --- | --- | --- | --- | --- | +| Commit canonical JSON + Ed25519 sign | `lib/src/commit.rs` `serialize_deterministically_json_ad` (:1181), `sign` (:1299) | `browser/lib/src/commit.ts` `serializeDeterministically` (:368), `signAt` (:213) | **None.** `sign.test.ts:24-39` pins an inline legacy `set` vector; Rust `signature_matches` (`commit.rs:1625`) no longer asserts bytes, only `validate_signature`. One-sided. | Keep twin (sign only). Add `testdata/commit-canonical.json` consumed by both. | +| Commit verify + apply | `commit.rs` `validate_signature`, `apply_changes` (:1014); `lib/src/sync/engine.rs` `ingest_commit_json` (:327); `wasm/src/lib.rs` `applyCommit` | `commit.ts` `applyCommitToResource` — import only, no verify | n/a | **Rust-only.** TS keeps in-memory Loro import for the UI doc; persistence and verification go through the WASM node. | +| Genesis cert encode/sign/verify | `lib/src/genesis.rs` `GenesisCert::{encode,decode,sign,verify}` (:67-226) | `browser/lib/src/genesis.ts` `encodeGenesisCert`, `signGenesisCert`, `verifyGenesisCert` (:79-234) | **Yes.** `lib/src/genesis_test_vectors.json`, loaded by `genesis.rs:545` (`matches_the_golden_vectors`), `genesis.test.ts:184`, and `flutter/test/atomic/signing_golden_vectors_test.dart:19`. | Keep twin. This is the model every other twin must copy. | +| RBSR item/range fingerprint + reconcile | `lib/src/sync/rbsr.rs` `item_fingerprint` (:44), `range_fingerprint` (:67), `reconcile` (:117); server side `server/src/handlers/web_sockets.rs:552` | `browser/lib/src/rbsr.ts` same three; client side runs `reconcile` in `websockets.ts:1085` | **Inline only.** Same hex in `rbsr.rs:414` and `rbsr.test.ts:37-40`; not a shared file. | Keep twin until `AtomicNode::sync_with` reaches WASM; promote vector to `testdata/`. | +| Canonical drive hash (SYNC_VV probe) | `lib/src/sync/engine.rs` `compute_drive_hash` (:570) | `browser/lib/src/canonical-drive-hash.ts` (40 lines) | **Inline only.** `lib/src/sync/tests.rs:2302` and `canonical-drive-hash.test.ts:11-14` pin the same hex. | Keep twin; [`drive-reconciliation.md`](./drive-reconciliation.md) calls byte-parity "the load-bearing task". Promote to `testdata/`. | +| Authorization / hierarchy | `lib/src/hierarchy.rs` (917 lines) | `resource.ts` `canWrite` (60 lines, UI hint) | None | **Rust-only.** `canWrite` stays as a hint; it is never a gate. | +| Loro materialize + datatype tags | `lib/src/loro.rs` `loro_value_to_atomic_value_tagged` (:848), `datatype_tag` (:828) | `resource.ts` `rebuildCacheFromLoro` (:739), `writeDatatypeTags` (:807); `datatypes.ts` `datatypeTag` (:69) | None | Keep twin for tags (pure) with a fixture. Materialization stays twinned because TipTap needs a main-thread `LoroDoc` (#1278). | +| JSON-AD parse / serialize | `lib/src/parse.rs` (1552), `lib/src/serialize.rs` (490) | `browser/lib/src/parse.ts` `JSONADParser` (133) | None | Keep twin; adapter format, not authority. | +| WS v2 frames | `lib/src/sync/protocol.rs` `encode_*` (:220-321) | `browser/lib/src/ws-v2.ts` `encode*` (:121-226) | Unverified (no `testdata/` entry) | Keep twin; add frame fixture. | +| Auth header signing | `lib/src/authentication.rs` | `browser/lib/src/authentication.ts` `signRequest` (:38) | None | Keep twin; tiny. Fixture. | +| Search escape / server URL | `lib/src/client/search.rs` | `browser/lib/src/search.ts`, `flutter/lib/atomic/server_url.dart` | **Yes in #1274**: `testdata/search-query.json`, `testdata/server-url.json` | Keep twin, bound by #1274. | +| Plugin planner / applier (#1307) | `server/src/plugins/plan.rs` (783), `apply.rs` (879) | `browser/lib/src/plugin-plan.ts` (450), `plugin-apply.ts` (487) | **Yes**: `testdata/plugin-plans/*.json`, run by `plugin-plan.fixtures.test.ts` and `plugins::plan::fixture_tests` | Planner: keep twin (pure, fixtured). Applier: writes commits — not pure; see Consequences. | +| Query / filter evaluation | `lib/src/db/query_index.rs` `QueryFilter`, `query_id` (:33-141); `Storelike::query` | none — `client-db.ts` calls WASM `query` | n/a | Already Rust-only. Keep it that way. | + +Pattern: every must-match twin that has a *shared file* fixture (genesis) is stable; the ones +with inline copies (commit bytes, RBSR, drive hash) have already drifted or gone one-sided. +The Rust `Db` already emits `DbEvent` (`lib/src/db.rs:104`, `subscribe_events` :1829) and +has a policy struct for ingest (`CommitIngestOpts`, `lib/src/sync/engine.rs:294`); the +missing piece is a named surface over them. + +## Options + +| | A. Twinned-by-design | B. Rust-only | C. Rust-authoritative + gated pure twins | +| --- | --- | --- | --- | +| What | Both libs implement protocol, verify, authorize; parity by golden tests | All protocol/verify/apply/sync in `atomic_lib`; TS is `ClientDb` glue + React cache | Rust owns verify/authorize/ingest/sync/hash/genesis; TS owns cache, reactivity, TipTap doc, signing UX; twins only for pure byte-producing functions with a `testdata/` fixture | +| Cost vs shipped code | Must add verify + hierarchy + ingest to TS (~2k lines that Rust already has); no gate exists, drift already observed | Every `Resource.get` crosses WASM: ~1000× slower (#1278); TipTap still needs a second `loro-crdt` heap; Ed25519 sign must move into a Worker | Matches what is shipped: TS already never verifies; WASM already applies. Work is a named `AtomicNode` slice + 4 fixture files | +| SDK story | Python/Kotlin/Flutter each re-wrap `Db` (status quo, 3 surfaces) | One surface | One surface | +| Verdict | Reject: doubles the trusted computing base and has no gate | Reject for reads; correct for writes/sync | **Adopt** | + +## Recommendation + +Adopt **C**. The rule, quotable: + +> **Rust decides; TS displays. Anything that verifies, authorizes, persists, or syncs lives once in `atomic_lib`. A TS copy is allowed only for a pure function whose output is pinned by a shared `testdata/` fixture and that passes the #1273 bind-twins gate. New SDKs bind `AtomicNode`, never `Db`.** + +Ownership: + +- **Rust (`lib/`)**: commit ingest and signature/timestamp/previous-commit verification + (`lib/src/commit.rs`, `lib/src/sync/engine.rs`), authorization (`lib/src/hierarchy.rs`), + sync/RBSR (`lib/src/sync/`), canonical hashing (`engine.rs::compute_drive_hash`, + `rbsr.rs`), genesis certs (`lib/src/genesis.rs`), query evaluation + (`lib/src/db/query_index.rs`), Loro persistence. +- **TS (`browser/lib/`)**: `Store`/`Resource` cache, subscriptions, outbox scheduling, React + reactivity, TipTap `LoroDoc`, key custody + signing via `SubtleCrypto`, JSON-AD adapter + parsing. `canWrite` is a hint. +- **Twins (pure, fixtured)**: commit canonical bytes, genesis cert bytes, RBSR fingerprint, + drive hash, datatype tag, WS frame encoding, auth header string, search escape, plugin + planner. Each needs one file under `testdata/` loaded by both test suites, the way + `lib/src/genesis_test_vectors.json` and `testdata/pairing-request.json` already do. +- **Not twins**: applying/verifying commits, rights, sync state machines, query planners, + anything that writes. + +### First `AtomicNode` slice + +Module `lib/src/runtime/node.rs` (`pub mod runtime` in `lib/src/lib.rs`). No behavior change: +every method delegates to code that exists today. + +```rust +pub struct AtomicNode { db: Db, agent: Option } + +pub enum IngestPolicy { Hub, Peer, Replica, LocalCache } // LocalCache = today's WASM opts + +impl AtomicNode { + /// Db::init_redb / init_redb_file / init_redb_opfs (lib/src/db.rs:474, :506, :714) + pub async fn open(cfg: NodeConfig) -> AtomicResult; + /// Storelike::get_resource_extended (lib/src/storelike.rs:489) with ForAgent + pub async fn get(&self, subject: &Subject, for_agent: &ForAgent) -> AtomicResult; + /// Storelike::query (lib/src/storelike.rs:651) + pub async fn query(&self, q: &Query) -> AtomicResult; + /// sync::engine::ingest_commit_json (lib/src/sync/engine.rs:327) + CommitIngestOpts (:294) + pub async fn apply_commit(&self, commit_json: &str, policy: IngestPolicy) -> AtomicResult; + /// CommitBuilder::sign (lib/src/commit.rs:1299) then apply_commit(Hub) — replaces Resource::save_locally (lib/src/resources.rs:1178) + pub async fn mutate(&self, edit: ResourceEdit) -> AtomicResult; + /// Db::subscribe_events (lib/src/db.rs:1829); DbEvent (lib/src/db.rs:104) + pub fn subscribe(&self) -> broadcast::Receiver; + /// sync::peer::sync_drive_with_peer_outcome (lib/src/sync/peer.rs:1343) + pub async fn sync_with_peer(&self, node_id: &str, drive: &Subject) -> AtomicResult; +} +``` + +`IngestPolicy::LocalCache` gives the WASM path a name instead of the ad-hoc `CommitOpts` in +`wasm/src/lib.rs:195`; #1274 says "WASM `applyCommit` is **not** folded in (signature off — a +fourth policy)". Naming it is the fold. Deliverable includes one in-memory smoke test +(open, mutate, query, get, subscribe) and the #1273 measure script over `wasm/src/lib.rs` +showing `ClientDb` shrinking. + +### Sequencing + +1. Merge #1273 (contract) and #1274 (ingest policies). Add the "pure function" clause to + `consolidation-contract.md` kind 3. +2. Land `lib/src/runtime/node.rs` as above. `wasm/src/lib.rs` `ClientDb::applyCommit` calls + `node.apply_commit(_, LocalCache)`. +3. Fixtures: `testdata/commit-canonical.json`, `testdata/rbsr-fingerprint.json`, + `testdata/drive-hash.json`, `testdata/datatype-tags.json`. Move the inline hex in + `rbsr.test.ts`, `canonical-drive-hash.test.ts`, `sign.test.ts` and the Rust twins onto them. +4. `ffi/` (#1277) and `python/` call `AtomicNode`; `flutter/rust/src/api/simple.rs` store + group calls `AtomicNode`, canvas functions stay FRB-specific. +5. `server/src/handlers/commit.rs` → `node.apply_commit(_, Hub)` + ([`atomic-lib-runtime.md`](./atomic-lib-runtime.md) Phase 2). +6. Only then: `AtomicNode::sync_with(transport)` in WASM, after which `rbsr.ts` and + `canonical-drive-hash.ts` become deletable (kind 1, lines must drop). + +## Consequences for open PRs + +- **#1278** (duplication analysis): merge-as-is. Its "must-match ~1.5–2.5k lines stays JS" and + "do not move `Resource.get` into WASM" are adopted here; link + `planning/ts-wasm-duplication.md` to this decision. +- **#1273** (contract + measure script): merge-as-is, then add one sentence to kind 3: + twins must be pure functions (no I/O, no verification, no writes). +- **#1274** (ingest policies, bind-twin fixtures): merge-as-is. `CommitIngestOpts::{hub,peer,replica}` + becomes `IngestPolicy`; add `LocalCache` in the `AtomicNode` PR, not here. +- **#1277** (Python + Kotlin SDKs): change. Do not ship two more `Db` wrappers + (`python/src/store.rs`, `ffi/src/store.rs`). Rebase after sequencing step 2: `ffi/` + becomes UniFFI over `atomic_lib::runtime::AtomicNode`; Python is a PyO3 skin over the + same. Its `planning/kotlin-sdk.md` already states this target. No `simple.rs` in the diff + (verified); the `ffi/` surface is the concern. +- **#1241** (Flutter SDK packaging): merge-as-is (packaging, CI, docs). Its follow-up + "generic query/blobs bridge APIs" must be `AtomicNode` bindings, not new `simple.rs` + functions; `simple.rs` is already 1444 lines. +- **#1307** (plugins, two planners): planner twin passes the rule (`testdata/plugin-plans/`, + both suites load it). Applier does not: `plugin-apply.ts` (487) and `apply.rs` (879) + are two write paths. Change: the TS applier must reduce to ordinary `Resource.save()` calls + (unverified whether it already does) or be dropped in favour of the server applier via + `apply_commit(Hub)`. `store_host.rs` writes must go through `ingest_commit_json`, so + rebase-after-#1274. +- **#1313** (commits as envelopes): touches `lib/src/commit.rs` and `browser/lib/src/commit.ts` + together. Change: it must ship `testdata/commit-canonical.json` (sequencing step 3), + since the commit-bytes twin currently has no shared pin. +- **#1311** (CRDT list append/remove/move): touches `lib/src/loro.rs` and `resource.ts` + list semantics with no fixture. Change: add a `testdata/` fixture for list-merge results + or make TS delegate to the WASM node for these operations. +- **#1262** (`did:ad:frozen` + `jcs.ts`): a new canonical-bytes twin (`browser/lib/src/jcs.ts` + vs `lib/src/frozen.rs`) with fixtures under `test-vectors/`. Change: move fixtures to + `testdata/` so there is one fixture home for the #1273 gate. +- **#1250** (commit perf, Rust-only): merge-as-is; no TS twin needed. +- **#1254** (ACL zones): Rust-only authorization (`hierarchy.rs`, `zones.rs`) — consistent + with this decision; review on its own merits. Do not add a TS zone evaluator. diff --git a/planning/schema-routes-decision.md b/planning/schema-routes-decision.md new file mode 100644 index 0000000000..d0be89dde1 --- /dev/null +++ b/planning/schema-routes-decision.md @@ -0,0 +1,158 @@ +# Schema routes: one on-ramp for Classes and Properties + +**Status:** Decision requested (2026-09-01). + +> **Decision needed by maintainer** +> +> Question: Which of three competing schema on-ramps is *the* way a Class/Property comes into existence, and what happens to the other two? +> Options: (A) optional schema only — no schema resources required, `lib/defaults/*.json` stays the only shared vocab (#1316, #1245). (B) `did:ad:frozen` content-addressed schema for app/shared vocab, with (A) as the permissive write path and `lib/defaults/*.json` kept only as the serialization of the built-in set (#1262). (C) `lib/defaults/*.json` + `https://atomicdata.dev/...` URLs for every new vocabulary, as #1251 does today. +> Recommendation: **B** — it is the only option whose identity works offline and across hosts without trusting atomicdata.dev, and the write path already tolerates missing schema, so (A) is a policy statement, not a competing mechanism. +> Blocked PRs: #1316, #1245, #1262, #1209, #1251 (and #1309 sequencing). + +## Context + +What ships on `develop` today, verified against code. + +**Subject form of built-in vocab.** Every core Class/Property is an `https://atomicdata.dev/...` +URL: `lib/src/urls.rs:4-15` (`CLASS`, `PROPERTY`, `COMMIT`, ...; 188 occurrences of +`atomicdata.dev` in that file). `lib/defaults/*.json` (`table.json`, `ontologies.json`, +`meeting.json`, `i18n.json`, ...) use the same form for `@id`, `isA` and `parent`. There is +no `did:ad:frozen` anywhere in `lib/src`, `server/src`, `browser/lib/src` or `docs/src` +(grep). `docs/src/did.md` documents `did:ad:agent` and `did:ad:blob` only. + +**How defaults reach a store.** `lib/src/populate.rs`: + +- `populate_base_models` (line 18) hard-codes the bootstrap Properties/Classes and is + **add-only**: it skips any subject already present (lines 231-263, comment: "Only ever ADD ... + on atomicdata.dev itself those resources are the site's own authored content"). +- `populate_default_store` (line 268) runs `store.import(include_str!("../defaults/X.json"), + &ParseOpts::default())`. `ParseOpts::default()` is `SaveOpts::Save`, `overwrite_outside: true` + (`lib/src/parse.rs:73-84`); `SaveOpts::Save` calls `store.add_resource` (`parse.rs:773-780`), + which is `add_resource_opts(resource, check_required_props: true, update_index: true, + overwrite_existing: true)` (`lib/src/storelike.rs:260-273`). So JSON defaults are **upserted + with required-prop validation**, not add-only. +- `bootstrap` (line 338) gates on two sentinels, `SHORTNAME` and `LORO_UPDATE`; if both are + stored it returns early (lines 355-361). `Db::init` / `init_memory` / `init_redb` / + `init_redb_opfs` all call `bootstrap` on open (`lib/src/db.rs:435,464,497,741`). The comment + at `db.rs:432` ("Re-run on every startup so new vocabulary ... is available") is wrong: the + sentinel gate skips it for every already-seeded store. + +**`--repopulate-defaults` exists.** `server/src/config.rs:21-23` (`ATOMIC_REPOPULATE_DEFAULTS`); +`server/src/appstate.rs:156-178` runs `populate_base_models` + `populate_default_store` when +set and the store is not being initialized. It is manual, server-only, and does not touch +existing base-model definitions (add-only above). The gap as claimed in planning: +[`drafts-and-suggestions.md`](./drafts-and-suggestions.md) §Known gap ("a store that was already +populated never picks up a newly-added `lib/defaults/*.json`"; browser WASM needs a rebuilt +bundle *and* a store that repopulates), inherited verbatim by +[`content-i18n.md`](./content-i18n.md) (lines 88-91, 327). `--repopulate-defaults` is not +wired into the WASM/OPFS path at all (`db.rs:714` `init_redb_opfs` only calls `bootstrap`). + +**How the write path validates.** The Loro commit path (`/commit`, WS, Iroh) builds +`CommitOpts { validate_schema: true, ... }` in `lib/src/sync/engine.rs:420-421`; +`validate_schema` only runs `check_required_props` (`lib/src/commit.rs:966-969`), which iterates +`get_classes` (`lib/src/resources.rs:313-335`). `get_classes` **skips** a class the store cannot +resolve (`resources.rs:566-600`, with the field incident that motivated it). No per-property +datatype lookup happens on the commit path; datatypes come from the Loro `datatypes` tag map +(`resources.rs:104-117`). `Resource::set` (Rust API path, `resources.rs:1340`) is the one place +that still hard-fails on an unknown Property. TS `Resource.set` already skips validation when +`getProperty` fails (`browser/lib/src/resource.ts:3389-3405`). Conclusion: schema is already +optional on every network write path; only the Rust in-process API and the data-browser form gate +(`browser/data-browser/src/components/forms/ResourceForm.tsx:167`, "Only resources with valid +classes can be created or edited") still force it. + +**Codegen.** `@tomic/cli` `ad-generate ontologies` fails on `did:ad:` ontologies; #1309 makes +`https://host/did:ad:...` an alias of the DID (`browser/lib/src/parse.ts`, +`subjectsReferToSameResource`) and routes raw DIDs through `/did?subject=` using `serverUrl`. + +## Options + +| | (A) Optional schema | (B) `did:ad:frozen` + code-first | (C) `lib/defaults/*.json` | +| --- | --- | --- | --- | +| PRs | #1316, #1245 | #1262 (contains #1209) | #1251 (pattern shared by every existing default) | +| How a class is minted | Not minted; app writes URL-keyed props with no `isA` | `defineSchema()` in code → `freezeSchema` → JCS-canonical body → `blake3` → id (`browser/lib/src/schema.ts`, `lib/src/frozen.rs` `frozen_id`, shared `test-vectors/freeze-schema.json`) | Hand-edit JSON under `lib/defaults/`, add `urls.rs` consts, add an `import` call in `populate.rs` | +| Subject form | Any URL; none required | `did:ad:frozen:{blake3-hex}` (`lib/src/subject.rs` `DID_AD_FROZEN_PREFIX`); core vocab it points at stays `https://atomicdata.dev/...` | `https://atomicdata.dev/{classes,properties,ontology}/...` | +| Immutable / content-addressed | n/a | Yes: `Tree::Frozen` keyed by hash, verify-by-rehash on read (`lib/src/db.rs` `materialize_frozen`), commits to a frozen subject rejected (`lib/src/commit.rs`) | No; mutable https resources, upserted by `import` | +| Works offline / without atomicdata.dev | Yes | Yes: resolve order in-memory → `defineSchema` body registry → `GET /frozen/{hash}` (any host, rehash) | Only because the bytes are embedded via `include_str!`; any subject *not* embedded resolves by HTTP fetch of atomicdata.dev (`populate.rs:154,206,347-349`) | +| Write-path validation | `check_required_props` on resolvable classes; unknown class skipped; #1316 makes `Resource::set` match | Same, once the store can load the frozen Class/Property. **Gap found:** frozen bodies are identity-only (no `description`, `schema.ts` "Frozen bodies hold identity only"), but `Property::from_resource` / `Class::from_resource` still require `description` (`lib/src/schema.rs:34,109`, unchanged on the branch) → Rust `get_property`/`get_class` fail for frozen ids and `requires` is silently skipped (code reading; not run) | Full: datatype + `allowsOnly` on `Resource::set`, `requires` on commit | +| Updating defaults on an existing store | n/a | Never needed: a changed definition is a new hash; old data keeps pointing at the old id | Manual `--repopulate-defaults` (server) / no path at all (WASM); sentinel gate skips `bootstrap` | +| `@tomic/cli` codegen | n/a (no ontology to generate from) | #1262 rewrites `browser/cli/src/generate*.ts` to fetch through a `Store`; overlaps #1309 in `browser/cli/src/{config,store}.ts` (both PRs touch them) | Works today (https ontology) | +| Cost against shipped systems | None; matches `get_classes` and TS behaviour | New tree, two handlers (`server/src/handlers/frozen.rs` `PUT/GET /frozen/{hash}`), 9k-line diff, 237 commits behind `develop`, Phase D (Iroh `FROZEN_REQUEST`) and ClientDb/OPFS persistence not done | Zero new code; every new feature repeats the bootstrap gap | +| Verdict | Necessary policy, not an on-ramp | The on-ramp for app and shared vocab | Serialization format for the built-in set only | + +(A) is not an alternative to (B): (A) says what happens when schema is *absent*; (B) says how +schema is *identified* when present. (C) cannot be the on-ramp for anything outside this repo: +it requires a commit to `lib/defaults/` and a release, and its identity is a hostname. + +## Recommendation + +Adopt **B**. The rule: **a Class or Property is identified by the hash of its definition; +hosts cache it, nobody owns it; a resource that names a class the store cannot load is still +a valid write.** + +Sequencing: + +1. **Close the repopulate gap generically (small, independent of #1262).** Replace the two + sentinels in `populate::bootstrap` with a *defaults fingerprint*: at build time hash the + concatenated embedded `lib/defaults/*.json` plus the `populate_base_models` list; store it + under a reserved key; on every `Db` open (server *and* `init_redb_opfs`) compare and, on + mismatch, run `populate_base_models` + `populate_default_store` then write the new + fingerprint. Make the JSON import idempotent by construction: `add_resource_opts(&r, + check_required_props: false, update_index: true, overwrite_existing: true)` — i.e. upsert by + subject with `validate:false`, so an identity-only or partially-migrated definition cannot + abort the whole batch (today `SaveOpts::Save` validates, `parse.rs:773-780`). Keep + `--repopulate-defaults` as a forced re-run; fix the misleading comment at `db.rs:432`. + Why frozen ids make the upsert safe: for `did:ad:frozen` subjects, same subject ⇒ same + bytes, so overwrite is a no-op and a changed definition is a new subject; the only + overwrite risk is the mutable `https://atomicdata.dev/...` set, which is exactly the set + `populate_base_models` already refuses to overwrite (`populate.rs:231-234`). The + fingerprint also removes the "rebuilt wasm bundle *and* a store that repopulates" dance in + [`drafts-and-suggestions.md`](./drafts-and-suggestions.md): a new bundle carries a new + fingerprint, so the OPFS store repopulates itself on next open. +2. **Merge the policy (#1316, #1245).** They make the Rust API and the data-browser match + what the commit path already does. +3. **Land frozen (#1262) after a rebase**, with these changes: make `description` optional in + `Property::from_resource` / `Class::from_resource` (`lib/src/schema.rs:34,109`), or have + `materialize_frozen` merge the package `presentation` layer, and add a Rust test that + `check_required_props` rejects a resource missing a `requires` of a frozen Class; resolve the + `browser/cli/src/{config,store}.ts` overlap with #1309 by rebasing onto #1309; persist + `Tree::Frozen` in the OPFS ClientDb (currently in-memory + network only). Phase D (Iroh + `FROZEN_REQUEST`) can follow; `GET /frozen` over HTTP is enough for the first cut. +4. **Freeze the built-ins last.** Once 3 is in, emit `did:ad:frozen` ids for the app-level + ontologies in `lib/defaults/` (table, meeting, dashboard, chatroom, forks, i18n, contacts) + and keep the JSON files as the serialization the fingerprint in step 1 hashes. The core + vocabulary in `urls.rs` (Class, Property, Commit, Agent, Drive, ...) stays https-addressed: + frozen bodies themselves reference it (`schema.ts` uses `core.classes.property`), and + `docs/src/schema/` and `atomicdata.dev` are its home. A separate decision is needed + before touching those. + +Not decided here: `did:ad:frozen` discovery beyond a known host (DHT / registry), and +migrations between schema versions (issue #1207 open question). Neither blocks steps 1-3. + +## Consequences for open PRs + +- **#1316** (Schema is recommended, not required on the write path): **merge-as-is**. One + behaviour change (`Resource::set` falls back to `set_unsafe` on unknown Property, + `lib/src/resources.rs`) plus docs and `planning/optional-schema.md`. Ask the author to note + in `optional-schema.md` that this is the permissive half of this decision, not the on-ramp. +- **#1245** (Allow editing classless resources): **merge-as-is**. Removes the + `ResourceForm.tsx:167` gate, adds `browser/e2e/tests/classless-edit.spec.ts`. UI dual of #1316. +- **#1262** (`did:ad:frozen` schemas + code-first API): **rebase-after #1309 and step 1**, then + change: (i) `description` optional in `lib/src/schema.rs` `from_resource` or merged from + the presentation layer, with a Rust `check_required_props`-against-frozen-Class test; + (ii) resolve the `browser/cli/src/{config,store}.ts` overlap with #1309; (iii) OPFS persistence + of `Tree::Frozen`; (iv) update `planning/json-schema-code-first.md` status from "Proposal. + Nothing built" to point at `did-ad-frozen-server.md` (branch-only today) and land both docs. + Split into lib+server / `@tomic/lib` / data-browser Freeze UI / CLI if review load requires; + the 9k-line single PR is the main merge risk. +- **#1209** (Schema in code #1207 + did:ad:frozen #1208): **close as superseded**. Its head + `2efcd9f92` is an ancestor of #1262's head (`git merge-base --is-ancestor`); every file it + touches is in #1262's file list. Keep issues #1207 and #1208 open until #1262 merges. +- **#1251** (Contacts): **merge-as-is** on the current (C) convention — `lib/defaults/contacts.json` + with `https://atomicdata.dev/...` subjects, `populate.rs` import, `urls.rs` consts — because + every existing default ships that way and the freeze of built-ins is step 4, not a + precondition. It inherits the bootstrap gap like `forks.json` and `i18n.json`; step 1 closes + it for all of them at once. Do not add a second Contacts-specific repopulate path. +- **#1309** (Fix `@tomic/cli` ontology codegen for `did:ad` subjects): **merge before #1262**; + it is the smaller change to the files both PRs edit, and its DID-alias rule in + `browser/lib/src/parse.ts` is what frozen ontologies resolved via `https://host/did:ad:frozen:...` + will also need. diff --git a/planning/trust-model-decision.md b/planning/trust-model-decision.md new file mode 100644 index 0000000000..2128b4146a --- /dev/null +++ b/planning/trust-model-decision.md @@ -0,0 +1,179 @@ +# Trust model: the node that owns the URL is trusted; anything that only stores is blind + +**Status:** Decision requested (2026-09-01). + +> **Decision needed by maintainer** +> +> Question: Is an Atomic node that serves a drive a trusted plaintext verifier, or must it be a blind (E2EE) replica? +> Options: (A) trusted node everywhere, no blind tier. (B) blind node: server stores ciphertext, clients do everything. (C) split by role: the node that owns the URL is trusted with plaintext; the Vault (backup/escrow) is blind. +> Recommendation: **C** — every shipped server function needs plaintext, and the blind store already exists as a separate product (`lib/src/vault/`). +> Blocked PRs: #1310, #1307, #1254; sequencing for #1313. + +## Context + +[`encryption.md`](./encryption.md) is marked "Exploration / undecided (2026-06)" and lists +"blind replica" and "trusted verifier" as open roles. Two pieces have since shipped and +fixed the shape of the answer: + +- **At-rest cache encryption** (browser only): `lib/src/db/encrypted_backend.rs`, wired in + `RedbStore::new_opfs` (`lib/src/db/redb_store.rs:207`). The native server path + `Db::init_redb_file` (`lib/src/db.rs:506`) takes no key: a self-hosted or managed + `atomic-server` keeps `atomic.redb` **plaintext on disk** today. See + [`opfs-per-agent-encryption.md`](./opfs-per-agent-encryption.md). +- **Blind vault backup v1**: `lib/src/vault/` (envelope, pack, keys, store, sync). The store + holding vault objects cannot read subjects, values, or counts + (`a_restore_without_the_right_key_fails`, `sealed_packs_do_not_reveal_subjects`). See + [`encrypted-vault-format.md`](./encrypted-vault-format.md). + +The SaaS side already sells these as two tiers on a "trust spectrum" (`atomic-saas` +`OSS_STRATEGY.md`: Blind = recovery + Cloud Vault; Trusted = Cloud Sync managed node) and +`TIER_SWITCHING_FLOWS.md` §1 states it as a key matrix: Local-Only and Cloud Vault have no +host plaintext access; Hosted Server has plaintext "for index & query". + +### What the server reads in plaintext today (verified) + +Every row is a function a blind node cannot perform. This list is the cost of option B. + +| # | Function | Where | Plaintext it needs | +| --- | --- | --- | --- | +| 1 | Rights check on every read/write/append | `lib/src/hierarchy.rs:214` `check_rights` (`read`/`write`/`parent` propvals, ancestor walk); `RightsCache` `:122` | ACL arrays and `parent` of the resource and its ancestors | +| 2 | Commit apply: Loro merge + atom diff | `lib/src/commit.rs:1014` `apply_changes` → `import_update_with_diff`; `check_append`/`check_write` at `:822`, `:864` | The Loro update and the current snapshot | +| 3 | Full-text search index | `server/src/search.rs:136-160`: `to_json_ad`, title, description, `extract_document_plain_text` into tantivy | Whole resource, document body text | +| 4 | Vector search / embeddings | `server/src/vector_search/common.rs:35` builds plain-text chunks; `embeddings/openrouter.rs` sends them to `openrouter.ai` | Document text, leaves the node when `OPENROUTER_API_KEY` is set | +| 5 | Collections and queries | `lib/src/db.rs:2273` `query_basic` (rights memo); `lib/src/db/query_index.rs` `resource_matches_filter`, `sort_key_for` (drive-scoped, `:31`) | Property values for filter, sort, and the `PropValSub`/`ValPropSub` indexes | +| 6 | WS fan-out and sync readability | `server/src/commit_monitor.rs:355-380` `check_read` on the drive at `SubscribeDrive`; `lib/src/sync/engine.rs:1180` `collect_readable_snapshots` (`check_read` per subject) | Drive and per-resource ACLs; drive membership of each commit | +| 7 | Plugin execution | `server/src/plugins/wasm.rs:709` `get_resource` hands materialized resources to WASM guests; `PluginMeta.agent_secret` `lib/src/db/plugin_meta.rs:10` | Resources the plugin reads, plus a plaintext plugin agent secret on `develop` | +| 8 | Invites, replicate, export | `server/src/plugins/invite.rs:183` writes `read`/`write`; `server/src/plugins/replicate.rs:91` `check_write` on the drive; `server/src/handlers/export.rs:80` `get_resource_extended` per agent | ACLs and full resource bodies | +| 9 | Image renditions | `server/src/handlers/download.rs:216-251` decodes blob bytes, caches renditions in `Tree::Blobs` | Plaintext file bytes | +| 10 | AI | No server-side chat endpoint exists (`server/src/handlers/` has none; chat streams browser→OpenRouter, `browser/data-browser/src/chunks/AI/ClientOnlyTransport.ts`). Server-side AI today is row 4 only. | Row 4; any future hosted proxy (`AI_ACCESS_AND_PRICING.md` option B) reads prompts that contain drive content | + +Rows 1, 2, 5, 6 are structural: without them the node cannot decide whether to accept a +commit, whom to fan it out to, or answer a collection. Rows 3, 4, 7, 9, 10 are the +product surface (search, plugins, previews, AI). `encryption.md` § "Search, queries, and +server features" reaches the same list from the other direction. + +### What is blind today + +Only the vault. `lib/src/vault/store.rs` writes opaque objects keyed under +`vault//lanes//seg-NNNNNN.pack`; the operator sees kind, size, epoch, +lane, timing (`CLOUD_VAULT_ARCHITECTURE.md` "Visible metadata"). The SaaS control plane +brokers presigned URLs and never receives object bytes (`LORO_ENCRYPTED_VAULT.md:117`, +`ENCRYPTED_BLOB_VAULT_MOAT.md:87`). + +## Options + +| | (A) Trusted node everywhere | (B) Blind node (E2EE) | (C) Split by role | +| --- | --- | --- | --- | +| What it is | Every node that stores a drive may read it. No blind tier; vault is "encrypted backup" only as a courtesy. | The server stores ciphertext envelopes. Clients merge, index, authorize, fan out. | The node that **owns the URL** (serves `GET`, accepts `/commit`, fans out, indexes, runs plugins/AI) is a trusted plaintext verifier. Anything that **only stores** (vault, S3 bucket, escrow) is blind. | +| Rows 1, 2, 6 (auth, apply, fan-out) | unchanged | Needs a blind authorization model: `encryption.md` lists four candidates, none chosen; ACLs live inside the ciphertext | unchanged on the node; the vault does no authorization (v1 is same-agent drives only, `CLOUD_VAULT_ARCHITECTURE.md` decision 7) | +| Row 5 (queries) | unchanged | Gone on the server; every client re-derives `QueryMembers` locally; no `/query` for external apps | unchanged | +| Rows 3, 4 (search) | unchanged | Gone; client-only search, no hosted full-text | unchanged | +| Row 7 (plugins) | unchanged | Scheduled / server-placed runs impossible (`plugin-secrets.md`: "a plugin importing at 3am has nobody to ask for a passkey") | unchanged | +| Rows 9, 10 (previews, AI) | unchanged | Gone | unchanged | +| Blob GC, compaction | unchanged | Blind replica cannot see `File` refs or validate a checkpoint (`encryption.md` § Compaction, § Blobs) | node does GC; vault GC uses signed coverage maps it already has | +| Product | Contradicts the SaaS "Blind" tier already sold (`OSS_STRATEGY.md` Trust Spectrum) | Contradicts Cloud Sync, hosted URLs, hosted AI, every plugin | Matches `TIER_SWITCHING_FLOWS.md` key matrix exactly | +| Verdict | Loses the blind vault for nothing | Rebuilds the server as a relay; ships nothing until a blind-authorization design exists | Names what is already built | + +## Recommendation + +**C.** The rule, quotable: + +> **The node that owns the URL is trusted with plaintext; anything that only stores is blind.** + +"Owns the URL" means: it is the node a subject resolves to for `GET`/`/commit`/WS — the +one that checks rights, materializes Loro, indexes, fans out, runs plugins and serves AI. +That node holds the drive key (or the plaintext directly) and protects it **at rest** with +a node-held key. "Only stores" means: it never decrypts, never authorizes on content, +never indexes — vault objects, S3 blob bytes, recovery blobs. Trust is a property of the +role, not of who operates the machine: a self-hosted node and a managed Cloud Sync node +are both trusted; a self-hosted MinIO bucket and the SaaS Vault are both blind. + +Consequences per area: + +- **[`encryption.md`](./encryption.md)** — close the E2EE / blind-replica question as + **"at-rest + vault"**: local cache at rest (shipped), server at rest (to build, see + step 2), blind vault (shipped). Mark "Blind replica" and "Optional trusted verifier" + candidate models as *not planned*. Reopen only if a concrete design demonstrates, on + ciphertext, all three of: (a) **authorization** — accept/reject a commit against + `read`/`write`/`append`/`parent` without reading them; (b) **indexing** — answer a + drive-scoped `QueryFilter` and full-text search; (c) **fan-out** — decide which + subscribers may receive a commit. Anything short of all three is a vault, and the vault + exists. +- **Zones plaintext ACLs** ([`zones.md`](./zones.md), #1254) — fine. Rights arrays stay + plaintext propvals read by `check_rights`; the zone index is a derived plaintext index on + the trusted node. `zones.md`'s aside that "ACL properties stay plaintext containers even + in an encrypted zone, so blind hubs can enforce admission" is compatible but not required + by this decision; do not build blind-hub admission. +- **Plugin secrets** (`plugin-secrets.md`, #1307) — node-held, encrypted at rest under a + node key beside `config.toml` (`server/src/node_key.rs` on `origin/feat/plugin-model`, + wrapper kind `NodeKey` in `lib/src/vault/secret_envelope.rs`). Correct under C: the node + spends the secret in unattended runs, so the node must be able to open it. Never a + resource, never synced. +- **S3 blobs** ([`s3-blob-storage.md`](./s3-blob-storage.md)) — the bucket is a blind + store. Its v1 "rely on S3-side SSE" is not enough under the rule: encrypt blob bytes with + a node-held key before `put`, so the bucket operator sees ciphertext keyed by a hash the + node chooses (the vault already keys blobs by `blake3::keyed_hash`, reuse it). The node + decrypts to serve `/download` and renditions (row 9). `ENCRYPTED_BLOB_VAULT_MOAT.md:179` + already states the S3 backend keeps the node "a queryable, plaintext-indexing node". +- **OIDC root keys on the node** (#1310, `planning/oidc-oauth.md` §4a on + `origin/cursor/oidc-oauth-reconsider-1cb5`): "The node mints the root Agent, wraps it + with a node-held KEK, stores it keyed by `(iss, sub)`. The private key does not go to the + browser." The PR calls this "custodial of the root, on purpose." Under C that is the + trusted node holding a secret at rest — allowed, and it must use the same node key as + plugin secrets, not a second KEK. +- **SaaS tier switching** (`TIER_SWITCHING_FLOWS.md`) — what moves between tiers is the + `DriveVaultKey`, nothing else. Local→Vault: client keeps the key, uploads ciphertext (Flow + A). Vault→Hosted: client wraps the key to the hosted node's pubkey; node decrypts the + vault into redb + tantivy and switches to live plaintext sync (Flow C; UI must say the + server can read the drive). Hosted→Vault: node flushes to vault, volume scrubbed, key + forgotten (Flow D). Vault stays an incremental backup target beside a hosted node; its + privacy benefit is superseded while the node exists (§4 Q2). +- **AI access** (`AI_ACCESS_AND_PRICING.md`) — the node reads plaintext to serve AI. Today + that is only embeddings (row 4); BYOK chat stays browser→OpenRouter and blind to us. A + hosted proxy (that doc's option B) moves hosted AI to the trusted side; it must run on, + or with the same disclosure as, the trusted node, with no prompt retention. A blind tier + never gets AI over drive content. +- **Sync peers** — a peer that receives a drive over `SYNC` is a node that owns the data + for its own reads: trusted by construction (`collect_readable_snapshots` already filters + per agent). Same-agent P2P ([`serverless-p2p.md`](./serverless-p2p.md)) is unaffected. + +Sequencing: + +1. Edit `encryption.md` status to "Closed: at-rest + vault (2026-09)"; keep the "Keys" + and "Blobs" sections as the reference for steps 2–3; move the blind-replica sections + under a "Not planned" heading with the three-part reopen test above. +2. **Server at rest.** Give `Db::init_redb_file` (`lib/src/db.rs:506`) the same + `Option<&[u8; 32]>` that `new_opfs` has and open native redb through + `EncryptedBackend`, keyed by the node key from #1307. This is what makes "trusted with + plaintext" mean *in process*, not *on disk*. Behind a flag; migration copies like + `migrate_legacy_db` (`lib/src/db/opfs_backend.rs:151`). +3. **One node key.** Land `server/src/node_key.rs` (#1307) first; #1310's root-agent KEK and + `s3-blob-storage.md` Phase 2a's `Secret` DEK derive from it. +4. **Blob encryption** before any S3 backend ships (`s3-blob-storage.md` phase 1b): the + `BlobBackend` trait takes ciphertext; `RedbBlobBackend` may skip it once step 2 lands. +5. `zones.md` and `index-performance.md` proceed unchanged; nothing in them assumed a + blind node. + +## Consequences for open PRs + +- **#1310** (OIDC/OAuth retarget) — merge-as-is on the trust question: node-held root key + is the trusted node holding a secret at rest. Change: wrap under the shared node key from + #1307 rather than a PR-local KEK; rebase-after-#1307. +- **#1307** (plugin model, `plugin-secrets.md`) — merge-as-is on the trust question. Its + node-key encryption at rest (`server/src/node_key.rs`, `NodeKey` wrapper) is the first + concrete piece of step 2/3; the unbuilt "user-wrapped half" stays not built. Ask: land + the node-key module in a way #1310 and step 2 can import. +- **#1254** (ACL zones) — merge-as-is on the trust question: plaintext ACLs on the trusted + node are the model. No blind-hub admission work. +- **#1313** (commits as signed envelopes) — unaffected by A/B/C, but relevant to step 1: + dropping stored content commits removes the "blind replica must retain every encrypted + update" concern from `encryption.md` § Compaction; note it there when closing. +- **#1300** (ecosystem integrations / webhooks) — planning only; outbound webhooks read + plaintext resource events on the trusted node, consistent with C. +- **#1259** (single OpenAI-compatible AI endpoint, draft) — browser-side; keep BYOK + browser→provider as the blind path per `AI_ACCESS_AND_PRICING.md`. +- **#659** (s3 uploads, 2024), **#1110** (OpenDAL persistable), **#1117** (SQLite storage) + — stale storage-backend PRs predating `s3-blob-storage.md`. Close, or rebase-after step 4 + with blob bytes encrypted before leaving the node. +- No open PR proposes a blind live replica; nothing is blocked on option B (unverified + only for PR branches whose diffs exceed the 20k-line API limit, #1307). From a79260c579401e8d4d560c4e484633c165d77bee Mon Sep 17 00:00:00 2001 From: Joep Meindertsma Date: Tue, 1 Sep 2026 20:55:04 +0200 Subject: [PATCH 2/3] docs(planning): match the decisions index to the documents Claude-Session: https://claude.ai/code/session_019asLKBrBWY5ovyeCgtmdSd --- planning/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/planning/README.md b/planning/README.md index ac734e0dad..419d9be1c7 100644 --- a/planning/README.md +++ b/planning/README.md @@ -27,10 +27,10 @@ and move the document to `completed/`. | Document | Question | Recommendation | Blocks | | --- | --- | --- | --- | | [`runtime-boundary-decision.md`](./runtime-boundary-decision.md) | Rust-only runtime vs twinned-by-design between `atomic_lib` and `@tomic/lib`. | Rust owns ingest, auth, sync, hashing, genesis; TS owns cache/reactivity/UI; twins only for pure functions with shared fixtures. First `AtomicNode` slice. | #1273, #1274, #1277, #1241, #1278 | -| [`authority-unit-decision.md`](./authority-unit-decision.md) | Drive vs zone as the unit of authority; additive creator chain vs replace-and-replay. | Drive stays the identity/replication unit; zones only as a rights unit inside a drive, additive. | #1254, #1307, #1310 | -| [`commit-retention-floor-decision.md`](./commit-retention-floor-decision.md) | What must be retained for authorization and audit before commits become envelopes. | Envelope-on-resource floor; sequence #1274 → #1313 → #1254. | #1313, #1250, #1254 | -| [`trust-model-decision.md`](./trust-model-decision.md) | Blind vs trusted server. | The node that owns the URL is trusted with plaintext; the Vault is blind. Close `encryption.md` to at-rest + vault. | #1310, #1307, #1254 | -| [`schema-routes-decision.md`](./schema-routes-decision.md) | Optional schema vs `did:ad:frozen` vs `lib/defaults/*.json`; the `--repopulate-defaults` gap. | One on-ramp; see document for the fate of each PR. | #1316, #1245, #1262, #1209, #1251 | +| [`authority-unit-decision.md`](./authority-unit-decision.md) | Drive vs zone as the unit of authority; additive creator chain vs replace-and-replay. | Hybrid: drive stays the identity/replication/fan-out/index unit; the zone chain is the rights unit, additive, creator always writes. Replace semantics deferred behind opt-in. | #1254, #1307, #1310 | +| [`commit-retention-floor-decision.md`](./commit-retention-floor-decision.md) | What must be retained for authorization and audit before commits become envelopes. | Envelope-on-resource (`Tree::Envelopes`, latest signed envelope travels with the snapshot); Loro oplog is the history. Sequence #1274 → #1313 → #1254. | #1313, #1274, #1254 | +| [`trust-model-decision.md`](./trust-model-decision.md) | Blind vs trusted server. | The node that owns the URL is trusted with plaintext; anything that only stores is blind. Close `encryption.md` to at-rest + vault. | #1310, #1307, #1254 | +| [`schema-routes-decision.md`](./schema-routes-decision.md) | Optional schema vs `did:ad:frozen` vs `lib/defaults/*.json`; the `--repopulate-defaults` gap. | `did:ad:frozen` is the on-ramp; optional schema is the write-path policy; `lib/defaults/*.json` only serializes the built-in set. Defaults fingerprint on `Db` open closes the repopulate gap. | #1316, #1245, #1262, #1209, #1251, #1309 | ## Active From 47f9cebd22f4f011396cc7f49b7ee7766a98e702 Mon Sep 17 00:00:00 2001 From: Joep Meindertsma Date: Tue, 1 Sep 2026 21:38:58 +0200 Subject: [PATCH 3/3] docs(planning): record the five architecture decisions as accepted All five RFCs accepted 2026-09-01. Status boxes record the outcome, the README table is renamed to "Decisions", and encryption.md is closed to "at-rest + vault" with an explicit reopen test. Claude-Session: https://claude.ai/code/session_019asLKBrBWY5ovyeCgtmdSd --- planning/README.md | 21 ++--- planning/authority-unit-decision.md | 2 +- planning/commit-retention-floor-decision.md | 2 +- planning/encryption.md | 87 ++++++++++++++------- planning/runtime-boundary-decision.md | 2 +- planning/schema-routes-decision.md | 2 +- planning/trust-model-decision.md | 2 +- 7 files changed, 76 insertions(+), 42 deletions(-) diff --git a/planning/README.md b/planning/README.md index 419d9be1c7..389a75ec3a 100644 --- a/planning/README.md +++ b/planning/README.md @@ -17,20 +17,21 @@ Protocol reference lives in the public docs: discuss how that protocol is used internally, but should not duplicate the wire reference. -## Decisions pending +## Decisions Decision documents: one question each, written as an RFC with a recommendation. -Each starts with a **Decision needed by maintainer** box and ends with the -consequences for open PRs. Once decided, fold the outcome into the owning plan -and move the document to `completed/`. +Each starts with a decision box and ends with the consequences for open PRs. +All five below were **accepted on 2026-09-01**; the status line at the top of +each document records the outcome. Fold each outcome into the owning plan, then +move the document to `completed/`. -| Document | Question | Recommendation | Blocks | +| Document | Question | Outcome (accepted 2026-09-01) | Affects | | --- | --- | --- | --- | -| [`runtime-boundary-decision.md`](./runtime-boundary-decision.md) | Rust-only runtime vs twinned-by-design between `atomic_lib` and `@tomic/lib`. | Rust owns ingest, auth, sync, hashing, genesis; TS owns cache/reactivity/UI; twins only for pure functions with shared fixtures. First `AtomicNode` slice. | #1273, #1274, #1277, #1241, #1278 | -| [`authority-unit-decision.md`](./authority-unit-decision.md) | Drive vs zone as the unit of authority; additive creator chain vs replace-and-replay. | Hybrid: drive stays the identity/replication/fan-out/index unit; the zone chain is the rights unit, additive, creator always writes. Replace semantics deferred behind opt-in. | #1254, #1307, #1310 | -| [`commit-retention-floor-decision.md`](./commit-retention-floor-decision.md) | What must be retained for authorization and audit before commits become envelopes. | Envelope-on-resource (`Tree::Envelopes`, latest signed envelope travels with the snapshot); Loro oplog is the history. Sequence #1274 → #1313 → #1254. | #1313, #1274, #1254 | -| [`trust-model-decision.md`](./trust-model-decision.md) | Blind vs trusted server. | The node that owns the URL is trusted with plaintext; anything that only stores is blind. Close `encryption.md` to at-rest + vault. | #1310, #1307, #1254 | -| [`schema-routes-decision.md`](./schema-routes-decision.md) | Optional schema vs `did:ad:frozen` vs `lib/defaults/*.json`; the `--repopulate-defaults` gap. | `did:ad:frozen` is the on-ramp; optional schema is the write-path policy; `lib/defaults/*.json` only serializes the built-in set. Defaults fingerprint on `Db` open closes the repopulate gap. | #1316, #1245, #1262, #1209, #1251, #1309 | +| [`runtime-boundary-decision.md`](./runtime-boundary-decision.md) | Rust-only runtime vs twinned-by-design between `atomic_lib` and `@tomic/lib`. | `AtomicNode` in `lib/src/runtime/` is the binding runtime; #1277/#1241 bind it, no parallel `simple.rs`/`ffi/`. First slice on `feat/atomic-node-slice`. | #1273, #1274, #1277, #1241, #1278 | +| [`authority-unit-decision.md`](./authority-unit-decision.md) | Drive vs zone as the unit of authority; additive creator chain vs replace-and-replay. | Drive stays the authority unit; #1254 restores the drive fast path, drops `collect_zone_subjects`, keeps the zone chain hybrid/additive. | #1254, #1307, #1310 | +| [`commit-retention-floor-decision.md`](./commit-retention-floor-decision.md) | What must be retained for authorization and audit before commits become envelopes. | Envelope-on-resource. #1313 on hold until `Tree::Envelopes` exists; sequence #1274 → #1313 → #1254. | #1313, #1274, #1254 | +| [`trust-model-decision.md`](./trust-model-decision.md) | Blind vs trusted server. | The node that owns the URL is trusted with plaintext; anything that only stores is blind. `encryption.md` closed to at-rest + vault. Sync F1 closed by the signed state root, not provenance-per-push. | #1310, #1307, #1254 | +| [`schema-routes-decision.md`](./schema-routes-decision.md) | Optional schema vs `did:ad:frozen` vs `lib/defaults/*.json`; the `--repopulate-defaults` gap. | Accepted as policy (#1316): `did:ad:frozen` is the on-ramp, optional schema is the write-path policy. #1251 to become a frozen ontology; bootstrap sentinel-gate fix on `fix/defaults-bootstrap-gate`. | #1316, #1245, #1262, #1209, #1251, #1309 | ## Active diff --git a/planning/authority-unit-decision.md b/planning/authority-unit-decision.md index a910923302..e7992bf1b2 100644 --- a/planning/authority-unit-decision.md +++ b/planning/authority-unit-decision.md @@ -1,6 +1,6 @@ # Unit of authority: drive, zone, or hybrid -**Status:** Decision requested (2026-09-01). +**Status:** Accepted 2026-09-01 — C + A2. The drive stays the authority unit; #1254 must restore the drive fast path, drop `collect_zone_subjects`, and keep the zone chain hybrid/additive. > **Decision needed by maintainer** > diff --git a/planning/commit-retention-floor-decision.md b/planning/commit-retention-floor-decision.md index 8ae3ca94da..67552ac149 100644 --- a/planning/commit-retention-floor-decision.md +++ b/planning/commit-retention-floor-decision.md @@ -1,6 +1,6 @@ # Commit retention floor -**Status:** Decision requested (2026-09-01). +**Status:** Accepted 2026-09-01 — option C. Hold #1313 until `Tree::Envelopes` exists; sequence #1274 → #1313 → #1254. > **Decision needed by maintainer** > diff --git a/planning/encryption.md b/planning/encryption.md index 5c4a08d1f1..86dde27f72 100644 --- a/planning/encryption.md +++ b/planning/encryption.md @@ -1,19 +1,30 @@ # Encryption and replica trust -> **Status:** Exploration for live E2EE / blind replicas (problem 1 below), -> still undecided. Two of the four problems have shipped since the 2026-06 -> draft: **local encryption at rest** (problem 2, 2026-07, -> [`opfs-per-agent-encryption.md`](./opfs-per-agent-encryption.md)) and -> **encrypted backups** (problem 3, vault v1 in `lib/src/vault/`, 2026-08-04, -> [`encrypted-vault-format.md`](./encrypted-vault-format.md)). In practice -> that is candidate model 2, "encrypted archive first"; whether blind *live* -> replication (models 3–4) follows is the open question. Updated 2026-09-01. +> **Status:** Closed: at-rest + vault (2026-09-01), per +> [`trust-model-decision.md`](./trust-model-decision.md). > -> The rest of this document records the design space. It is not an accepted -> architecture for live encrypted replication: we have not decided whether -> Atomic should support blind replicas, what metadata they may observe, how -> encrypted-drive authorization works, or which encryption mode should be the -> product default. +> The open question this document explored — whether an external hosted server +> can be a **blind replica** of a live drive — is closed as *not planned*. The +> node that serves a drive is a **verifier**: it holds the drive key (or the +> plaintext), materializes, indexes, authorizes, and fans out. What ships as +> encryption is problems 2–4 below: local encryption at rest (shipped 2026-07, see +> [`opfs-per-agent-encryption.md`](./opfs-per-agent-encryption.md)), server +> encryption at rest (to build), and the blind vault for backups +> (vault v1 in `lib/src/vault/`, shipped 2026-08-04, +> [`encrypted-vault-format.md`](./encrypted-vault-format.md)). End-to-end +> encrypted replication (problem 1) is not on the roadmap. +> +> **Reopen test.** This reopens only when a paying customer requires the +> external hosted server to be a blind replica — never granted the drive key, +> never a verifier for their drive — *and* accepts that this server then +> provides none of the content-aware services listed under +> [Search, queries, and server features](#search-queries-and-server-features): +> no property queries, no full-text or vector search, no backlinks or derived +> feeds, no server-side plugins, previews, moderation, or AI over that drive. +> A customer who wants any of those from the host wants a trusted verifier, +> which already exists. The sections below are kept as the design record for +> that case; the "Keys" and "Blobs" sections remain the reference for server +> at-rest and blob encryption. ## Question @@ -31,9 +42,11 @@ This is broader than encrypting the redb file. The same logical drive may be: These are separate trust and storage decisions. -## Current direction, not a decision +## Node roles -The most promising model is to distinguish a node's role **per drive**: +The role model below is the vocabulary the decision uses. The **Blind replica** +row is *not planned* for live drives; it survives only as the archive/backup +target (the vault). | Role | Holds drive key | Imports Loro | Materializes and indexes | Stores/relays ciphertext | | --- | --- | --- | --- | --- | @@ -172,7 +185,19 @@ Open key questions: - Does a server ever receive a drive key through an explicit "trusted verifier" grant? -## Possible encrypted replication shape +## Not planned: blind live replication + +The three sections below (replication shape, authorization, compaction) +describe the blind-replica design space. They are retained as a record and are +**not planned**; see the reopen test in the status box. One later change is +worth noting against them: with commits stored as signed envelopes on the +resource rather than as a retained log +([`commit-retention-floor-decision.md`](./commit-retention-floor-decision.md), +#1313), the "blind replica must retain every encrypted update" concern under +[Compaction and retention](#compaction-and-retention) no longer applies to a +verifier node; it would only return with a blind live replica. + +### Possible encrypted replication shape A blind replica cannot safely accept replacement ciphertext as the current resource state. It cannot decrypt, merge concurrent Loro changes, inspect a @@ -226,7 +251,7 @@ A blind replica would: 4. store and relay the envelope; 5. never call `AtomicLoroDoc::from_snapshot` or index the resource contents. -## Authorization is the hardest unresolved part +### Authorization is the hardest unresolved part Atomic currently represents authorization in resource content: @@ -265,7 +290,7 @@ Options under consideration: No option has been selected. -## Compaction and retention +### Compaction and retention A blind replica cannot create or validate the contents of a compacted Loro snapshot. Without another mechanism it must retain every encrypted update. @@ -305,6 +330,10 @@ Open questions: ## Blobs +Still relevant: this is the shape of blob encryption before an S3 or other +blind object store ships (`s3-blob-storage.md`, trust-model step 4). The blind +blob GC problem below only applies to a blind object store, which does no GC. + Encrypted blobs are related but do not need to use the same storage format as Loro updates. @@ -426,28 +455,32 @@ authorization, synchronization, deduplication, and retention. ## Candidate product models -These are options, not decisions: +Outcome per [`trust-model-decision.md`](./trust-model-decision.md): -1. **Trusted-server Atomic only** - - Add local encryption at rest and encrypted backups. +1. **Trusted-server Atomic only** — *chosen*, combined with 2. + - Add local encryption at rest (shipped) and server encryption at rest (to + build). - Keep current server-side materialization/indexing model. - - Lowest complexity; no protection from the hosted server. + - Lowest complexity; no protection from the hosted server beyond at rest. -2. **Encrypted archive first** — where the shipped code sits today (vault v1). +2. **Encrypted archive first** — *shipped* as the vault. - External server stores encrypted checkpoints/backups, not live updates. - - Avoids blind live-sync authorization and compaction initially. + - Avoids blind live-sync authorization and compaction. -3. **Blind replica plus local verifier** +3. **Blind replica plus local verifier** — *not planned*. - External server stores and relays encrypted envelopes. - Clients merge/index locally. - Requires envelope sync, checkpointing, and a blind authorization model. -4. **Optional trusted verifier** +4. **Optional trusted verifier** — *not planned* as a separate mode; every + serving node is the trusted verifier. - Same encrypted replication format, but selected servers receive drive keys and run verifier services. - Most flexible, but key grants and role transitions become load-bearing. -## Decisions required before implementation +## Decisions that were required before blind replication + +Kept for the reopen case; none of these is being worked on. - What exact threat model and metadata-leakage budget are we targeting? - Is blind live replication a core requirement or should encrypted backups ship diff --git a/planning/runtime-boundary-decision.md b/planning/runtime-boundary-decision.md index 404a96fa23..0374eaff4e 100644 --- a/planning/runtime-boundary-decision.md +++ b/planning/runtime-boundary-decision.md @@ -1,6 +1,6 @@ # Runtime boundary: Rust-only vs twinned-by-design -**Status:** Decision requested (2026-09-01). +**Status:** Accepted 2026-09-01 — option C. `AtomicNode` in `lib/src/runtime/` is the binding runtime; #1277 and #1241 must bind it, no parallel `simple.rs` / `ffi/` surface. A first slice is being built on branch `feat/atomic-node-slice`. > **Decision needed by maintainer** > diff --git a/planning/schema-routes-decision.md b/planning/schema-routes-decision.md index d0be89dde1..63f5c71263 100644 --- a/planning/schema-routes-decision.md +++ b/planning/schema-routes-decision.md @@ -1,6 +1,6 @@ # Schema routes: one on-ramp for Classes and Properties -**Status:** Decision requested (2026-09-01). +**Status:** Accepted 2026-09-01 as policy (#1316) — option B. #1251 is to be converted to a frozen ontology; the bootstrap sentinel-gate fix is being built on branch `fix/defaults-bootstrap-gate`. > **Decision needed by maintainer** > diff --git a/planning/trust-model-decision.md b/planning/trust-model-decision.md index 2128b4146a..fe110ae56e 100644 --- a/planning/trust-model-decision.md +++ b/planning/trust-model-decision.md @@ -1,6 +1,6 @@ # Trust model: the node that owns the URL is trusted; anything that only stores is blind -**Status:** Decision requested (2026-09-01). +**Status:** Accepted 2026-09-01 — option C. For [`unified-sync.md`](./unified-sync.md) F1 the fix is the signed state root ([`drive-reconciliation.md`](./drive-reconciliation.md)), chosen over provenance-per-push envelopes on `SYNC_PUSH`. > **Decision needed by maintainer** >