Lot F de la revue adverse. Chaque affirmation relue dans `nextgraph-rs` par symbole avant
d'être réécrite ; aucune ne s'est révélée exacte.
- `AddLink` / `RemoveLink` / `RepoLinkV0` étaient présentés comme **implémentés**. Leurs
arms de vérificateur sont des `Ok(())`, là où celui d'`AddRepo` fait un vrai travail, et
rien ne les construit. Le tableau dit désormais « déclaré, stubbé », et la conclusion qui
en déduisait « le registre existe, seule la livraison manque » est corrigée : **les deux
bouts** sont déclarés-et-stubbés.
- La table `inboxes` était dite « reconstruite vide à chaque session » — elle est
repeuplée au chargement, et la clé privée d'inbox est persistée par repo. L'argument de
sécurité qui s'appuyait dessus repose maintenant sur le bon motif : la table est **par
vérificateur**, pas éphémère.
- La citation de « `doc_create` laisse `inbox: None` » pointait un constructeur réservé aux
tests ; re-ciblée sur le chemin de production.
- `ExtObjectGet` était dit « le seul » primitif accessible à un non-membre et exigeant les
clés : il y en a trois, et sa structure n'a aucun champ de clé.
- L'en-tête de `public-store.ts` était marqué **VERIFIED** alors qu'il repose sur un
commentaire de doc, et la condition qu'il citait (« si les brokers pairs l'autorisent »)
disparaissait de la conclusion. Requalifié en **pari**, condition rétablie.
- Deux sur-restrictions corrigées (ce qu'écrit le traitement d'un `ContactDetails`, et le
prétendu « miroir 1:1 » de `NuriV0`, qui a dix champs).
**Sur la grammaire du ReadCap, une correction de MA correction.** J'avais écrit que la
forme `{target}:r:{cap}` était notre invention. Faux : le segment `r:` et son encodage sont
ceux d'amont, et l'auteur de NextGraph l'a énoncé. Ce qui est établi est plus étroit —
aucun parseur amont n'accepte aujourd'hui un NURI de repo qui le porte, et le segment est
produit comme valeur de champ. J'avais conclu d'une implémentation absente à ce que la
cible ferait, ce que la doctrine du projet interdit nommément. Seule « P1b remplace la
valeur, pas la forme » est corrigée, requalifiée en **pari**.
**Et la surface ne publie plus de type que personne n'utilise.** `export * from
"./model/types"` publiait huit types en bloc ; c'est une liste nommée de six. `ReadCap`
sort — aucune signature publiée ne le prend ni ne le rend, seules deux fonctions privées
de `inbox.ts` s'en servent — et `InboxScope` aussi. Un type n'est publié que si une
signature publiée l'utilise.
197 tests, 0 échec ; les trois typechecks propres ; `lint` sans erreur.
18 KiB
Document links — what travels when an application shares a document
Established 2026-08-06, by direct reading of the nextgraph-rs clone (HEAD 213338f6, 2026-05-16), the installed @ng-org/web@0.1.2-alpha.13 declarations, and this repo's docs. Layer numbers per README.md § The three references (1 = engine, 2 = wasm binding / Rust SDK, 3 = ORM). file:line references are dated — navigate by symbol when they drift. Epistemic labels follow docs/api-contract.md.
The question. Alice writes a note; she wants Bob to open it. Bob is not necessarily known to her, and the sharing may cross a channel NextGraph knows nothing about — an email, a printed QR code, a web page. Does the model contain a value that travels, and if so, what does an application legitimately hold and pass around?
The verdict. Yes — and it is not a side feature: circulating a link is the model's only distribution mechanism ("you cannot discover, you can only follow links" — PO, 2026-07-30, docs/readcap-and-nuri-model.md §4ter-bis). The engine declares a complete link family (NgLink) whose doc comments describe out-of-band circulation as the normal case, and even names a durable "PermaLink" governed by a permission. But nothing constructs any of it yet, the repo-level string form an application would put in an email does not exist at any layer, and the value differs by scope: a link to a protected document carries the key, a link to a public document carries none.
1. Does a real application need this?
Yes, and not as a convenience. The two candidate paths in the framing collapse into one gesture at the model level: publishing is two acts — place the data, then circulate the link (§4ter-bis), and the inbox is merely one channel for that circulation ("post it into inboxes, or put it somewhere already reachable by the people concerned"). Directed delivery (path 1) is the same Link payload sealed to a known recipient's inbox; the email/QR case (path 2) is the same payload over a channel the recipient's identity never touches. Since there is no discovery — no index, no registry, nothing to find — a document that never has a link circulated for it has no audience at all. An application that lets users share anything therefore handles links; the only question is what the value is.
2. What the model actually offers
Exists and is exercised
- A self-contained share-URL for objects, commits and files. Grammar:
did:ng[:o:{repo}]:v:{overlay}(:[cj]:{id}:k:{key})+[:s:{id}:k:{key}]:l:{locator}(RE_OBJECTS,engine/net/src/types.rs:63-65); parserNuriV0::new_for_readcaps(engine/net/src/app_protocol.rs:493-548); one caller,ngcli get(bin/ngcli/src/main.rs:430-460), which connects anonymously to the broker named in the URL's:l:locator, fetches blocks withExtObjectGetV0, and decrypts locally with the keys carried in the URL. Level 1 + CLI, working end to end: a stranger holding only the string reads the content. It addresses frozen content (objects, files, a commit) — not a live document. - Shareable values at level 2 — for profiles and wallets, not documents.
get_qrcode_for_profile(session_id, _public, size)and its receiving halfimport_contact_from_qrcode(index.d.ts:96,106), payloadNgQRCodeProfileSharingV0 { inbox, profile, name, email }(engine/net/src/types.rs:5227-5233);wallet_export_get_qrcode(index.d.ts:332;sdk/rust/src/local_broker.rs:2074, with textcode and rendezvous variants). Precedent that "a value crosses an out-of-band channel" is SDK-real — no document instance of it exists. - NURIs are URL-shaped and the app opens them. The app layer routes
/did:ng:*to aNURIcomponent (app/ui-common/src/App.svelte:66); the component is absent from this clone (app mid-rebuild), and its historical version (git077ba626, oldng-app/src/routes/NURI.svelte) deep-links only documents the connected user already holds. An in-app precedent for "a document reference is a URL", not for stranger-opening.
Exists, unexercised — the declared model
- The link family.
NgLinkV0 = Repo | PublicRepo | Branch | Object(engine/net/src/types.rs:5206-5217).RepoLinkV0 { id, read_cap, overlay, peers }(:5062-5079), whose comment states the flow: "In most cases, the link is shared and then the recipient opens it and subscribes soon afterward" (:5059).PublicRepoLinkV0 { repo, branch?, heads, snapshot?, public_store, peers }— noread_capfield (:5105-5127).ReadBranchLinkV0(:5142),ObjectLinkV0(:5176). Zero constructors and zero consumers in the entire workspace (grep overengine/,sdk/,bin/,app/): the family is a declared model, not a feature. - The NURI type has exactly a link's slots — the string grammar does not.
NuriV0 { target, overlay, access: Vec<NgAccessV0>, locator: Option<Locator> }(engine/net/src/app_protocol.rs:181-195);NgAccessV0::ReadCap(ReadCap)(:55) has zero consumers anywhere. Ther:segment (a repo-level ReadCap,BlockRef::readcap_nuri,engine/repo/src/types.rs:518-521) is produced for display (engine/verifier/src/verifier.rs:278,320) and parsed by nothing;NuriV0::new_from(app_protocol.rs:643-737) accepts only cap-less forms plus the file/commit:k:forms. The only parsed cap+locator grammar is the object form above. - The recipient's mechanics exist and work — short of the external case.
Verifier::load_repo_from_read_cap(engine/verifier/src/verifier.rs:2237,pub(crate)) is exercised for the user's own stores and theAddReporeplay (engine/verifier/src/commits/mod.rs:660); the one call site that would open an external repo from a received cap is commented out (engine/verifier/src/inbox_processor.rs:469-481, social-query forwarder). The durable filing register for a received link exists:AddLink { read_cap }on the User branch, "so that a user can share with all its device a new Link they received", external repos only (engine/repo/src/types.rs:1934-1950). - Serving the public store to strangers is broker-plumbed and client-disabled.
PinRepoV0.expose_outerexists through the broker's storage (engine/net/src/types.rs:3123;engine/broker/src/server_storage/core/overlay.rs:103-127), but the client hard-codesexpose_outer: falsein bothPinRepoconstructors (engine/net/src/actors/client/pin_repo.rs:51,79), andExtTopicSyncReq— the anonymous branch-sync aPublicRepoLinkneeds — is declared andunimplemented!()(engine/net/src/types.rs:4523,4533). OnlyExtObjectGetandWalletGetExportare servable ext requests today. - Durable links are named, not built.
PermaCapis a TODO (engine/repo/src/types.rs:578), andPermissionV0::PermaShare— "can create and answer to PermaCap (PermaLink)" — already reserves the governance of creating one (:1761).
Does not exist
- No function at level 2 or 3 produces or accepts a document link (all 77
index.d.tsexports checked;sdk/rust/src/local_broker.rshas only the wallet QR/textcode/rendezvous trio).doc_fetch_repo_subscribe(index.d.ts:64,sdk/js/lib-wasm/src/lib.rs:1900) opens documents the session already holds — an unknown repo errorsRepoNotFound(engine/verifier/src/request_processor.rs:155,163) — it is not a stranger-open. - No string serialization of
NgLink, and no repo-level cap-bearing NURI that any parser accepts. Per the design principle, none of these absences says anything about the future SDK; the declared types above say a great deal.
What the receiving side actually needs — a reference alone is NOT enough
A bare did:ng:o:{id}, or even o:{id}:v:{overlay}, gives a stranger nothing: no locator → no broker to ask; no cap → nothing decrypts, and addressing itself presupposes the cap (the outer overlay is never registered, and there is no existence probe — docs/readcap-and-nuri-model.md §4bis, the refuted-hypothesis block). What a stranger needs to resolve and join is precisely RepoLinkV0's field list — id + read_cap + overlay + peers — or, public, PublicRepoLinkV0's find-and-join set. The peers/locator half is not decoration; it is the join, and it is why the polyfill's single-broker world makes locators invisible today.
3. Public vs protected — the distinction is in the model
The two link structures differ on exactly one thing: the key. RepoLinkV0 carries read_cap; PublicRepoLinkV0 carries none — "The latest ReadCap of the branch … will be downloaded from the outerOverlay, if the peer brokers listed below allow it", and "This link is durable, because the public site are served differently by brokers" (engine/net/src/types.rs:5100-5104). Public-ness is therefore a property of the store and of how brokers serve it (outer-overlay exposure), not a capability that circulates. This matches the official docs' store table (docs-intent, transcribed in docs/nextgraph-current-state.md:44-58): Public read = "Everyone, no capability", "equivalent to your website … without the need for special permissions"; Protected = "share … but they will need a special link and permission".
Two nuances against over-reading:
- It is not that no key exists. Today every branch — public included — has its ReadCap and its content is encrypted; the public link merely does not carry the key, because any holder of the link may fetch the current one from the outer overlay. The stated direction of not encrypting public-store content at all is PO-confirmed but explicitly not to be depended on by any surface (§4ter).
- The durability asymmetry follows from what the value contains: a public link names and stays valid across cap refresh; a protected link carries and goes stale when the cap is rotated (§4 below).
What follows for a public document, and is easy to get wrong: there is nothing per-reader. No grant, no per-reader revocation, no audience list — the only lever is coarse (unpublish/re-key the store's serving). An application UI that enumerates or revokes "the readers of my public document" is built on nothing.
The polyfill's public emulation, read against this
Rewritten 2026-08-06, when the emulation changed. It used to refuse a cap-less reference for every scope, which was over-strict in the safe direction but left an application unable to express "circulate widely, the reference is enough" — the one act the model makes cheap. emulated-verifier/public-store.ts now emulates the declared mechanism: a document in a public store exposes its ReadCap, and any reader's first door fetches it. Possession still decides everything; what changed is that for a public document the cap is obtainable instead of having to be handed over.
This aligns on a DECLARED model, not on current behaviour, and the difference is worth stating. What is read in source: PublicRepoLinkV0 carries no read_cap and its comment says "The latest ReadCap of the branch will be downloaded from the outerOverlay, if the peer brokers listed below allow it […] the public site are served differently by brokers" (engine/net/src/types.rs:5098-5124); the broker's expose_outer plumbing exists (engine/broker/src/server_storage/core/overlay.rs:103-133). What is not wired today, per the inventory above: the client hard-codes expose_outer: false in both PinRepo constructors, and ExtTopicSyncReq — the anonymous branch-sync such a link needs — is unimplemented!(). So the emulation follows the model the engine declares, in a place the engine does not yet serve. That is the intended posture for this library (an absent implementation says nothing about what the target will do), and it is a bet, named here as one.
Three things that remain true and must stay visible wherever the public emulation is documented:
- Per-reader semantics for public documents must not be inferred from the cap-per-holder bookkeeping — upstream has none. No grant, no per-reader revocation, no audience list.
- The
:r:segment inside anything naming a public document is emulation detail: upstream's public link carries no key material, because the key is fetched. Harmless as long as the value stays opaque to the consumer — which is the contract to enforce. - Reading is not writing. The cap a public store serves grants reading only;
caps.learnFromPublicStorefiles it apart anddocs.sparqlUpdaterefuses a write on it. Upstream a public store never makes a repo world-writable — writing needs the write cap, andverify_permissionfires on WRITE only.
The surface consequence: the act — obtain a link, circulate it — is the same for both scopes upstream (NgLinkV0 is one enum over both), so one producing function covering both is target-shaped; what differs is the semantics attached to the value (durability, revocability, the absence of per-reader anything), and that belongs in documentation, not in a second function.
4. Durability — what an application may store
- Protected links go stale by design. "Those capabilities are not durable: They can be refreshed … and previously shared Caps will become obsolete/revoked" (
engine/net/src/types.rs:5055-5058). Rotation re-delivers the new cap to the inboxes of those who keep access, at the rotating party's choice (PO DIRECTION,docs/readcap-and-nuri-model.md§3) — never through the stored link. So a protected link in an email opened months later may no longer open; an application must treat open-from-link as fallible and fall back to a person-channel re-request. The declared future answer for stored-unopened links isPermaCap/PermaLink — a TODO (engine/repo/src/types.rs:578,1761). - Public links are durable by construction (
:5103): they carry nothing that rotation invalidates. - The polyfill emulates no revocation (readcap doc §5, P3), so nothing goes stale today; consumers must not read permanence into that.
5. Recommendation for the polyfill
SUPERSEDED on its first point, 2026-08-06 — linkTo was REMOVED, and the removal stands. This section used to open: "The surface already exists: linkTo(doc: NuriLike): ReadCap … inbox.share(doc, toUser) for directed delivery. Keep linkTo — the act is the right one". What that recommendation missed is the single thing § 0 of docs/readcap-and-nuri-model.md exists to hold: a call that returns a document's key where a caller asked for its reference converts "whoever has the reference AND the key reads" into "whoever has the reference reads" — for that document and for every document it mentions — so confidentiality can no longer be composed inside anything one circulates. The act (circulate a value that opens a document) may still be right; making it the answer to "give me the link to my document" was not. The reasoning is recorded where the function was, in the No linkTo here block of packages/sdk/src/surface/placement.ts.
What an application does instead, today: it names a document with the bare reference it already holds — every published call returns one — and grants access with inbox.share(doc, toUser) (packages/sdk/src/surface/inbox.ts). The four points below are kept and re-read against that: 1 and 4 stand as written for any future link-producing surface; 2 is void with the function; 3 is unchanged and still open.
- Label any such call LEVEL-1 SHAPE in
docs/api-contract.md. What supports it: theNgLinkfamily and its stated sharing flow, thePermaSharepermission, the exercised object-URL and profile-QR precedents, and the PO doctrine that circulation is the only distribution. The model's own stated flows are unusable without some produce-a-link affordance, which is as much confidence as an unbuilt feature allows. What cannot be promised: the SDK's name for it, sync vs async (upstream link-building needs overlay + peers from the session, so async is plausible — same adapter-sized delta class assubscribeDoc's sync unsubscribe), and whether the value is a NURI string or a structured link. Therefore: the returned value is opaque; a consumer that stores it, transmits it, and hands it back unmodified learns nothing to unlearn; a consumer that parses it does. Fix the comment-vs-code mismatch inVOID — the function is gone. The observation it rested on survives and is worth keeping:linkTo.RepoLinkV0-with-key IS the protected-document link, and circulating it out-of-band is the documented normal case (:5059), so a protected link carrying its key is not in itself a violation — with the § 4 durability caveat. What madelinkTowrong was not that the value carried a key; it was that a caller got one by asking for a reference. Handing over a key must be its own act, which is whatinbox.shareis.- The recipient verb is missing. Nothing exported ingests an out-of-band link:
learnis reached only by inbox processing and the connection drain (packages/sdk/src/surface/inbox.ts:410,packages/sdk/src/emulated-verifier/connect.ts:68), andgetCaps()is documented machinery (api-contract §15). The model names the recipient act precisely — open the link: load the repo from its read cap, fileAddLinkdurably on the User branch, subscribe (:5059;engine/repo/src/types.rs:1934-1950;verifier.rs:2237). Suggested surface, same epistemic label as point 1:openLink(link: string): Promise<Nuri>— files the cap in the emulated registers and returns the cap-less target for use in reads. Without it, path 2 has a producer and no consumer, and the multi-actor test where Bob obtains the document through calls (never through a shared variable) cannot be written — the exact failure moderules/engineering/multi-actor-tests-obtain-not-receive.mdrecords. - Do not add: link options (expiry, audience, revoke-this-link), per-reader introspection for public documents, or any API that parses or inspects a link's insides — nothing upstream supports any of them, and each teaches a lever the model does not have.
6. The question for the NextGraph developer
What will an application hold and pass around to share one document out-of-band (email, QR) — a NURI string with r:/l: segments, a serialized NgLink, something else — and which SDK calls will produce it and open it? And for a public document, is the intended value the key-less PublicRepoLink form, i.e. should applications treat public sharing as never involving a per-reader capability?