Files
ng-eventually/docs/briefs/2026-07-20-caps-emulation-alignment.md
Sylvain Duchesne 0eb25286c8 refactor: renommer client → sdk, et fusionner les deux portes en une
Deux mouvements de surface, aucun changement de comportement.

**`packages/client` → `packages/sdk`, `@ng-eventually/client` → `@ng-eventually/sdk`.**
« client » ne disait rien : ce paquet EST le SDK que l'application appelle, et c'est
tout ce qu'elle appelle. L'ancien nom reste comme mot-clé de recherche dans
`docs/source-layout-by-fate.md` et le tableau des paquets du README.

**Une seule entrée.** L'entrée `./polyfill` disparaît ; ses symboles applicatifs —
`configure`, `configureStoreRegistry`, `setCurrentUser`, `connectedUser` et leurs types
— vivent dans un bloc `POLYFILL-ERA` de `src/index.ts`.

Ce que la seconde porte portait mérite d'être nommé avant d'être retiré : *ce qu'on
importe de ce chemin est exactement ce qu'on supprimera à la migration*. Une seule
porte perd ce signal — rien à la ligne d'import ne distingue `configure`, qui part, de
`docs`, que le vrai SDK remplace sur place. Trois choses le portent désormais : le bloc
lui-même, l'inventaire d'exports de `docs/api-contract.md` (épinglé par
`test/vocabulary.test.ts`, donc il ne peut pas rancir en silence), et le contrôle de
vocabulaire sur les noms publiés.

**Six symboles quittent la surface au passage**, et la fusion est ce qui a rendu le
choix visible plutôt qu'hérité :

- `getConfig` / `getStoreRegistryDeps` — câblage interne, atteint par
  `shared-wallet/bootstrap` ;
- `resetConfig` / `resetStoreRegistry` / `resetCaps` — remises à zéro de test, atteintes
  par leur chemin interne, ce qui est leur raison d'être ;
- le `share` direct — `inbox.share` a toujours été la même fonction, et la publier deux
  fois brouillait la frontière qu'elle servait à marquer.

Corrections d'affirmations fausses trouvées en chemin : le contrat annonçait `isNuri` /
`hasReadCap` sur la porte SDK alors qu'ils ne sont plus exportés depuis le passage au
permissif en entrée (`NuriLike` validé à la porte) ; le README du paquet documentait
`capFor`, `shareCap`, `getCaps` et `publishRepoLink`, dont aucun n'existe ; et le README
de l'app d'exemple affirmait que la suite e2e la pilote, ce qui reste à faire.

179 tests unitaires, typecheck bibliothèque / exemple / harnais, e2e 42/42 contre le
broker en ligne — mesuré une fois après le renommage, une fois après la fusion.
2026-08-07 11:16:57 +02:00

20 KiB
Raw Permalink Blame History

Brief — align the caps emulation with the real NextGraph model

Brief (incubation) — 2026-07-20. See the reference docs/readcap-and-nuri-model.md.

Problem

emulated-verifier/caps.ts emulates read rights as an ACL (Map<Nuri, Set<PrincipalId>>, grantRead(doc, grantee)) — the inversion of the real NextGraph model (key possession). Consequences: no notion of a cap-less reference, grant/revocation instantaneous and total (instead of durable sealing + re-key), and an API (declareConnections) that consumers have to re-declare every session. This divergence makes it impossible to properly build models that rest on the real semantics — in particular anonymous presence (naming/counting without reading).

Objective: shape-fidelity, NOT security

The polyfill does NOT match the security of finished NextGraph, and does not try to. The shared wallet plus the absence of crypto make the emulation deliberately insecure (everything is in plaintext, any marker is forgeable) — a dev/staging vehicle, not a goal. Sole objective: expose the RIGHT SHAPE of the future primitives so that consumers (Festipod) are coded against the correct mental model and do not have to be rewritten when NextGraph is finished.

