Files
ng-eventually/docs/document-links.md
T
Sylvain Duchesne 737729c9ce refactor: le paquet s'appelle polyfill, « SDK » désigne celui de NextGraph
Le nom @ng-eventually/sdk entrait en collision avec le SDK de NextGraph, dont
ce paquet est justement un polyfill. Impossible d'écrire « le SDK » sans lever
l'ambiguïté à chaque phrase — et le contrat publié, lu par une application,
était le pire endroit pour laisser traîner ça.

packages/sdk → packages/polyfill, @ng-eventually/sdk → @ng-eventually/polyfill,
contract_sdk-surface → contract_polyfill-surface, e2e/sdk-entry.ts →
e2e/polyfill-entry.ts, docs/sdk-reference.md → docs/polyfill-reference.md.

Les occurrences de « SDK » qui désignent celui de NextGraph restent intactes,
y compris les chemins dans nextgraph-rs (sdk/js/orm, sdk/js/web). Le tri s'est
fait occurrence par occurrence, pas par substitution.

Le contrat énonce désormais son identité en une phrase : « This package is a
polyfill of NextGraph's SDK. »
2026-08-10 17:14:25 +02:00

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); parser NuriV0::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 with ExtObjectGetV0, 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 half import_contact_from_qrcode (index.d.ts:96,106), payload NgQRCodeProfileSharingV0 { 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 a NURI component (app/ui-common/src/App.svelte:66); the component is absent from this clone (app mid-rebuild), and its historical version (git 077ba626, old ng-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 }no read_cap field (:5105-5127). ReadBranchLinkV0 (:5142), ObjectLinkV0 (:5176). Zero constructors and zero consumers in the entire workspace (grep over engine/, 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. The r: 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 the AddRepo replay (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_outer exists 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-codes expose_outer: false in both PinRepo constructors (engine/net/src/actors/client/pin_repo.rs:51,79), and ExtTopicSyncReq — the anonymous branch-sync a PublicRepoLink needs — is declared and unimplemented!() (engine/net/src/types.rs:4523,4533). Only ExtObjectGet and WalletGetExport are servable ext requests today.
  • Durable links are named, not built. PermaCap is a TODO (engine/repo/src/types.rs:578), and PermissionV0::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.ts exports checked; sdk/rust/src/local_broker.rs has 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 errors RepoNotFound (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:

  1. 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.
  2. 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.
  3. Reading is not writing. The cap a public store serves grants reading only; caps.learnFromPublicStore files it apart and docs.sparqlUpdate refuses a write on it. Upstream a public store never makes a repo world-writable — writing needs the write cap, and verify_permission fires 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 is PermaCap/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): ReadCapinbox.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/polyfill/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/polyfill/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.

  1. Label any such call LEVEL-1 SHAPE in docs/api-contract.md. What supports it: the NgLink family and its stated sharing flow, the PermaShare permission, 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 as subscribeDoc'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.
  2. Fix the comment-vs-code mismatch in linkTo. VOID — the function is gone. The observation it rested on survives and is worth keeping: 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 made linkTo wrong 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 what inbox.share is.
  3. The recipient verb is missing. Nothing exported ingests an out-of-band link: learn is reached only by inbox processing and the connection drain (packages/polyfill/src/surface/inbox.ts:410, packages/polyfill/src/emulated-verifier/connect.ts:68), and getCaps() is documented machinery (api-contract §15). The model names the recipient act precisely — open the link: load the repo from its read cap, file AddLink durably 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 mode rules/engineering/multi-actor-tests-obtain-not-receive.md records.
  4. 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?