docs: reprendre les citations après le rangement, et retirer le membre login fabriqué

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.
This commit is contained in:
Sylvain Duchesne
2026-08-04 12:56:02 +02:00
parent cd096de2b0
commit 0b37d17c2f
19 changed files with 151 additions and 145 deletions
@@ -4,7 +4,7 @@
## Problem
`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).
`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
@@ -113,7 +113,7 @@ Erroneous content kept below as a record:
**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: `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.
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.
@@ -142,4 +142,4 @@ An adversary refuted the brief (7 findings — the 7th marked *(Plausible)*). **
**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/caps.ts`. On the consumer side, the Festipod brief "realign the sign-ups" depends on this effort.
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.
+10 -10
View File
@@ -11,7 +11,7 @@ The spec below is unchanged — read it first. Everything from here to *Why this
> **Superseded in places by later lots — read with [`2026-07-30-virtual-wallet-boundary.md`](2026-07-30-virtual-wallet-boundary.md).** This report was accurate on 2026-07-28 and is kept as written; four of its statements have since been overtaken, and a fifth was wrong when written:
>
> - *"ReadCaps are NOT persisted as caps anywhere. There is no key store"* — **no longer true.** Both durable registers are now emulated: `shim:readCap` on the store's Store branch (`AddRepo`) and `shim:link` on its User branch (`AddLink`). Caps are read back, not recomputed.
> - *"Processing inboxes … Not started"* — **done** (`src/connect.ts`), at both levels, including per-document inboxes.
> - *"Processing inboxes … Not started"* — **done** (`src/emulated-verifier/connect.ts`), at both levels, including per-document inboxes.
> - *"`Nuri` and `ReadCap` are plain strings"* — **superseded the same week**: they are template literal types, so the confusion the runtime guard catches is now also a compile error. The *Typing* section below records the change; the earlier sentences were not rewritten.
> - The `:k:` segment throughout — **a ReadCap is `r:`** (`BlockRef::readcap_nuri`, `engine/repo/src/types.rs:518-521`), reported by NextGraph's developer and verified. `:k:` belongs to objects, files and commits.
> - *"That branch lists the store's documents… It is the owner's keyring. Upstream, the keyring is the wallet"* — **wrong when written**, and it is the sentence that produced a global in-memory "keyring". There is no keyring object; the wallet holds one root key per user. See [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4quater. The word *keyring* is left standing everywhere below because this report is kept as written; read it as *"what the holder holds"*, which is what the code now calls it.
@@ -22,13 +22,13 @@ The spec below is unchanged — read it first. Everything from here to *Why this
| Spec | Where |
|---|---|
| `Nuri` / `ReadCap` (plain strings, `:r:` discriminant) | `packages/client/src/types.ts`, `src/nuri.ts` (internal parse/mint/derive) |
| Keyring, one per identity — `capFor` | `src/caps.ts` (`CapRegistry`), surfaced as `capFor` in `src/polyfill.ts` |
| Caps of my OWN documents (the emulated `AddRepo { read_cap }`) | `src/store-registry.ts` `fileOwnCaps`, called from `createEntityDoc` and `listMyEntityDocs` |
| `shareCap(cap, toInbox)` + reception with no dedicated operation | `src/inbox.ts` (`shareCap`, and the inline absorption in `read`) |
| `publishRepoLink` | `src/caps.ts`. *(The published-only guard it fed lived in `src/discovery.ts`, removed 2026-07-30 — see the boundary brief.)* |
| Possession gate on reads | `src/read-model.ts` (`readUnion`), `src/read-filter.ts`, `src/use-shape.ts` |
| Cap-mutation signal (a delivered cap re-triggers reads) | `CapRegistry.onChange``src/watch-shape.ts` |
| `Nuri` / `ReadCap` (plain strings, `:r:` discriminant) | `packages/client/src/model/types.ts`, `src/model/nuri.ts` (internal parse/mint/derive) |
| Keyring, one per identity — `capFor` | `src/emulated-verifier/caps.ts` (`CapRegistry`), surfaced as `capFor` in `src/polyfill.ts` |
| Caps of my OWN documents (the emulated `AddRepo { read_cap }`) | `src/shared-wallet/account-registry.ts` `fileOwnCaps`, called from `createEntityDoc` and `listMyEntityDocs` |
| `shareCap(cap, toInbox)` + reception with no dedicated operation | `src/surface/inbox.ts` (`shareCap`, and the inline absorption in `read`) |
| `publishRepoLink` | `src/emulated-verifier/caps.ts`. *(The published-only guard it fed lived in `src/discovery.ts`, removed 2026-07-30 — see the boundary brief.)* |
| Possession gate on reads | `src/surface/read-model.ts` (`readUnion`), `src/emulated-verifier/read-filter.ts`, `src/surface/use-shape.ts` |
| Cap-mutation signal (a delivered cap re-triggers reads) | `CapRegistry.onChange``src/surface/watch-shape.ts` |
| Acceptance test (§8) | `test/cross-user-access.test.ts` (see below); isolation end-to-end in `test/isolation-active.test.ts`. *Originally `test/watch-shape.test.ts` (e), on the discovery fold — dropped 2026-07-30 with `discovery.ts`; the property it proved is covered on the model's own terms by the cross-user scenario.* |
| Cross-user scenario (§5 non-recursiveness) | `test/cross-user-access.test.ts` — see below |
@@ -192,7 +192,7 @@ Not started. It changes the consumer contract in the right direction (one less o
- **Unit suite green — 146 tests**, typecheck clean on `src`, `test` and the e2e harness.
- The typing was verified from a **consumer's** point of view, not just the library's: a synthetic app compiled against the entry points shows the two real mistakes (`shareCap(bareNuri, …)` and passing a raw `string` from storage) as compile errors, while every correct path — `capFor(doc)``shareCap(cap, inbox)`, and narrowing with the exported guards — needs no cast.
- The acceptance test was **mutation-checked**: reverting both gardes (the discovery fold and the `readUnion` possession gate) makes `watch-shape.test.ts` (e) fail with the bare-referenced document reappearing. The test has teeth.
- **The e2e ran against the live broker (`nextgraph.eu`) on 2026-08-03 — 39 passed, 0 failed.** The first run was 22/8, and the eight refusals were not test noise: they exposed a **real hole in the surface**. `docs.docCreate` filed no cap for the creator, so a consumer could create a document through the public primitive and then be refused reading or writing it. Upstream that cannot happen — `doc_create` commits `AddRepo { read_cap }` to the store's Store branch, so the creator holds it from the first instant. Fixed at `packages/client/src/docs.ts:73`, and deliberately NOT replicated in `physical.ts`: the shim's own documents belong to no user, and `store-registry` files their caps where it knows whose they are. The remaining failures were the harness acting as a second identity without establishing it (`createEntityDoc(id, …)` with someone else connected) or reading an arbitrary document as an inbox; both are now `setCurrentUser` + `walletInbox`, which is what a consumer must do too.
- **The e2e ran against the live broker (`nextgraph.eu`) on 2026-08-03 — 39 passed, 0 failed.** The first run was 22/8, and the eight refusals were not test noise: they exposed a **real hole in the surface**. `docs.docCreate` filed no cap for the creator, so a consumer could create a document through the public primitive and then be refused reading or writing it. Upstream that cannot happen — `doc_create` commits `AddRepo { read_cap }` to the store's Store branch, so the creator holds it from the first instant. Fixed at `packages/client/src/surface/docs.ts:73`, and deliberately NOT replicated in `shared-wallet/physical.ts`: the shim's own documents belong to no user, and `store-registry` files their caps where it knows whose they are. The remaining failures were the harness acting as a second identity without establishing it (`createEntityDoc(id, …)` with someone else connected) or reading an arbitrary document as an inbox; both are now `setCurrentUser` + `walletInbox`, which is what a consumer must do too.
- **An e2e run against a persistent wallet must use a FRESH identity per run.** The second run was green and the third was not, on unchanged code: moving the inbox tests onto `walletInbox(id)` made the inbox *stable for its owner* — which is the point of an inbox — so a fixed id accumulates every past run's deposits and `deposits.length === 2` drifts to 4. Green-then-red on identical code is the tell. The disposable thing is the **user**, not the inbox: `run.ts` now stamps `@inbox-user-`/`@watcher-`/`@friend-` with `Date.now()`, as it already did for `@alice-`. Any future step that resolves a durable per-user document (inbox, stores, Links) inherits this constraint.
- **The cap registry is process-wide and `bun test` shares modules across files**, so suites that read without declaring caps now reset explicitly (`read-model.test.ts`, `watch-shape.test.ts`). Worth knowing before adding a suite.
@@ -206,7 +206,7 @@ Written 2026-07-27, after two adversarial reviews and three corrections from the
## Why this lot exists
`caps.ts` currently models read rights as an **ACL** — a `Map<doc, Set<principal>>` plus `grantRead(doc, grantee)`. That is the **exact inversion** of the real model, where reading is **key possession**: whoever holds the key reads, and there is no authorization list anywhere.
`emulated-verifier/caps.ts` currently models read rights as an **ACL** — a `Map<doc, Set<principal>>` plus `grantRead(doc, grantee)`. That is the **exact inversion** of the real model, where reading is **key possession**: whoever holds the key reads, and there is no authorization list anywhere.
This is not a security problem — the library is deliberately insecure and that is accepted (see `../vision.md`). It is a **shape** problem, and shape is the only thing this library exists to get right. A consumer coded against an ACL is coded against a model that will never exist, and will have to be rewritten.
@@ -10,7 +10,7 @@
>
> And four more, all confirmed:
>
> 4. **D4 would delete a working recovery path.** Inbox deposits are never removed (`packages/client/src/inbox.ts`), so a second device/tab recovers its caps by re-reading. localStorage-without-re-reading loses them permanently, and contradicts P1a's delivered doctrine that per-process rebuild "is correct".
> 4. **D4 would delete a working recovery path.** Inbox deposits are never removed (`packages/client/src/surface/inbox.ts`), so a second device/tab recovers its caps by re-reading. localStorage-without-re-reading loses them permanently, and contradicts P1a's delivered doctrine that per-process rebuild "is correct".
> 5. **D3 is false outside entity documents.** `capFor(scopeIndexDoc)` and `capFor(walletInbox)` are undefined before *and after* `listMyEntityDocs` — their caps can only ever be derived. Yet the boundary brief requires them reachable. Upstream that root comes from the wallet plus `AddSignerCap` on the User branch — a level the fact table omitted entirely.
> 6. **`doc_create` writes four times, not two** (+ the class quad on the Header branch, + `AddSignerCap` on the User branch).
> 7. **Ordering defect: D2 before the boundary guard opens cap harvesting.** Once caps are triples in `scopeIndexDoc(bob,…)`, and both `scopeIndexDoc` and `docs.sparqlQuery` are exported, `setCurrentUser("mallory")` reads Bob's caps. Today `mintCap` is unexported, so a NURI yields nothing. **The guard must land before the caps become triples.**
@@ -93,7 +93,7 @@ The in-memory `CapRegistry` then stops being "the keyring" and becomes what it a
Verified: there is no received-caps register upstream, and inventing one would expose a shape the target does not have. What upstream does is persist the `read_cap` of every **opened** repo in local user storage.
So the emulation is a **local, per-virtual-user store** — the same nature as `accounts.ts`'s existing `IdentityStore` (localStorage). This ends "re-read the inbox every session to recover caps", which the PO identified as the wrong model: an inbox is a queue you consume, not a store you re-read.
So the emulation is a **local, per-virtual-user store** — the same nature as `shared-wallet/accounts.ts`'s existing `IdentityStore` (localStorage). This ends "re-read the inbox every session to recover caps", which the PO identified as the wrong model: an inbox is a queue you consume, not a store you re-read.
*Open*: whether to do D4 in this lot or after the boundary lot. It is the piece with the most design risk, and it is not needed for D1D3 to be correct.
@@ -129,7 +129,7 @@ So this is not a P1b concern, it is a **shape** concern — the one thing this l
- **My own documents** → the cap goes **beside the NURI in the scope container**, which is the emulation of `AddRepo { read_cap }` on a branch of the store. Today the container stores only `shim:contains <nuri>` and the cap is **re-derived** from that NURI; it gains a `shim:readCap` beside it. Deriving then stops being how a cap is *recovered* and becomes merely how the stand-in value is *minted* — the single function P1b replaces.
- **Caps I received** → into the emulation of `AddLink { read_cap }` on the **User branch** of the private store. Verified 2026-07-30 (see [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4quinquies): that register exists, it is explicitly for **external repos**, and its stated purpose is to *"share with all its device a new Link they received"* — wallet-resident and cross-device. So a received cap belongs **inside the virtual user**, like everything else. *(Two earlier versions of this note were wrong and are recorded in [`2026-07-30-users-stores-branches.md`](2026-07-30-users-stores-branches.md): the first blamed key derivation and proposed a "keyring document"; the second concluded no register existed at all and proposed browser-local storage — which would have put library data OUTSIDE even the physical user. Both were refuted; `AddLink` is the answer.)*
3. ~~**The guard at the four passage points**~~**DONE 2026-07-30.** `src/reach.ts` holds the boundary as **two rules on one criterion — possession — implemented in two places** (PO directive):
3. ~~**The guard at the four passage points**~~**DONE 2026-07-30.** `src/emulated-verifier/reach.ts` holds the boundary as **two rules on one criterion — possession — implemented in two places** (PO directive):
- **Rule 1, authorization**, at the passage points (`assertMayReach`, called from `docs.sparqlQuery` / `sparqlUpdate`): nothing reaches `ng` unless the connected user possesses the document's cap.
- **Rule 2, do not even attempt**, at the callers (`mustNotAttempt`, applied in `read-model.readUnion`, which now filters BEFORE opening or reading): a reader that holds no cap does not issue the operation at all. Upstream you cannot even address a repo you have no cap for, so asking about one is not "a read that will be refused" — it is a read with no meaning.
@@ -149,7 +149,7 @@ The PO's framing, which replaced the exemption list entirely:
> Clearly distinguish what is polyfill machinery (and therefore the PHYSICAL user) from what is a virtual user's operation. Use different functions, probably grouped in different namespaces — because one API is exposed to the app and the other must never be.
`src/physical.ts` now holds `physicalCreate` / `physicalQuery` / `physicalUpdate`, with `ensurePhysicalRepoOpen` and `subscribePhysicalDoc` as their open/subscribe counterparts. They are unguarded, and **never exported from the package** — a test asserts it, because a regression there is silent and total.
`src/shared-wallet/physical.ts` now holds `physicalCreate` / `physicalQuery` / `physicalUpdate`, with `ensurePhysicalRepoOpen` and `subscribePhysicalDoc` as their open/subscribe counterparts. They are unguarded, and **never exported from the package** — a test asserts it, because a regression there is silent and total.
The dividing line:
@@ -14,7 +14,7 @@
>
> **`inbox.postToDocument(doc, { payload })`** is the one call an app makes: it names the DOCUMENT, never an inbox, and **throws** when the document has no inbox rather than returning quietly — a deposit that vanishes without an error is the bug this whole path exists to close. There is deliberately **no target-document field on a deposit**, for the reason above.
>
> **Where the address lives.** On the document's emulated **Header branch** (`urn:ng-eventually:shim:headerBranch`), beside the content rather than in it — the same subject-as-compartment shape already used for the Store and User branches. `read-model` now drops every subject under the reserved `urn:ng-eventually:` namespace (`src/machinery.ts`), so the address cannot surface as one of the entity's properties. That filter is by SUBJECT, so it covers every emulated compartment present and future.
> **Where the address lives.** On the document's emulated **Header branch** (`urn:ng-eventually:shim:headerBranch`), beside the content rather than in it — the same subject-as-compartment shape already used for the Store and User branches. `read-model` now drops every subject under the reserved `urn:ng-eventually:` namespace (`src/emulated-verifier/machinery.ts`), so the address cannot surface as one of the entity's properties. That filter is by SUBJECT, so it covers every emulated compartment present and future.
>
> **The open question, answered.** *"Is 'a document has an inbox addressable by any holder' upstream, or this library's invention?"* — **upstream, as a capability**; ours only as a JS surface. The engine lets any repo have an inbox: `inbox: Option<PrivKey>` is a field of every `Repo` (`engine/repo/src/repo.rs:126`), `AddInboxCapV0` is keyed by `repo_id` (`engine/repo/src/types.rs:1973`), and `update_inbox_cap_v0` applies it with **no `is_store` check** (`engine/verifier/src/verifier.rs:1920`), at any time (User-branch commit, `commit.rs:1043-1050`, whose type documents the update case). What no code path does is CREATE one for a document: `new_store_default` attaches one only `if !private` (`verifier.rs:2994`), `doc_create` leaves `inbox: None` (`repo.rs:574`), and the engine's only two `AddInboxCap` commits are for the public and protected STORE repos (`site.rs:128,149`). "Does not" is not "cannot" — an earlier version of this note called the feature an anticipation on exactly that confusion. The half-split is upstream's too — a depositor seals with the inbox PUBLIC key (`engine/net/src/types.rs:4299`) and only the owner holds the private half — which is why an address is public by nature and belongs on the document, not on the owner's User branch.
>