fix: écrire est une PROPRIÉTÉ, et trois portes qui n'auraient pas dû être ouvertes

Suite de la revue adverse. Quatre trous de frontière, tous hors du champ « l'isolation
est fausse jusqu'à P1b » — P1b parle de matériau de clé, ceux-ci sont des défauts de
FORME et resteraient des trous avec une vraie clé.

**La garde d'écriture reposait sur la mauvaise question.** Elle demandait « ce cap m'a-t-il
été servi par un store public ? ». Ce prédicat était faux dans les deux sens à la fois :
trop laxiste — une clé reçue dans une inbox donnait l'écriture, alors qu'en amont un Link
est « external repos only » et qu'écrire est l'appartenance au repo ; trop strict — la
propriétaire de son propre document public était refusée dès qu'elle l'ouvrait depuis sa
référence avant que son store ne soit listé. Un prédicat poussé dans deux sens est le
signe que c'était le mauvais prédicat.

Écrire dépend désormais de la PROPRIÉTÉ, lue sur la branche Store (l'`AddRepo` émulé),
plus la paternité de session pour les documents créés par la primitive brute qui n'a
aucun store où s'inscrire. Conséquence assumée et documentée : seul le propriétaire écrit,
ce qui est l'état amont d'un repo tant qu'aucun membre n'a été ajouté — mécanisme qu'on
n'émule pas.

**`docs.depositInto` quittait la frontière en la publiant.** Sa doc disait « `inbox.post`
est le seul appelant » : vrai dans la bibliothèque, faux dès qu'on le publie. Démontré :
avec la seule référence nue d'un document public, on réécrit l'adresse d'inbox posée
dessus et on détourne les dépôts destinés à son propriétaire. Une porte qui saute une
garde ne doit pas être ouvrable par une application — elle rejoint la machinerie.

**Le filtre de lecture n'interceptait que trois membres** et transmettait tout le reste
lié à la CIBLE : `.values()`, `.map()`, `.getById()` rendaient le contenu d'un autre
utilisateur — précisément les membres qu'une API de set réactif met en avant. Les membres
qui rendent des éléments sont désormais filtrés, les mutations passent (elles ne rendent
rien), et **tout membre inconnu lève** au lieu de transmettre : une transmission est une
fuite silencieuse, une levée est bruyante et greppable.

**Le mémo du store public était par document.** Le premier demandeur déclenchait le
téléchargement, le cap était classé chez LUI, et tout demandeur suivant recevait « oui »
en ne détenant rien. En amont un broker qui sert un overlay externe répond à TOUS. Le
mémo garde la valeur, l'appelant la classe pour qui est connecté.

Aussi : l'exemption `declareInfrastructure` supprimée — zéro appelant, ensemble toujours
vide, et une doc décrivant deux documents exemptés qui ne l'ont jamais été. Et les caps
d'écriture décrits comme « partiels » sont dits **inertes**, ce qu'ils sont : `grantWrite`
n'a aucun appelant de production.

**Ce que l'e2e a rattrapé.** Ma première version de la garde refusait au créateur
l'écriture sur un document fait par `docs.docCreate` — 7 étapes rouges contre le broker,
après une suite unitaire restée verte. La primitive brute n'inscrit la paternité nulle
part ; c'est ce que `mintedHere` couvre désormais.

185 tests unitaires (dont quatre régressions : la propriétaire écrit, le destinataire non,
le store public sert tout demandeur, aucun membre non filtré ne transmet), e2e 40/40 et
applicatif 10/10.
This commit is contained in:
Sylvain Duchesne
2026-08-07 13:59:13 +02:00
parent c5878c6126
commit 0b936d2119
19 changed files with 426 additions and 223 deletions
+9 -10
View File
@@ -289,12 +289,11 @@ export async function sparqlQuery(
label = "sparqlQuery",
): Promise<unknown>;
// docs.ts:113 — machinery, see § 15
export async function depositInto(
sessionId: string,
query: string,
targetInbox: Nuri,
label = "deposit",
): Promise<void>;
// NOT published since 2026-08-07 — moved to `emulated-verifier/register-write.ts`.
// It skips the boundary by design ("the one write that legitimately crosses"), and a door
// that skips a guard must not be one an application can open: holding nothing but a public
// document's bare reference, one could rewrite the inbox address posted on it and divert
// every deposit meant for its owner. Go through `inbox.post` / `inbox.share`.
```
### Target
@@ -310,7 +309,7 @@ declare function sparql_update(session_id: any, sparql: string, nuri: any): Prom
declare function sparql_query(session_id: any, sparql: string, base: any, nuri: any): Promise<any>;
```
`depositInto` has **NO COUNTERPART as a SPARQL write**: upstream a deposit is a sealed message, not an update into the recipient's graph (§ 9). It exists only because the emulated inbox is an RDF document.
`depositInto` has **NO COUNTERPART as a SPARQL write**: upstream a deposit is a sealed message, not an update into the recipient's graph (§ 9). It exists only because the emulated inbox is an RDF document — and it is no longer published (see the block above).
**Store targeting — finer than "not JS-constructible".** *(The other docs were corrected on 2026-08-03 to match this entry; they used to state the blanket form.)* Verified in the clone:
@@ -601,7 +600,7 @@ export type { NG } from "@ng-org/web";
Exported, but not SDK surface. Coding against these builds knowledge that migration deletes:
- **`docs.depositInto`** — the named boundary-crossing write `inbox.post` uses. It is exported only because `inbox.ts` lives in another module; a consumer must always go through `inbox.post` / `inbox.share`. Upstream a deposit is a sealed message, not a SPARQL update — this function's very signature is emulation.
- ~~**`docs.depositInto`**~~ **FIXED 2026-08-07.** It was published "only because `inbox.ts` lives in another module", with the note that a consumer must always go through `inbox.post`. That note is not a mechanism: an adversarial review drove through it — bare reference to a public document, rewrite its posted inbox address, divert its owner's deposits. It now lives in `emulated-verifier/register-write.ts`, which nothing exports.
- **`getConfig` / `getStoreRegistryDeps`** — tagged `@internal` in source, exported for the lib's own wrappers.
- **`resetConfig` / `resetStoreRegistry` / `resetCaps` / `storeRegistry.resetRegistryCache`** — test/reset machinery. In particular `resetCaps` wipes EVERY holder's caps, which no product flow should ever do.
- **`getCaps()` and the `CapRegistry` class** — the registry is the emulation's engine room. The consumer surface is the acts that file caps: creating a document, `inbox.share` (grant), processing one's inbox, and reading a document a public store serves. `CapRegistry.grantWrite` / `governsWrite` / `canWrite` / `hasWritePolicy` are explicitly decorative until P1b — the guard they feed is bypassed by every internal writer.
@@ -609,7 +608,7 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat
- ~~**`storeRegistry.addLink` / `readLinks`**~~ — **RESOLVED 2026-08-03**: no longer exported. Consumers receive caps by processing their inbox (automated at connection); calling these directly baked in a register the verifier owns upstream.
- ~~**`virtualUsers.*` on the SDK entry**~~ — **RESOLVED 2026-08-03**: moved to `/polyfill`, where its disappearance at migration is visible at the import line.
- **`inbox.watch`'s `_opts?: { intervalMs?: number }`** — accepted and ignored (no polling exists). Dead compatibility surface; do not pass it.
- **The `label` parameters** on `docs.sparqlUpdate` / `docs.sparqlQuery` / `docs.depositInto` — lib-internal access-log tags, never forwarded to `ng`. The real signatures have no such parameter.
- **The `label` parameters** on `docs.sparqlUpdate` / `docs.sparqlQuery` — lib-internal access-log tags, never forwarded to `ng`. The real signatures have no such parameter.
### Places the current surface teaches something to unlearn
@@ -631,7 +630,7 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat
```text
direct: BaseType, DeepSignalSet, DocChange, DocChangeType, EventuallyConfig, InboxScope, NG, NgLike, Nuri, NuriLike, PrincipalId, ReadCap, RegistrySession, Schema, Scope, ShapeObservable, ShapeQuery, ShapeType, SharedWalletConfig, UnionSubject, Unsubscribe, UseShapeLike, configure, docChangeType, ensureIdentity, init, initNg, ng, readUnion, subscribeDoc, subscribeDocs, useShape, watchShape
docs: depositInto, docCreate, sparqlQuery, sparqlUpdate
docs: docCreate, sparqlQuery, sparqlUpdate
inbox: Deposit, PostOptions, materialize, post, postToDocument, processInbox, read, readForDocument, readSynced, share, watch
storeRegistry: createEntityDoc, listMyEntityDocs, openDocumentInbox, resolveScopeGraph, resolveWriteGraph
```
+1 -1
View File
@@ -538,7 +538,7 @@ redundant so a lapse in either is caught by the other:
legitimately holds a bare NURI while possessing its cap elsewhere — references travel
bare through content and stores, the cap sits in what the user holds.
The exception is **depositing** into another user's inbox (`docs.depositInto`): a
The exception is **depositing** into another user's inbox (`register-write.depositInto`, internal): a
named primitive rather than a flag, because it is a different act — you hold no cap,
you cannot read back, and you get nothing in return. It is the only channel by which
a link crosses between users, hence the bootstrap of the whole reachability graph.