Files
ng-eventually/docs/document-links.md
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

85 lines
18 KiB
Markdown

# 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): 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/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?