0b37d17c2f
Les deux contrats et la doctrine portent des centaines de citations `file:line` vers `src/`. Le rangement par destin les périmait en bloc — 208 reprises (45 chemins `src/x.ts`, 163 mentions nues). Sans ça la réorganisation pourrissait l'instrument même qui tient la discipline qu'elle sert. Et un défaut relevé par le contrat interne, vérifié : `ng-proxy` fabriquait un membre `login`. `@ng-org/web` n'expose aucune méthode de ce nom — zéro occurrence dans les déclarations installées comme dans `sdk/js/lib-wasm/src/lib.rs` — mais le proxy répondait une fonction au lieu d'`undefined`, laquelle plantait à l'appel. C'était le seul endroit où ce wrapper ajoutait à la surface du SDK, contre son propre en-tête. 157 tests unitaires, typecheck src/test/e2e vert.
146 lines
20 KiB
Markdown
146 lines
20 KiB
Markdown
# Brief — align the caps emulation with the real NextGraph model
|
||
|
||
**Brief (incubation) — 2026-07-20.** See the reference `docs/readcap-and-nuri-model.md`.
|
||
|
||
## Problem
|
||
|
||
`emulated-verifier/caps.ts` emulates read rights as an **ACL** (`Map<Nuri, Set<PrincipalId>>`, `grantRead(doc, grantee)`) — **the inversion** of the real NextGraph model (key possession). Consequences: no notion of a **cap-less reference**, grant/revocation **instantaneous and total** (instead of durable sealing + re-key), and an API (`declareConnections`) that consumers have to **re-declare every session**. This divergence makes it impossible to properly build models that rest on the real semantics — in particular **anonymous presence** (naming/counting without reading).
|
||
|
||
## Objective: shape-fidelity, NOT security
|
||
|
||
The polyfill does **NOT match** the security of finished NextGraph, and does not try to. The shared wallet plus the absence of crypto make the emulation **deliberately insecure** (everything is in plaintext, any marker is forgeable) — a dev/staging vehicle, not a goal. **Sole objective**: expose the **RIGHT SHAPE** of the future primitives so that consumers (Festipod) are coded against the **correct mental model** and **do not have to be rewritten** when NextGraph is finished.
|
||
|
||
Corollary: **"no crypto" is not a problem**; what matters is being **in the same logic, with RIGOR**. A criticism of the form "an attacker reads the plaintext / forges a marker" is **correct but out of scope**. What is **unacceptable** = exposing the **wrong shape** (e.g. an ACL where the real thing is key possession) → the consumer codes against a model that will not exist. **The ACL inversion of ReadCaps IS that lack of rigor** — the central defect to fix.
|
||
|
||
## Enforcement mechanism: LIGHTWEIGHT crypto simulation (anti-ACL, anti-shortcut)
|
||
|
||
For the shape to be **really** key-possession (and not an ACL in disguise), a doc's data is **stored encrypted** (per-doc symmetric encryption, however lightweight) and the **ReadCap = the key**. Invariant (cf. `docs/vision.md`):
|
||
|
||
> a **bare `did` (without a ReadCap)** does **NOT** allow reading; a **NURI with a ReadCap** is **sufficient and required**.
|
||
|
||
This **prevents the shortcuts** the adversary pointed out (#4/#6: reading the plaintext, `sparqlQuery`/`inbox.read` bypassing the filter) and **forbids** falling back on an ACL — that is the heart of "same logic, with rigor".
|
||
|
||
**Target — NOT the current state**: every surface that returns data will have to go through decryption-with-key. **Today this is FALSE, and far more broadly than this brief first stated** — mapping of 2026-07-27, VERIFIED: **only 4 sites consult the caps** (`use-shape`, `read-filter`, `read-model.readUnion`, `discovery.submitToIndex`). Everything else returns data with no guard:
|
||
|
||
| Surface | State |
|
||
|---|---|
|
||
| `docs.sparqlQuery` / `sparqlUpdate` | **bypasses** — they call the injected `ng` **directly** (an accepted constraint, to avoid a double-Proxy `DataCloneError`). **The widest breach**: a session id + a NURI are enough to read everything. |
|
||
| `inbox` (`read` / `readSynced` / `materialize` / `watch`) | **bypasses** — no cap consulted; the drops go to whoever asks for them |
|
||
| `store-registry` (**zero** reference to caps in the whole file) | **bypasses** — the account→NURI root of trust is universally readable |
|
||
| `discovery.readIndex` | **bypasses** on read (caps checked on write only) |
|
||
| `subscribe`, `open-repo` | **bypass** — the subscription push carries the doc state with no check |
|
||
| `watch-shape` | deliberately delegates to `readUnion` (does not re-filter) |
|
||
|
||
**And the WRITE guard is already stillborn**: `ng-proxy` guards `sparql_update`, but `docs` bypasses the proxy **by design**, and **all** internal writers go through `docs`. So the guard only fires for an app calling `ng.sparql_update` on the exported `ng` — which Festipod does not do. `grantWrite` / `canWrite` are **decorative**. *(This finding reinforces §1 of the adversarial review: writing is not an axis "to be added", it is an axis we believed was covered and is not.)*
|
||
|
||
**This inventory IS the scope of P1b.** The only existing guard (`caps.canRead`) is moreover a **set-membership ACL** — the very inversion the vision forbids.
|
||
|
||
1. **Two distinct reference shapes**: cap-less (names/locates without reading — aligned with the NURI without `:r:`) vs cap-bearing (id + key/token). Absent today.
|
||
2. **Grant = delivering a cap-token to a recipient** (emulating the sealing: the recipient *receives* the token in their inbox; it is **possession** of the token that authorizes reading — not an ACL row checked per principal).
|
||
3. **Enforcement by possession**: readers (`read-filter`, `use-shape`) only see what they **hold the token for**, not "what they are in the readers set for".
|
||
4. **Resolving a cap-less** = naming / proving existence / counting, **without** exposing the content (support for anonymous presence).
|
||
5. **Revocation = re-key** emulated: invalidate the old token, re-deliver a new one to the remaining authorized holders; **non-retroactive**.
|
||
|
||
## ~~Widened scope: the WriteCap (= membership)~~ — DROPPED (2026-07-21)
|
||
|
||
**This section was wrong and is kept struck through as a guardrail.** It imported a notion of *membership* read from the **current state** of `nextgraph-rs` (`AddMember`, `PermissionV0`, `member_pubkey`) and promoted it into a **target shape**. But (a) those types are **inert scaffolding** at runtime — `verify_sig` / `verify_perm` are only called in unit tests, and `Repo`s are built with `members: HashMap::new()`; and (b) the target model **has no notion of membership at all**: only **keys and URLs**, symmetric and asymmetric. A shape in terms of `member`/`role`/`permission` is therefore exactly the **wrong shape** that this brief exists to prevent.
|
||
|
||
**The methodological lesson, which is worth more than the dropped section**: reading NextGraph's current state in order to **deduce** the target shape is a mistake — the current state contains unfinished work that must not be frozen into the polyfill. The source serves to verify an existing **mechanism**, never to infer an **intent**.
|
||
|
||
Erroneous content kept below as a record:
|
||
|
||
<details>
|
||
<summary>Dropped section</summary>
|
||
|
||
**Why this is here and not elsewhere.** The brief was at first ReadCap-centric; an adversarial finding showed that it **does not compose** with its consumer: the Festipod brief on "Set-based sign-ups" needs to **deduplicate** participations (one user = one participation per event), and the only non-application-level basis available is the commit's **author signature** — hence a **write** primitive, not a read one. A polyfill that only exposes the ReadCap shape leaves the consumer to invent its own application-level dedup → exactly the wrong shape.
|
||
|
||
**The real shape (VERIFIED, cf. `readcap-and-nuri-model.md` §1)** — and it is **asymmetric** with reading, which is the easiest point to miss:
|
||
|
||
- **Reading = possession of a key.** No ACL. Whoever holds, reads.
|
||
- **Writing = membership + permissions** (`AddMember`, `AddPermission` on the `RootBranch`). It really **is an authorization list** — not possession. Emulating writing "by token possession" would be just as wrong as the current read ACL, in mirror image.
|
||
- **Commits ARE signed** by a `UserId` (a **technical** key, distinct from the profile) — so a dedup identifier **exists** natively, with no application-level pseudonym.
|
||
- **But verifying a signature requires being a member of the repo** (access to the `member_pubkey`). A non-member third party sees a signed commit without being able to attribute it.
|
||
- **The inbox drop is NOT authenticated** (anonymous sealed box): a declared `from` is content, not proof.
|
||
|
||
**What that imposes on the polyfill.** Expose `membership` as a primitive **distinct** from cap possession, with at minimum: adding/removing a member of a repo, reading the members map **when one is a member**, and **verifying the author of a commit** (→ an author digest, **per-overlay hence per-store**). It is this last point that unblocks the dedup on the Festipod side.
|
||
|
||
**The shape consequence, to be documented explicitly** (otherwise the consumer picks the wrong model): the author digest being **per-store**, the choice of how stores are carved up **is** the choice of the correlation level. A **per-user stable** store gives an identifier traceable **across events**; a **per-event** store gives a pseudonym **local to the event** — dedup possible, correlation impossible. Festipod needs the second. So the polyfill must make this carving **expressible**, not freeze it.
|
||
|
||
**Still open**: "can the creator of an event be a member of the store that contains the participations, without holding its read key?" — that is, membership (writing/verification) and possession (reading) genuinely **orthogonal**. If NextGraph couples them, verified dedup and anonymity are mutually exclusive, and it is the Festipod brief that must settle what it sacrifices. **To be verified before shaping the API.**
|
||
|
||
</details>
|
||
|
||
*(Question now moot: there is no membership. The dedup does not go through signature verification — see the Festipod brief on "sign-ups".)*
|
||
|
||
## Open questions
|
||
|
||
- **SETTLED (PO directive, 2026-07-21)**: we **simulate the crypto** (per-doc encrypted data, cap = key). "Semantics only" (a token registry) is **discarded** — it turns back into an ACL and lets the plaintext be read. What remains to settle is the **level** of simulation (real lightweight encryption vs masked read-model projection), **before P1**.
|
||
- NURI representation, cap-less vs cap-bearing, in the emulation (mirror `:r:`).
|
||
- ~~Should **keyless fetch** be allowed (resolving a cap-less into existence/count without the content)~~ — **SETTLED, and negatively (2026-07-27)**: not constructible. Addressing itself presupposes the cap, so there is nothing to expose. See the corrected Q1 verdict below. Kept struck through rather than deleted: the hypothesis is intuitive and will otherwise be re-formed.
|
||
- API migration: `declareConnections`/`grantRead` → `seal(cap, recipient)` + `inbox → received caps`. Breaks consumers (the app-side `declareConnections` disappears).
|
||
|
||
## P0 — "keyless-resolve" spike (the blocker, BEFORE any P1)
|
||
|
||
**Load-bearing question**: can a holder of a **cap-less reference** (`did:ng:o:{id}:v:{overlay}`, without `:r:`), **without ever reading the content**:
|
||
- **Q1 — Existence / fetch**: prove/retrieve the presence of the (encrypted) blocks from the broker? Or does the broker require a ReadCap/membership in order to serve the blocks?
|
||
- **Q2 — Deletion**: distinguish "exists" from "deleted"? *(The FRAGILE point: NextGraph is an append-only CRDT — a withdrawal = a **tombstone commit** that one would have to **read** in order to know about → potentially **the key is required**. And the **decrement on leave** depends on it.)*
|
||
- **Q3 — Confidentiality**: does the key remain **required** in order to decrypt (keyless never gives the content)?
|
||
|
||
**Why this is the blocker**: the whole **anonymous counter** (counting/validating cap-less refs without reading) AND the **decrement on leave** depend on it. **If NO** → "anonymous counter via cap-less ref" is **not constructible in the target** → Festipod must **not** code that shape (guaranteed rewrite). **If YES** → P1 exposes `resolveCapLess(nuri) → {exists|deleted}` (never any content), and the emulation simulates it faithfully.
|
||
|
||
**Method** (cheap, decisive):
|
||
1. **Trace** in `nextgraph-rs` the broker/verifier **fetch authorization** path: who serves the blocks (`BlocksGet`/`TopicSync`/`OverlaySync`)? is a cap/membership checked, or is `id+overlay` enough? is the *outer* overlay public? is a deletion observable without the key?
|
||
2. *(Optional)* **decisive e2e test** (in the style of `e2e/reactivity-doc-subscribe.ts`): B holds the cap-less ref, attempts fetch/existence **without** the key, verifies that it **does not reach** the content. Empirical proof > source.
|
||
3. *(Or)* confirm with the NextGraph dev — the fastest.
|
||
|
||
**Deliverable**: YES/NO/PARTIAL per Q1/Q2/Q3 + the exact primitive (file:line) + the API shape to expose (if YES), or the finding that the counter changes (if NO).
|
||
**Gated decision**: YES → P1; NO → the sign-ups brief revisits the counter (not anonymous, or another primitive).
|
||
|
||
### Spike verdict (2026-07-21) — VERIFIED in `nextgraph-rs`
|
||
|
||
| | Answer | Evidence |
|
||
|---|---|---|
|
||
| **Q1 — existence/fetch without a cap** | **NO** *(corrected on 2026-07-27 — the initial "partial YES" verdict over-read the evidence)* | Read access control does indeed let you through (reads are not cap-gated) — **but addressing presupposes the cap**: no existence command at the SDK level; the only probe is internal to the crate, requires `BlockId`s **and** a loaded repo, and targets the **inner** overlay derived from the read secret. A cap-less reference has neither `BlockId` nor the required overlay. See `readcap-and-nuri-model.md`. |
|
||
| **Q2 — detect a deletion without the key** | **NO** | Append-only broker; a deletion is an **encrypted tombstone commit** (`RemoveRepo`), a no-op on the verifier side. Without the key one observes "some activity", never "a deletion". |
|
||
| **Q3 — confidentiality** | **YES** | Blocks stored as ciphertext; the key is `#[serde(skip)]` (`types.rs`), derived from the `ReadCapSecret`. Keyless **never** gives the content. |
|
||
|
||
**What that decides.**
|
||
- **P1 is unblocked**: `resolveCapLess(nuri) → { exists }` is the right shape — but **`{ exists | deleted }` is NOT**. Do not expose a `deleted` state; that would be inventing a capability the target will never have (precisely the failure mode this brief fights).
|
||
- **Withdrawal has to be a message, not an observation.** On the consumer side: an explicit *nudge*. The polyfill has **nothing** to emulate for that — it just must not pretend otherwise.
|
||
- **Settled by the Q1 correction**: the anonymous counter can**not** rest on an existence validation — that is not constructible. So it rests on something **declarative**, which is acceptable (outside the security scope) as long as the **exposed shape does not lie**: do not expose an existence primitive that the target will not offer.
|
||
|
||
## P1a — the surface — **DONE (2026-07-28)**
|
||
|
||
**Extracted into its own brief: [`2026-07-27-p1a-cap-surface.md`](2026-07-27-p1a-cap-surface.md), which records what landed where.**
|
||
|
||
The ACL inversion — *the central defect this whole chantier exists to fix* — is gone: `emulated-verifier/caps.ts` is a keyring, sharing is a per-document delivery to an inbox, and a bare reference reads nothing. **P1b is now the blocker for any privacy claim**: the emulated key is derived (hence reproducible) and the bypass inventory below is untouched.
|
||
|
||
In two lines: a single new type (`ReadCap`), a keyring (`capFor`), a per-document share to an inbox (`shareCap`) — and nothing else. The branded types, `resolveCapLess`, `receivedCaps`, `refOf`, `parseNuri` and `PrincipalId` were **discarded** after a double adversarial review; the reasons are in that note.
|
||
|
||
This brief remains the **overall effort**: P0 verdicts, P1b scope, P2–P4 batches, and the adversarial reviews.
|
||
|
||
## Phase sketch
|
||
|
||
- ~~**P1a** — **the surface**~~ **DONE 2026-07-28**: one new type (`ReadCap`), a keyring (`capFor`), per-document sharing to an inbox (`shareCap`). It was the only batch blocking Festipod, and it no longer does. See [`2026-07-27-p1a-cap-surface.md`](2026-07-27-p1a-cap-surface.md) for what landed where. *(An earlier draft listed `DocRef`/`DocCap` branded types, `resolveCapLess` and a durable `sealCapTo` here — all three were **dropped** after adversarial review; the note says why.)*
|
||
- **P1b** — **the enforcement**: per-doc encryption (cap = key) and closing out the inventory of bypasses. Without it the shape is right but the isolation remains false — so nothing "anonymous" can be claimed. **Requalified 2026-07-30**: the bypass inventory below is really a **virtual user boundary** problem, and it is now specified on its own in [`2026-07-30-virtual-wallet-boundary.md`](2026-07-30-virtual-wallet-boundary.md). That lot precedes or absorbs this one — encrypting each document while any wallet can reach any document secures the windows with the door open.
|
||
- **P2** — replace the ACL with a **token possession** model (grant = deliver to a recipient; enforcement = possession). *Requalified by the adversarial review: the real content of P2 is **durability + cap-less + re-sharing by the holder**, not "inverting the ACL" — without crypto, inverting produces no observable delta.*
|
||
- **P3** — revocation by re-key (invalidation + re-delivery, non-retroactive).
|
||
- ~~**PW** — WriteCap = membership~~ **DROPPED (2026-07-27)**. This batch rested on a notion of membership that **does not exist** in the model (everything is keys and URLs); see the struck-through section above. It was moreover justified by a need for **dedup via signature verification** that the consumer turns out not to have: its dedup rests on the overlay, which is store-scoped. *For the record, two facts verified along the way, not to be re-discovered*: author signature verification **is not called at runtime**; and the author digest is **not** keyed under the read secret — it is keyed by the **outer** overlay, which is public *(it is the commit's **content** that is encrypted, hence the fact that verifying still presupposes being able to read)*. Detail in `nextgraph-current-state.md`.
|
||
- **P4** — adapt the consumer API + `migration-guide.md`. *The adversarial review requalifies this batch: it is not an API swap but a **consumer re-architecture** (the grant moves to connection acceptance and becomes persistent; `declareConnections` disappears).*
|
||
|
||
## Adversarial review (2026-07-20) — to be integrated
|
||
|
||
An adversary refuted the brief (7 findings — the 7th marked *(Plausible)*). **To be read through the filter of the Objective above** (shape, not security). The purely **security** criticisms — readable plaintext content (#4), forgeable markers — are **ACCEPTED / out of scope**: the polyfill does not seek to prevent them. What remains are the real **SHAPE / rigor** defects (to be fixed), and a question of **future model** (#5):
|
||
|
||
1. **WriteCap forgotten, and "possession" is FALSE there.** Writing is **membership/permissions** (`AddMember`) — an **authorization list**, not key possession (ref. §1); `ng-proxy.ts:28-48` guards every `sparql_update`. → keep a **WriteCap = membership track**; **possession concerns ONLY reading**.
|
||
2. **P2 "possession without crypto" = the ACL renamed.** Without crypto, "who holds which token" = `Map<doc, Set<holder>>` = the current `readers`: **no observable delta**. The real deltas are **durability + cap-less + re-sharing by the holder** — THAT is the content of P2, not "inverting the ACL".
|
||
3. **Non-retroactive revocation NOT EMULABLE** without versioning: `read-model.ts:112-118` only reads the current state → "invalidate the old token" = total removal = the inverse of the real thing (the former holder decrypts the **prior** versions). → emulate only "no new reads after re-key" + **document non-retroactivity as non-emulable**.
|
||
4. **cap-less "without exposing the content" ILLUSORY in the emulation**: content in **plaintext** in the shared wallet; `sparqlQuery`/`inbox.read` **bypass** the filter; `read-filter.ts:30-35` is all-or-nothing. → cap-less anonymity requires either **real crypto** or a **masked read-model projection** (counting without reading). "Replacement not overhaul" is **overstated**.
|
||
5. **Keyless-fetch = INFERRED and load-bearing**: add a **P0 spike** that verifies it **before** P1 (otherwise the model — polyfill AND Festipod — is not constructible).
|
||
6. **Migration ≠ API swap.** `declareConnections` is replayed every session because the map is ephemeral; durable seals move the grant to **connection acceptance** + persist "already sealed" — no analogue of `protectedDocsOf` + the re-derivation loop. **Consumer re-architecture.**
|
||
7. *(Plausible)* delivering a cap through the async inbox **does not re-trigger** `watchShape` (which subscribes to data docs, not to caps) → unreadable views left **stale** until another change. → plan for a cap-mutation signal.
|
||
|
||
**Consequence**: add **P0 (keyless-fetch spike)** up front and a **distinct WriteCap track**; requalify P2 (the real content = durability + cap-less + re-sharing, not "inverting the ACL"); record that **without crypto, read privacy is not applicable** (choose: real crypto vs masked projection).
|
||
|
||
Links: `readcap-and-nuri-model.md`, `packages/client/src/emulated-verifier/caps.ts`. On the consumer side, the Festipod brief "realign the sign-ups" depends on this effort.
|