feat: un document en store public sert son ReadCap, une référence nue suffit
Le modèle amont est explicite dans `PublicRepoLinkV0` : le lien ne porte AUCUN `read_cap`, et son commentaire dit pourquoi — *"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). La clé n'est pas remise par un émetteur : elle est donnée par le réseau à qui la demande, parce que le broker a épinglé l'overlay externe (`expose_outer`). La bibliothèque refusait jusqu'ici la forme sans cap quel que soit le store. Sûr dans le bon sens, mais une application ne pouvait pas exprimer « fais circuler, la référence suffit » — le seul acte que le modèle rend gratuit — et son unique contournement était de distribuer la clé, ce qui détruit la confidentialité composable. `emulated-verifier/public-store.ts` émule le mécanisme SANS toucher à la garde. La possession reste l'unique critère : un document public est lisible non par exception mais parce que son cap est *obtenable*. Chaque porte de lecture demande d'abord (`readUnion`, `docs.sparqlQuery`, `ensureRepoOpen`, `documentInboxAddress`), puis le chemin ordinaire s'applique. Lire n'est pas écrire. Ce que le store sert est un droit de LECTURE : `learnFromPublicStore` le classe à part et `assertMayWrite` refuse l'écriture dessus. Sans cela une référence nue achetait une écriture, ce qu'aucun store amont n'accorde. Autres conséquences : - `recordInPublicStore` (marquer + frapper) devient `markInPublicStore` (marquer). Frapper un second cap à côté de celui qu'on vient de télécharger donnerait deux clés différentes le jour où la constante devient un secret. - `hasCap` quitte la porte polyfill : il se lisait « ai-je le droit de lire ceci ? » et un document public y répondait `false` jusqu'à ce qu'on demande son cap. Aucun appelant hors des tests. - Les tests cross-user ne font plus traverser de cap par une variable JS : Bob n'obtient que la référence nue, comme une vraie application. Écarts documentés plutôt que masqués : le pari sur un modèle DÉCLARÉ (`expose_outer` est câblé à `false` côté client et `ExtTopicSyncReq` est `unimplemented!()`), la découverte limitée à ce qu'on sait déjà nommer, `useShape` qui n'a pas d'await à dépenser, et l'absence de `locator`. 179 tests unitaires, e2e 42/42 contre le broker en ligne.
This commit is contained in:
+16
-15
@@ -336,7 +336,7 @@ declare function doc_subscribe(repo_o: string, session_id: any, callback: Functi
|
||||
|
||||
## 9. Inbox — deposits, and cap delivery
|
||||
|
||||
### Today — `@ng-eventually/client` (namespace `inbox`; `shareCap` also re-exported from `/polyfill`)
|
||||
### Today — `@ng-eventually/client` (namespace `inbox`; `share` also re-exported from `/polyfill`)
|
||||
|
||||
```ts
|
||||
// inbox.ts:48,58
|
||||
@@ -355,7 +355,7 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>;
|
||||
// inbox.ts:215
|
||||
export async function postToDocument(doc: Nuri, opts: PostOptions): Promise<void>;
|
||||
// inbox.ts:282
|
||||
export async function shareCap(cap: ReadCap, toInbox: Nuri): Promise<void>;
|
||||
export async function share(doc: NuriLike, toUser: string): Promise<void>;
|
||||
// inbox.ts:339
|
||||
export async function read(targetInbox: Nuri): Promise<Deposit[]>;
|
||||
// inbox.ts:419
|
||||
@@ -384,7 +384,7 @@ export function watch(
|
||||
Consequences per function:
|
||||
|
||||
- `post` / `postToDocument` — the sender-side act exists in the model (the broker routes `InboxPost` natively, `engine/net/src/server_broker.rs`); its JS surface does not. **The future SDK's name and signature are unknown** — `docs/nextgraph-current-state.md:187` records that nothing is announced. `postToDocument`'s resolution step (find the document's inbox address) rides on a **deliberate divergence**: this lib PUBLISHES the address on the document (Header-branch emulation), whereas upstream an address is only ever TRANSMITTED (`ContactDetails` carries `ng:site_inbox`/`ng:protected_inbox`, `engine/verifier/src/inbox_processor.rs:778-830`; the verifier's `inboxes` table is session-local, rebuilt empty — `verifier.rs:520,2820`). Documented in `docs/briefs/2026-08-03-document-inbox-addressing.md`.
|
||||
- `shareCap` — a **gap upstream, not a disagreement**, verified at both ends: `ContactDetails.read_cap: Option<ReadCap>` exists (`engine/net/src/types.rs:4233`) but building a message with it is `unimplemented!()` (`types.rs:3786`), its only caller passes `with_readcap: false`, and the receiving arm never reads the field (`inbox_processor.rs:778-830`). `InboxMsgContent::Link` is a **unit variant carrying nothing** (`types.rs:4252`) — do not read it as the delivery channel. The recipient-side filing the lib emulates is real: `AddLink { read_cap }` on the User branch (`engine/repo/src/types.rs:1939-1948`). The consumer's *act* (share one document's cap to one inbox) is target-shaped; only the transport is emulated.
|
||||
- `share` — a **gap upstream, not a disagreement**, verified at both ends: `ContactDetails.read_cap: Option<ReadCap>` exists (`engine/net/src/types.rs:4233`) but building a message with it is `unimplemented!()` (`types.rs:3786`), its only caller passes `with_readcap: false`, and the receiving arm never reads the field (`inbox_processor.rs:778-830`). `InboxMsgContent::Link` is a **unit variant carrying nothing** (`types.rs:4252`) — do not read it as the delivery channel. The recipient-side filing the lib emulates is real: `AddLink { read_cap }` on the User branch (`engine/repo/src/types.rs:1939-1948`). The consumer's *act* (share one document's cap to one inbox) is target-shaped; only the transport is emulated.
|
||||
- `read` / `materialize` / `readSynced` / `processInbox` / `watch` — **stand-ins for the recipient's own verifier processing**, which has no consumer-facing JS surface upstream and may never have this list-of-deposits shape. A consumer should treat "my inbox gets processed when I connect, and applied caps just appear in what I hold" as the durable contract (that is what `connectedUser` automates, § 13); code that leans on enumerating raw deposits as a mailbox UI is coding against emulation detail it may have to unlearn. The consumer-payload case (`Deposit.payload` as app data) maps to `InboxMsgContent` variants upstream (`types.rs:4249-4260`), of which only `ContactDetails` and `SocialQuery` are more than unit variants today — arbitrary app payloads through the inbox are an **ASSUMPTION**, constrained by the model only in that messages are sealed, per-recipient, and applied by the recipient.
|
||||
- `watch`'s `_opts?: { intervalMs?: number }` is accepted and **ignored** (kept for signature compatibility with a removed polling watcher) — dead surface, see § 15.
|
||||
|
||||
@@ -405,11 +405,10 @@ export function hasReadCap(s: string): s is ReadCap;
|
||||
export type Nuri = `did:ng:${string}`;
|
||||
export type ReadCap = `did:ng:${string}:r:${string}`;
|
||||
|
||||
// @ng-eventually/client/polyfill — polyfill.ts:205
|
||||
export function capFor(nuri: Nuri): ReadCap | undefined;
|
||||
// polyfill.ts:193 — hands out the registry itself
|
||||
export function getCaps(): CapRegistry;
|
||||
// polyfill.ts:215 — tests / fresh wallet only
|
||||
// @ng-eventually/client/polyfill
|
||||
// tests / fresh wallet only — the registry itself is NOT published, and neither is any
|
||||
// "do I hold this?" predicate (`hasCap`, removed 2026-08-06: it read like "may I read
|
||||
// this?", and a document in a public store answers `false` until something asks for it).
|
||||
export function resetCaps(): void;
|
||||
|
||||
// @ng-eventually/client/polyfill — caps.ts:59 (class CapRegistry)
|
||||
@@ -417,8 +416,10 @@ constructor(holder?: () => PrincipalId | null);
|
||||
mint(nuri: Nuri): ReadCap;
|
||||
learn(cap: ReadCap): void;
|
||||
capFor(nuri: Nuri): ReadCap | undefined;
|
||||
publishRepoLink(nuri: Nuri): ReadCap;
|
||||
isPublished(nuri: Nuri): boolean;
|
||||
learnFromPublicStore(cap: ReadCap): void; // a cap the public store SERVED — read only
|
||||
isReadOnlyPublicCap(nuri: Nuri): boolean;
|
||||
markInPublicStore(nuri: Nuri): void;
|
||||
isInPublicStore(nuri: Nuri): boolean;
|
||||
open(nuri: Nuri, scope: Scope): ReadCap;
|
||||
isEnforcing(): boolean;
|
||||
onChange(listener: () => void): () => void;
|
||||
@@ -439,7 +440,7 @@ clear(): void;
|
||||
|
||||
`capFor(nuri)` asks the only question the model admits — "do I hold this document's key?" — and returning `undefined` is the whole possible answer. There is no "may principal P read D?" anywhere, and the future SDK cannot offer one without inventing an ACL the engine does not have. That absence is a **finding about the target's model**, not a missing feature: a consumer should never expect a cap-introspection API.
|
||||
|
||||
The `CapRegistry` class itself is machinery (the in-memory record of what the connected holder holds — upstream's local user storage). The consumer-facing surface is `capFor` + the acts (`shareCap`, creating a document, processing one's inbox); see § 15.
|
||||
The `CapRegistry` class itself is machinery (the in-memory record of what the connected holder holds — upstream's local user storage). It is not published at all: the consumer surface is the ACTS (creating a document, `inbox.share`, processing one's inbox — and, for a document in a public store, simply reading it), never a lookup; see § 15.
|
||||
|
||||
---
|
||||
|
||||
@@ -586,10 +587,10 @@ 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.shareCap`. Upstream a deposit is a sealed message, not a SPARQL update — this function's very signature is emulation.
|
||||
- **`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.
|
||||
- **`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 `capFor` (possession lookup), `inbox.shareCap` (grant), and the acts that file caps implicitly (creating a document, processing one's inbox). `CapRegistry.grantWrite` / `governsWrite` / `canWrite` / `hasWritePolicy` are explicitly decorative until P1b — the guard they feed is bypassed by every internal writer.
|
||||
- **`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.
|
||||
- ~~**`storeRegistry.reservedAccount`, `resolveAccount`, `ensureAccount`, `VirtualUserRecord`, `RegistrySession`**~~ — **RESOLVED 2026-08-03**: no longer exported. Shim internals, now in `docs/internal-contract.md`. The consumer's legitimate touchpoint is `configureStoreRegistry` (bootstrap) plus the scope/entity resolvers.
|
||||
- ~~**`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.
|
||||
@@ -599,7 +600,7 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat
|
||||
### Places the current surface teaches something to unlearn
|
||||
|
||||
- ~~**The SDK entry is not as pure as its header claims.**~~ **FIXED 2026-08-03.** The header claimed the entry "exposes ONLY what `@ng-org/web` / `@ng-org/orm` expose" while also shipping `virtualUsers` and the whole `store-registry` module. Both are gone from it, and the header now states what the entry actually promises: *every symbol here has a target-SDK counterpart, verified or assumed, listed in this document*. It still exports `docs`, `readUnion`, `watchShape`, `subscribeDoc(s)`, the SPARQL helpers and the NURI guards — justified inventions, documented per subject above — so the promise is no longer "@ng-org surface only", which was never true, but "nothing here is machinery".
|
||||
- **`shareCap` is importable from both entries** (`inbox.shareCap` on the SDK entry via `export * as inbox`, and a named re-export on `/polyfill`). The polyfill re-export exists "so the cap vocabulary stays on the polyfill side" — but the namespace export undoes that. Harmless functionally; blurs the same boundary.
|
||||
- **`share` is importable from both entries** (`inbox.share` on the SDK entry via `export * as inbox`, and a named re-export on `/polyfill`). The polyfill re-export exists "so the cap vocabulary stays on the polyfill side" — but the namespace export undoes that. Harmless functionally; blurs the same boundary.
|
||||
- **`inbox.read`/`materialize` as a mailbox** — enumerating raw deposits is emulation detail (§ 9); the durable contract is deposit-and-it-gets-applied. An app building UI on the deposit list should expect that surface to change shape entirely.
|
||||
- **`watchShape`'s "planned `useShape` upgrade"** — stated in the module header with no provenance in this repo or the clone (§ 5). The load-state *distinction* is safe; the claim that NextGraph plans this exact hook shape is an assumption and must not be cited as an announced API.
|
||||
- **`UnionSubject` property bags** — polyfill read-model shape, not a target type; map them into app types at the boundary (which `watchShape`'s design already assumes).
|
||||
@@ -623,5 +624,5 @@ storeRegistry: createEntityDoc, listMyEntityDocs, openDocumentInbox, resolveScop
|
||||
### `@ng-eventually/client/polyfill` — `src/polyfill.ts`
|
||||
|
||||
```text
|
||||
direct: EventuallyConfig, RegistrySession, StoreRegistryDeps, VirtualUserRecord, configure, configureStoreRegistry, connectedUser, getConfig, getStoreRegistryDeps, hasCap, resetCaps, resetConfig, resetStoreRegistry, setCurrentUser, share
|
||||
direct: EventuallyConfig, RegistrySession, StoreRegistryDeps, VirtualUserRecord, configure, configureStoreRegistry, connectedUser, getConfig, getStoreRegistryDeps, resetCaps, resetConfig, resetStoreRegistry, setCurrentUser, share
|
||||
```
|
||||
|
||||
@@ -50,11 +50,15 @@ What follows for a public document, and is easy to get wrong: **there is nothing
|
||||
|
||||
### The polyfill's public emulation, read against this
|
||||
|
||||
Verified in this repo: publication **mints a cap** (`publishRepoLink` → `mint`, `packages/client/src/emulated-verifier/caps.ts:192-196`), the `published` set is consulted by no read path (emitter-side guard only), and the possession filter gates public documents exactly like private ones. Against the target *under the emulation's own topology* (one broker, so join-reachability is trivially satisfied): upstream, whoever can name a public document reads it; the polyfill refuses the cap-less form for everyone. That is **over-strict, not inverted** — it under-grants and never over-grants, and "circulate the link" remains the valid currency at migration. But three deltas deserve to stay visible wherever the public emulation is documented:
|
||||
**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.
|
||||
|
||||
1. A **cap-less reference to a public document** embedded in reachable content will resolve upstream (once public serving is wired) and does not resolve here.
|
||||
2. **Per-reader semantics for public documents** must not be inferred from the emulation's cap-per-holder bookkeeping — upstream has none.
|
||||
3. The `:r:` segment inside a link to a *public* document is emulation detail: upstream's public link carries no key material. Harmless as long as the value is opaque to the consumer — which is the contract to enforce.
|
||||
**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,7 +4,7 @@
|
||||
|
||||
**Scope.** The complement of [`docs/api-contract.md`](./api-contract.md): every module export under `packages/client/src/` that is NOT reachable from the two published entry points (`package.json` maps exactly `.` → `src/index.ts` and `./polyfill` → `src/polyfill.ts`). A consumer never reads this document; a maintainer does. The internal code is held to the same standard as the surface — as close as possible to what NextGraph does or plans — so every subject below carries the same target-side analysis. Written 2026-08-04, verified against the `nextgraph-rs` clone (HEAD `213338f6`) and the installed `@ng-org/web@0.1.2-alpha.13` declarations (`node_modules/.bun/@ng-org+web@0.1.2-alpha.13/node_modules/@ng-org/web/dist/index.d.ts`, hereafter `index.d.ts`).
|
||||
|
||||
**How the boundary was computed — mechanically, from the `export` statements.** `index.ts` re-exports wholesale (`export *` / `export * as ns`) from `types.ts`, `inbox.ts`, `docs.ts`, `surface/read-model.ts`, and by name everything `surface/use-shape.ts`, `surface/watch-shape.ts`, `lifecycle.ts`, `sparql.ts` export, plus `isNuri`/`hasReadCap` from `nuri.ts` and `subscribeDoc`/`subscribeDocs`/`docChangeType` (+ types) from `subscribe.ts`; its `storeRegistry` namespace is the **`surface/placement.ts` slice only** (7 functions: `createEntityDoc`, `listMyEntityDocs`, `resolveScopeGraph`, `resolveWriteGraph`, `userInbox`, `openDocumentInbox`, `documentInboxAddress`). `polyfill.ts` re-exports `CapRegistry` from `emulated-verifier/caps.ts`, `shareCap` from `inbox.ts`, `connectedUser` from `emulated-verifier/connect.ts`, `* as accounts` from `shared-wallet/virtualUsers.ts`, and the types `VirtualUserStorage`, `VirtualUserRecord`, `RegistrySession`. Everything else that carries `export` in a `src/` module is internal and inventoried here. Eight modules are internal in their entirety: `shared-wallet/access-log.ts`, `emulated-verifier/machinery.ts`, `surface/ng-proxy.ts`, `emulated-verifier/open-repo.ts`, `shared-wallet/outbox-log.ts`, `shared-wallet/physical.ts`, `emulated-verifier/reach.ts`, `emulated-verifier/read-filter.ts`. Four are internal in part: `nuri.ts`, `emulated-verifier/connect.ts`, `subscribe.ts`, `shared-wallet/account-registry.ts`.
|
||||
**How the boundary was computed — mechanically, from the `export` statements.** `index.ts` re-exports wholesale (`export *` / `export * as ns`) from `types.ts`, `inbox.ts`, `docs.ts`, `surface/read-model.ts`, and by name everything `surface/use-shape.ts`, `surface/watch-shape.ts`, `lifecycle.ts`, `sparql.ts` export, plus `isNuri`/`hasReadCap` from `nuri.ts` and `subscribeDoc`/`subscribeDocs`/`docChangeType` (+ types) from `subscribe.ts`; its `storeRegistry` namespace is the **`surface/placement.ts` slice only** (7 functions: `createEntityDoc`, `listMyEntityDocs`, `resolveScopeGraph`, `resolveWriteGraph`, `userInbox`, `openDocumentInbox`, `documentInboxAddress`). `polyfill.ts` re-exports `share` from `inbox.ts`, `connectedUser` from `emulated-verifier/connect.ts`, `* as accounts` from `shared-wallet/virtualUsers.ts`, and the types `VirtualUserStorage`, `VirtualUserRecord`, `RegistrySession`. Everything else that carries `export` in a `src/` module is internal and inventoried here. Eight modules are internal in their entirety: `shared-wallet/access-log.ts`, `emulated-verifier/machinery.ts`, `surface/ng-proxy.ts`, `emulated-verifier/open-repo.ts`, `shared-wallet/outbox-log.ts`, `shared-wallet/physical.ts`, `emulated-verifier/reach.ts`, `emulated-verifier/read-filter.ts`. Four are internal in part: `nuri.ts`, `emulated-verifier/connect.ts`, `subscribe.ts`, `shared-wallet/account-registry.ts`.
|
||||
|
||||
**Labels** are those of `docs/api-contract.md`: **PASSTHROUGH (level 3/2, VERIFIED)**, **LEVEL-1 SHAPE (model VERIFIED, JS surface ASSUMED)**, **ASSUMPTION**, **NO COUNTERPART**. Level numbers per `README.md` § *The three references*: 3 = JS ORM, 2 = wasm binding (`@ng-org/web`), 1 = Rust engine. One label recurs here that the surface contract rarely needs: **NO COUNTERPART, shared-wallet machinery** — the code below the emulation's floor, which the target has no image of because the target has no shared wallet. Per the design principle, an absent implementation is never treated as evidence about the future.
|
||||
|
||||
|
||||
@@ -27,11 +27,12 @@ this step swaps the *emulated* key for the real one, not the model:
|
||||
`r:{base64url(serde_bare(ObjectRef))}`. It is **one function** (`mintCap`), because
|
||||
every path now READS a stored cap instead of recomputing one. `hasReadCap` /
|
||||
`targetOf` stay meaningful: the `r:` discriminant is upstream grammar, not ours.
|
||||
- `shareCap(cap, toInbox)` becomes the native sealed delivery (whatever the SDK ends up naming it — see the note below
|
||||
- `inbox.share(doc, toUser)` becomes the native sealed delivery (whatever the SDK ends up naming it — see the note below
|
||||
and `ContactDetails.read_cap`), and `inbox.read`'s inline absorption becomes the
|
||||
recipient's own verifier applying queued messages. **The consumer's call does not
|
||||
change.**
|
||||
- `publishRepoLink` becomes `RepoLinkV0`.
|
||||
- `caps.markInPublicStore` and the whole of `emulated-verifier/public-store.ts` disappear: which store a document sits in stops being a fact we record, and serving a public store's repos becomes the broker's job (`expose_outer`, the ReadCap downloaded from the outer overlay — `PublicRepoLinkV0`, `engine/net/src/types.rs:5098`). Nothing an application calls changes: it circulates bare references now, and will still.
|
||||
- `assertMayWrite` goes with it — refusing a write on a cap the public store served is a stand-in for the write cap this emulation does not have.
|
||||
- The read filter (`emulated-verifier/read-filter.ts`) and the possession gate in
|
||||
`read-model.readUnion` are then dead code — the broker only delivers documents
|
||||
whose cap the wallet holds. Remove them.
|
||||
@@ -103,7 +104,7 @@ P1a broke the consumer once, deliberately and early, so that migration would not
|
||||
The old surface was an ACL held in memory, which forced the consumer to re-declare
|
||||
every grant on every session (`declareConnections`). That call **disappears**: with
|
||||
delivered caps the grant moves to the moment a connection is *accepted* — one
|
||||
`shareCap(capFor(doc), theirInbox)` per document shared — and it persists, because
|
||||
`inbox.share(doc, toUser)` per document shared — and it persists, because
|
||||
the delivery lives in the recipient's inbox rather than in a map that empties at
|
||||
reload. There is no analogue of `protectedDocsOf` + the re-derivation loop.
|
||||
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ notifications — none of these is enumerated across virtualUsers. Each is reach
|
||||
what is already reachable to me:
|
||||
|
||||
- my own docs (always in `self.repos`, and whose caps I hold);
|
||||
- docs whose cap an owner has delivered to my inbox (`shareCap` — see the
|
||||
- docs whose cap an owner has delivered to my inbox (`inbox.share` — see the
|
||||
per-document ReadCap in [`simulation.md`](./simulation.md));
|
||||
- my inbox (deposits addressed to me).
|
||||
|
||||
|
||||
@@ -358,9 +358,19 @@ The consequence for anything this library exposes: **a call either hands over th
|
||||
- **Transmit the reference** — covered, with no dedicated call: every reference the surface returns is bare (`createEntityDoc`, `docCreate`, `listMyEntityDocs`, `UnionSubject.subject`/`.graph`). An application cites what it already holds. Faithful.
|
||||
- **Transmit the reference and the key** — `inbox.share(doc, toUser)`. Names the document and the person; the key is looked up and sealed into a deposit, and the recipient applies it by connecting, with nothing to call. Faithful in shape. One recorded divergence: the deposit always goes to the recipient's PROTECTED inbox, where upstream the choice follows the profile the person was reached by (`engine/verifier/src/inbox_processor.rs:787`).
|
||||
|
||||
**And one property of the model this library does NOT emulate**: a document in a public store is readable from a bare reference. Here `mayReach` requires a held cap whatever the scope, so a bare reference never suffices. The gap is in the safe direction — we UNDER-grant, we do not over-grant — but it means an application cannot express "circulate widely, the reference is enough", and the only way it could work around that is by handing out the key, which is exactly what breaks composable confidentiality (§ 0). Left as a known limit rather than patched, because making public-scope documents readable without a cap would rest the property on a scope THIS library assigns, where upstream it comes from the store and from how brokers serve it (`expose_outer`).
|
||||
**And the property that makes the first act worth anything — a public store SERVES its documents' caps.** `PublicRepoLinkV0` (`engine/net/src/types.rs:5098-5124`) carries `repo`, `public_store` and `peers` and no `read_cap`, and its own comment says why: *"The latest ReadCap of the branch (or main branch) will be **downloaded from the outerOverlay**, if the peer brokers listed below allow it. […] This link is durable, because the public site are **served differently by brokers**."* The key is not something a sender hands over; it is something the network gives to whoever asks, because the broker pinned the outer overlay (`expose_outer`, `engine/broker/src/server_storage/core/overlay.rs:103-133`).
|
||||
|
||||
Two things a consumer must not conclude from the emulation: that placing a document in a public store is an act of KEY DISTRIBUTION (here it mints one, upstream none travels), and that a public document has anything per-reader — upstream there is no grant, no revoke and no audience on it, so there is nothing to build a UI around.
|
||||
That is emulated, since 2026-08-06, in `emulated-verifier/public-store.ts` — and emulated **without touching the guard**. Possession remains the one criterion: a public document is readable not because `mayReach` makes an exception, but because its cap is *obtainable* — the library asks, files what it gets, and from there the ordinary path applies. Every read door asks first (`readUnion`, `docs.sparqlQuery`, `ensureRepoOpen`, `documentInboxAddress`).
|
||||
|
||||
Where the emulation shows its seams, stated rather than hidden:
|
||||
|
||||
- Upstream nothing is WRITTEN to make a repo public — the store is public and the broker serves it. Here one broker serves every virtual user identically, so the cap is recorded on the document's Header branch and read back through the machinery's unguarded door. Fetching, not enumerating: a reader asks the document it already names.
|
||||
- A reader therefore learns a document is public by asking THAT document. One it has never heard of stays invisible, where upstream a broker would serve it just the same. That limits discovery, not access.
|
||||
- The cap a public store serves is a READ grant, and this emulation says so: `caps.learnFromPublicStore` files it apart, and `docs.sparqlUpdate` refuses a write on it (`assertMayWrite`). Without that, a bare reference would buy a write, which upstream it never does — writing needs the write cap, and no store hands that out.
|
||||
- `useShape` cannot ask (its signature is the real ORM's, with no await to spend), so a public document reached through it alone, read nowhere first, is filtered out. Recorded in `emulated-verifier/read-filter.ts`.
|
||||
- **No `locator` anywhere**, and the emulation's topology is why it does not show. Upstream a reference must be complete enough for a stranger to resolve — without a `locator` there is no broker to ask, and nothing opens, key or no key (`NuriV0.locator`, `engine/net/src/app_protocol.rs:181-194`). Here every virtual user is on the same broker, so the question never arises and no reference this library produces carries one. An application must not conclude that a bare reference travels anywhere: **it travels between users of one deployment**. The day two deployments have to exchange one, the locator is what will be missing, and nothing in the emulation will have prepared it.
|
||||
|
||||
One thing a consumer must not conclude from the emulation: that a public document has anything per-reader. Upstream there is no grant, no revoke and no audience on it — there is nothing to build a UI around.
|
||||
|
||||
## 5. What the polyfill emulates (caps.ts) — and where it still diverges
|
||||
|
||||
@@ -369,15 +379,16 @@ Two things a consumer must not conclude from the emulation: that placing a docum
|
||||
| | Real NextGraph | caps.ts emulation (post-P1a) |
|
||||
|---|---|---|
|
||||
| Nature | possession of a **key** | possession of a **key** — recorded per identity, indexed by the cap-less NURI |
|
||||
| Grant | seal the key (crypto_box) to the inbox | `shareCap(cap, toInbox)` → an inbox deposit, absorbed inline on read |
|
||||
| Grant | seal the key (crypto_box) to the inbox | `inbox.share(doc, toUser)` → an inbox deposit, absorbed inline on read |
|
||||
| Durability | **durable** (key delivered once) | durable **in shape**: creation and re-listing refile own caps from the scope index (the emulated `AddRepo` branch); a delivered cap persists in the recipient's inbox document |
|
||||
| Revocation | coarse **re-key**, non-retroactive | **not emulated** (P3). Nothing pretends to revoke |
|
||||
| Granularity | repo / branch / commit / object | **one cap per doc-NURI** |
|
||||
| Ref. without rights | **cap-less NURI** (no `r:` segment) | same — `Nuri` names, `ReadCap` names and reads |
|
||||
| Public store | the broker serves the outer overlay; the ReadCap is **downloaded** from it | `public-store.ts` — the cap is exposed on the document and fetched through the machinery's door, then held like any other. Filed apart (`learnFromPublicStore`) so it grants reading and **not** writing |
|
||||
|
||||
**The divergence that REMAINS**: the stand-in cap value is the constant `OK` rather than a secret. The read paths that once consulted no cap at all are now confined to the connected virtual user (`emulated-verifier/reach.ts`, 2026-07-30) — `docs.sparqlQuery`/`sparqlUpdate` and `subscribeDoc` are guarded, the inbox is read only by its owner, and the shim's own machinery moved to unguarded primitives that are never exported. So what is left for **P1b** is per-document encryption: replacing one constant with a real key. Until then, nothing may be claimed "anonymous" or "private".
|
||||
|
||||
**App-facing**: `declareConnections` (on the consumer side), which re-declared "my connections read my protected entities" **every session**, was an artifact of the ephemeral ACL — **it disappears**. The grant moves to the moment a connection is accepted (`shareCap` once, per document), which is a consumer **re-architecture**, not an API swap.
|
||||
**App-facing**: `declareConnections` (on the consumer side), which re-declared "my connections read my protected entities" **every session**, was an artifact of the ephemeral ACL — **it disappears**. The grant moves to the moment a connection is accepted (`inbox.share` once, per document), which is a consumer **re-architecture**, not an API swap.
|
||||
|
||||
## 6. Implications for consumers (e.g. Festipod)
|
||||
|
||||
|
||||
+13
-11
@@ -345,7 +345,7 @@ Three ways a cap arrives, and there are no others:
|
||||
recompute anything: that is the whole reason for storing them, and it is what lets
|
||||
a **fresh session** read its own documents again with nothing re-declared — the
|
||||
durability the old in-memory ACL faked and lost every reload.
|
||||
- **Delivery.** `shareCap(cap, toInbox)` deposits one document's cap into one
|
||||
- **Delivery.** `inbox.share(doc, toUser)` deposits one document's cap into one
|
||||
recipient's inbox; `inbox.read` applies it inline, exactly as the recipient's own
|
||||
verifier applies queued messages upstream. **Receiving needs no operation** — a
|
||||
consumer already watching its inbox gets them, and the resulting change
|
||||
@@ -364,17 +364,19 @@ another name.
|
||||
- **`setCurrentUser(id)` (`polyfill.ts`)** — the SDK's "current identity" call.
|
||||
It selects *whose* caps are consulted, lazily, so the delivered subset always
|
||||
reflects the identity in effect at read time.
|
||||
- **`shareCap(cap, toInbox)`** — the one sharing act the lib exposes. Recipients
|
||||
- **`inbox.share(doc, toUser)`** — the one sharing act the lib exposes. Recipients
|
||||
are addressed as **inboxes**, which `inbox.post(targetInbox)` already does here;
|
||||
there is no `PrincipalId` in this surface, because that notion exists nowhere
|
||||
upstream. Reaching several recipients means calling it once per inbox, which is
|
||||
what the real model does too (each delivery is sealed to one recipient).
|
||||
- **`getCaps().publishRepoLink(doc)`** — upstream `RepoLinkV0`: a shareable link
|
||||
**whoever receives it** can open. Put the *link* in what you make discoverable, not
|
||||
the bare NURI, or no reader can open it. Publication is **not recursive**: a public
|
||||
document may reference private ones, and the reference grants nothing on what it
|
||||
references — which is what lets a public object point at a private identity without
|
||||
disclosing it.
|
||||
- **A document created in the `public` scope** needs no sharing act at all. The store
|
||||
serves its ReadCap to whoever asks (`emulated-verifier/public-store.ts`, emulating
|
||||
*"the latest ReadCap will be downloaded from the outerOverlay"* — `PublicRepoLinkV0`,
|
||||
`engine/net/src/types.rs:5098`), so what an application circulates is the **bare
|
||||
reference**, exactly as it will after migration. Never recursive: a public document
|
||||
may reference private ones, and the reference grants nothing on what it references —
|
||||
which is what lets a public object point at a private identity without disclosing it.
|
||||
And never a write right: what the store serves is a read cap.
|
||||
|
||||
Upstream, directed delivery is a **gap, not a disagreement**: `ContactDetails.read_cap`
|
||||
exists, but the message construction is `unimplemented!()`, its only caller passes
|
||||
@@ -442,8 +444,8 @@ natively at migration); the read side is what makes isolation observably active.
|
||||
|
||||
Isolation is enforced by the per-document ReadCap (`emulated-verifier/caps.ts` + `emulated-verifier/read-filter.ts`)
|
||||
alone: the access unit is the document (`@graph` = repo), and the only acts are
|
||||
possession-shaped (`createEntityDoc` files a cap, `shareCap` delivers one,
|
||||
`publishRepoLink` emits an openable link). Because the consumer application writes
|
||||
possession-shaped (`createEntityDoc` files a cap, `inbox.share` delivers one, a public
|
||||
store serves one to whoever asks). Because the consumer application writes
|
||||
one document per entity, the per-document cap discriminates at entity granularity —
|
||||
the target's behaviour.
|
||||
|
||||
@@ -512,7 +514,7 @@ deposit — **whose JS name and signature are not known**, since none is exposed
|
||||
announced — and the read side is served by the recipient's own verifier unsealing
|
||||
queued messages inline.
|
||||
The inbox + watcher is the one deposit/read mechanism a consumer reuses for its own
|
||||
purposes — a registration/deposit, a cap delivery (`shareCap`), a link handed to
|
||||
purposes — a registration/deposit, a cap delivery (`inbox.share`), a link handed to
|
||||
someone — same `post` API, same watcher.
|
||||
|
||||
## The virtual user boundary (`emulated-verifier/reach.ts` + `shared-wallet/physical.ts`)
|
||||
|
||||
Reference in New Issue
Block a user