Corollary: "no crypto" is not a problem; what matters is being in the same logic, with RIGOR. A criticism of the form "an attacker reads the plaintext / forges a marker" is correct but out of scope. What is unacceptable = exposing the wrong shape (e.g. an ACL where the real thing is key possession) → the consumer codes against a model that will not exist. The ACL inversion of ReadCaps IS that lack of rigor — the central defect to fix.

Enforcement mechanism: LIGHTWEIGHT crypto simulation (anti-ACL, anti-shortcut)

For the shape to be really key-possession (and not an ACL in disguise), a doc's data is stored encrypted (per-doc symmetric encryption, however lightweight) and the ReadCap = the key. Invariant (cf. docs/vision.md):

a bare did (without a ReadCap) does NOT allow reading; a NURI with a ReadCap is sufficient and required.

This prevents the shortcuts the adversary pointed out (#4/#6: reading the plaintext, sparqlQuery/inbox.read bypassing the filter) and forbids falling back on an ACL — that is the heart of "same logic, with rigor".

Target — NOT the current state: every surface that returns data will have to go through decryption-with-key. Today this is FALSE, and far more broadly than this brief first stated — mapping of 2026-07-27, VERIFIED: only 4 sites consult the caps (use-shape, read-filter, read-model.readUnion, discovery.submitToIndex). Everything else returns data with no guard:

Surface State
docs.sparqlQuery / sparqlUpdate bypasses — they call the injected ng directly (an accepted constraint, to avoid a double-Proxy DataCloneError). The widest breach: a session id + a NURI are enough to read everything.
inbox (read / readSynced / materialize / watch) bypasses — no cap consulted; the drops go to whoever asks for them
store-registry (zero reference to caps in the whole file) bypasses — the account→NURI root of trust is universally readable
discovery.readIndex bypasses on read (caps checked on write only)
subscribe, open-repo bypass — the subscription push carries the doc state with no check
watch-shape deliberately delegates to readUnion (does not re-filter)

And the WRITE guard is already stillborn: ng-proxy guards sparql_update, but docs bypasses the proxy by design, and all internal writers go through docs. So the guard only fires for an app calling ng.sparql_update on the exported ng — which Festipod does not do. grantWrite / canWrite are decorative. (This finding reinforces §1 of the adversarial review: writing is not an axis "to be added", it is an axis we believed was covered and is not.)

This inventory IS the scope of P1b. The only existing guard (caps.canRead) is moreover a set-membership ACL — the very inversion the vision forbids.

  1. Two distinct reference shapes: cap-less (names/locates without reading — aligned with the NURI without :r:) vs cap-bearing (id + key/token). Absent today.
  2. Grant = delivering a cap-token to a recipient (emulating the sealing: the recipient receives the token in their inbox; it is possession of the token that authorizes reading — not an ACL row checked per principal).
  3. Enforcement by possession: readers (read-filter, use-shape) only see what they hold the token for, not "what they are in the readers set for".
  4. Resolving a cap-less = naming / proving existence / counting, without exposing the content (support for anonymous presence).
  5. Revocation = re-key emulated: invalidate the old token, re-deliver a new one to the remaining authorized holders; non-retroactive.

Widened scope: the WriteCap (= membership) — DROPPED (2026-07-21)

This section was wrong and is kept struck through as a guardrail. It imported a notion of membership read from the current state of nextgraph-rs (AddMember, PermissionV0, member_pubkey) and promoted it into a target shape. But (a) those types are inert scaffolding at runtime — verify_sig / verify_perm are only called in unit tests, and Repos are built with members: HashMap::new(); and (b) the target model has no notion of membership at all: only keys and URLs, symmetric and asymmetric. A shape in terms of member/role/permission is therefore exactly the wrong shape that this brief exists to prevent.

The methodological lesson, which is worth more than the dropped section: reading NextGraph's current state in order to deduce the target shape is a mistake — the current state contains unfinished work that must not be frozen into the polyfill. The source serves to verify an existing mechanism, never to infer an intent.

Erroneous content kept below as a record:

Dropped section

Why this is here and not elsewhere. The brief was at first ReadCap-centric; an adversarial finding showed that it does not compose with its consumer: the Festipod brief on "Set-based sign-ups" needs to deduplicate participations (one user = one participation per event), and the only non-application-level basis available is the commit's author signature — hence a write primitive, not a read one. A polyfill that only exposes the ReadCap shape leaves the consumer to invent its own application-level dedup → exactly the wrong shape.

The real shape (VERIFIED, cf. readcap-and-nuri-model.md §1) — and it is asymmetric with reading, which is the easiest point to miss:

  • Reading = possession of a key. No ACL. Whoever holds, reads.
  • Writing = membership + permissions (AddMember, AddPermission on the RootBranch). It really is an authorization list — not possession. Emulating writing "by token possession" would be just as wrong as the current read ACL, in mirror image.
  • Commits ARE signed by a UserId (a technical key, distinct from the profile) — so a dedup identifier exists natively, with no application-level pseudonym.
  • But verifying a signature requires being a member of the repo (access to the member_pubkey). A non-member third party sees a signed commit without being able to attribute it.
  • The inbox drop is NOT authenticated (anonymous sealed box): a declared from is content, not proof.

What that imposes on the polyfill. Expose membership as a primitive distinct from cap possession, with at minimum: adding/removing a member of a repo, reading the members map when one is a member, and verifying the author of a commit (→ an author digest, per-overlay hence per-store). It is this last point that unblocks the dedup on the Festipod side.

The shape consequence, to be documented explicitly (otherwise the consumer picks the wrong model): the author digest being per-store, the choice of how stores are carved up is the choice of the correlation level. A per-user stable store gives an identifier traceable across events; a per-event store gives a pseudonym local to the event — dedup possible, correlation impossible. Festipod needs the second. So the polyfill must make this carving expressible, not freeze it.

Still open: "can the creator of an event be a member of the store that contains the participations, without holding its read key?" — that is, membership (writing/verification) and possession (reading) genuinely orthogonal. If NextGraph couples them, verified dedup and anonymity are mutually exclusive, and it is the Festipod brief that must settle what it sacrifices. To be verified before shaping the API.

(Question now moot: there is no membership. The dedup does not go through signature verification — see the Festipod brief on "sign-ups".)

Open questions

  • SETTLED (PO directive, 2026-07-21): we simulate the crypto (per-doc encrypted data, cap = key). "Semantics only" (a token registry) is discarded — it turns back into an ACL and lets the plaintext be read. What remains to settle is the level of simulation (real lightweight encryption vs masked read-model projection), before P1.
  • NURI representation, cap-less vs cap-bearing, in the emulation (mirror :r:).
  • Should keyless fetch be allowed (resolving a cap-less into existence/count without the content)SETTLED, and negatively (2026-07-27): not constructible. Addressing itself presupposes the cap, so there is nothing to expose. See the corrected Q1 verdict below. Kept struck through rather than deleted: the hypothesis is intuitive and will otherwise be re-formed.
  • API migration: declareConnections/grantReadseal(cap, recipient) + inbox → received caps. Breaks consumers (the app-side declareConnections disappears).

P0 — "keyless-resolve" spike (the blocker, BEFORE any P1)

Load-bearing question: can a holder of a cap-less reference (did:ng:o:{id}:v:{overlay}, without :r:), without ever reading the content:

  • Q1 — Existence / fetch: prove/retrieve the presence of the (encrypted) blocks from the broker? Or does the broker require a ReadCap/membership in order to serve the blocks?
  • Q2 — Deletion: distinguish "exists" from "deleted"? (The FRAGILE point: NextGraph is an append-only CRDT — a withdrawal = a tombstone commit that one would have to read in order to know about → potentially the key is required. And the decrement on leave depends on it.)
  • Q3 — Confidentiality: does the key remain required in order to decrypt (keyless never gives the content)?

Why this is the blocker: the whole anonymous counter (counting/validating cap-less refs without reading) AND the decrement on leave depend on it. If NO → "anonymous counter via cap-less ref" is not constructible in the target → Festipod must not code that shape (guaranteed rewrite). If YES → P1 exposes resolveCapLess(nuri) → {exists|deleted} (never any content), and the emulation simulates it faithfully.

Method (cheap, decisive):

  1. Trace in nextgraph-rs the broker/verifier fetch authorization path: who serves the blocks (BlocksGet/TopicSync/OverlaySync)? is a cap/membership checked, or is id+overlay enough? is the outer overlay public? is a deletion observable without the key?
  2. (Optional) decisive e2e test (in the style of e2e/reactivity-doc-subscribe.ts): B holds the cap-less ref, attempts fetch/existence without the key, verifies that it does not reach the content. Empirical proof > source.
  3. (Or) confirm with the NextGraph dev — the fastest.

Deliverable: YES/NO/PARTIAL per Q1/Q2/Q3 + the exact primitive (file:line) + the API shape to expose (if YES), or the finding that the counter changes (if NO). Gated decision: YES → P1; NO → the sign-ups brief revisits the counter (not anonymous, or another primitive).

Spike verdict (2026-07-21) — VERIFIED in nextgraph-rs

Answer Evidence
Q1 — existence/fetch without a cap NO (corrected on 2026-07-27 — the initial "partial YES" verdict over-read the evidence) Read access control does indeed let you through (reads are not cap-gated) — but addressing presupposes the cap: no existence command at the SDK level; the only probe is internal to the crate, requires BlockIds and a loaded repo, and targets the inner overlay derived from the read secret. A cap-less reference has neither BlockId nor the required overlay. See readcap-and-nuri-model.md.
Q2 — detect a deletion without the key NO Append-only broker; a deletion is an encrypted tombstone commit (RemoveRepo), a no-op on the verifier side. Without the key one observes "some activity", never "a deletion".
Q3 — confidentiality YES Blocks stored as ciphertext; the key is #[serde(skip)] (types.rs), derived from the ReadCapSecret. Keyless never gives the content.

What that decides.

  • P1 is unblocked: resolveCapLess(nuri) → { exists } is the right shape — but { exists | deleted } is NOT. Do not expose a deleted state; that would be inventing a capability the target will never have (precisely the failure mode this brief fights).
  • Withdrawal has to be a message, not an observation. On the consumer side: an explicit nudge. The polyfill has nothing to emulate for that — it just must not pretend otherwise.
  • Settled by the Q1 correction: the anonymous counter cannot rest on an existence validation — that is not constructible. So it rests on something declarative, which is acceptable (outside the security scope) as long as the exposed shape does not lie: do not expose an existence primitive that the target will not offer.

P1a — the surface — DONE (2026-07-28)

Extracted into its own brief: 2026-07-27-p1a-cap-surface.md, which records what landed where.

The ACL inversion — the central defect this whole chantier exists to fix — is gone: emulated-verifier/caps.ts is a keyring, sharing is a per-document delivery to an inbox, and a bare reference reads nothing. P1b is now the blocker for any privacy claim: the emulated key is derived (hence reproducible) and the bypass inventory below is untouched.

In two lines: a single new type (ReadCap), a keyring (capFor), a per-document share to an inbox (shareCap) — and nothing else. The branded types, resolveCapLess, receivedCaps, refOf, parseNuri and PrincipalId were discarded after a double adversarial review; the reasons are in that note.

This brief remains the overall effort: P0 verdicts, P1b scope, P2P4 batches, and the adversarial reviews.

Phase sketch

  • P1athe surface DONE 2026-07-28: one new type (ReadCap), a keyring (capFor), per-document sharing to an inbox (shareCap). It was the only batch blocking Festipod, and it no longer does. See 2026-07-27-p1a-cap-surface.md for what landed where. (An earlier draft listed DocRef/DocCap branded types, resolveCapLess and a durable sealCapTo here — all three were dropped after adversarial review; the note says why.)
  • P1bthe enforcement: per-doc encryption (cap = key) and closing out the inventory of bypasses. Without it the shape is right but the isolation remains false — so nothing "anonymous" can be claimed. Requalified 2026-07-30: the bypass inventory below is really a virtual user boundary problem, and it is now specified on its own in 2026-07-30-virtual-wallet-boundary.md. That lot precedes or absorbs this one — encrypting each document while any wallet can reach any document secures the windows with the door open.
  • P2 — replace the ACL with a token possession model (grant = deliver to a recipient; enforcement = possession). Requalified by the adversarial review: the real content of P2 is durability + cap-less + re-sharing by the holder, not "inverting the ACL" — without crypto, inverting produces no observable delta.
  • P3 — revocation by re-key (invalidation + re-delivery, non-retroactive).
  • PW — WriteCap = membership DROPPED (2026-07-27). This batch rested on a notion of membership that does not exist in the model (everything is keys and URLs); see the struck-through section above. It was moreover justified by a need for dedup via signature verification that the consumer turns out not to have: its dedup rests on the overlay, which is store-scoped. For the record, two facts verified along the way, not to be re-discovered: author signature verification is not called at runtime; and the author digest is not keyed under the read secret — it is keyed by the outer overlay, which is public (it is the commit's content that is encrypted, hence the fact that verifying still presupposes being able to read). Detail in nextgraph-current-state.md.
  • P4 — adapt the consumer API + migration-guide.md. The adversarial review requalifies this batch: it is not an API swap but a consumer re-architecture (the grant moves to connection acceptance and becomes persistent; declareConnections disappears).

Adversarial review (2026-07-20) — to be integrated

An adversary refuted the brief (7 findings — the 7th marked (Plausible)). To be read through the filter of the Objective above (shape, not security). The purely security criticisms — readable plaintext content (#4), forgeable markers — are ACCEPTED / out of scope: the polyfill does not seek to prevent them. What remains are the real SHAPE / rigor defects (to be fixed), and a question of future model (#5):

  1. WriteCap forgotten, and "possession" is FALSE there. Writing is membership/permissions (AddMember) — an authorization list, not key possession (ref. §1); ng-proxy.ts:28-48 guards every sparql_update. → keep a WriteCap = membership track; possession concerns ONLY reading.
  2. P2 "possession without crypto" = the ACL renamed. Without crypto, "who holds which token" = Map<doc, Set<holder>> = the current readers: no observable delta. The real deltas are durability + cap-less + re-sharing by the holder — THAT is the content of P2, not "inverting the ACL".
  3. Non-retroactive revocation NOT EMULABLE without versioning: read-model.ts:112-118 only reads the current state → "invalidate the old token" = total removal = the inverse of the real thing (the former holder decrypts the prior versions). → emulate only "no new reads after re-key" + document non-retroactivity as non-emulable.
  4. cap-less "without exposing the content" ILLUSORY in the emulation: content in plaintext in the shared wallet; sparqlQuery/inbox.read bypass the filter; read-filter.ts:30-35 is all-or-nothing. → cap-less anonymity requires either real crypto or a masked read-model projection (counting without reading). "Replacement not overhaul" is overstated.
  5. Keyless-fetch = INFERRED and load-bearing: add a P0 spike that verifies it before P1 (otherwise the model — polyfill AND Festipod — is not constructible).
  6. Migration ≠ API swap. declareConnections is replayed every session because the map is ephemeral; durable seals move the grant to connection acceptance + persist "already sealed" — no analogue of protectedDocsOf + the re-derivation loop. Consumer re-architecture.
  7. (Plausible) delivering a cap through the async inbox does not re-trigger watchShape (which subscribes to data docs, not to caps) → unreadable views left stale until another change. → plan for a cap-mutation signal.

Consequence: add P0 (keyless-fetch spike) up front and a distinct WriteCap track; requalify P2 (the real content = durability + cap-less + re-sharing, not "inverting the ACL"); record that without crypto, read privacy is not applicable (choose: real crypto vs masked projection).

Links: readcap-and-nuri-model.md, packages/sdk/src/emulated-verifier/caps.ts. On the consumer side, the Festipod brief "realign the sign-ups" depends on this effort.