Two batches, verified against nextgraph-rs throughout. P1a — the capability surface. Reading was an ACL (Map<doc, Set<principal>>), the exact inversion of key possession. It is now possession: `capFor(nuri)` is the only question, there is no principal parameter anywhere, and nothing turns a bare reference into a cap. Sharing is `shareCap(cap, toInbox)`, a Link deposit; receiving needs no operation. `Nuri` and `ReadCap` are template literal types, so passing a bare reference where a cap belongs is a compile error, with runtime guards behind it for JavaScript callers. The virtual user boundary. Every access function is now confined to the connected user, through two rules on one criterion (possession), implemented in two places so a lapse in either is caught by the other: authorization at the passage points, and "do not even attempt" at the callers. The polyfill's own machinery moved to physical.ts — unguarded, never exported — which replaced an exemption list: the machinery no longer gets waved through the guard, it calls something the guard never saw. Removed, as emulating capabilities the target does not have: - discovery.ts and its global index. There is no discovery in NextGraph; you follow links. It also pooled user data across wallets. - the cross-account fan-out (listEntityDocs, resolveReadGraphs, allAccounts, loadShim), which was cross-user enumeration by construction. - resolveInboxAnchor, a single inbox common to every user. Caps are now stored where NextGraph stores them, and read back rather than recomputed: AddRepo on the store's Store branch for documents a user creates, AddLink on its User branch for caps received. Inboxes belong to someone — the user's own, plus one per document — and connecting a user drains them all; that is the library's job, not the app's. Corrections worth recording: a ReadCap is `r:`, not `:k:` (reported by NextGraph's developer, verified in BlockRef::readcap_nuri); received caps DO have a register (AddLink), contrary to what this repo's notes claimed; and "wallet" upstream means keyring — what owns three stores is a user, so the vocabulary follows. The cap value is the constant OK: the only question the emulation answers is whether a cap is held. P1b replaces that one constant with a real key. After this the shape is right and the isolation is still fake. Nothing here may be described as anonymous or private.
20 KiB
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
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.
- Two distinct reference shapes: cap-less (names/locates without reading — aligned with the NURI without
:r:) vs cap-bearing (id + key/token). Absent today. - 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).
- 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". - Resolving a cap-less = naming / proving existence / counting, without exposing the content (support for anonymous presence).
- 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,AddPermissionon theRootBranch). 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
fromis 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/grantRead→seal(cap, recipient)+inbox → received caps. Breaks consumers (the app-sidedeclareConnectionsdisappears).
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):
- Trace in
nextgraph-rsthe broker/verifier fetch authorization path: who serves the blocks (BlocksGet/TopicSync/OverlaySync)? is a cap/membership checked, or isid+overlayenough? is the outer overlay public? is a deletion observable without the key? - (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. - (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 adeletedstate; 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: 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, P2–P4 batches, and the adversarial reviews.
Phase sketch
P1a — the surfaceDONE 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. See2026-07-27-p1a-cap-surface.mdfor what landed where. (An earlier draft listedDocRef/DocCapbranded types,resolveCapLessand a durablesealCapTohere — all three were dropped after adversarial review; the note says why.)- P1b — the 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 = membershipDROPPED (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 innextgraph-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;declareConnectionsdisappears).
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):
- WriteCap forgotten, and "possession" is FALSE there. Writing is membership/permissions (
AddMember) — an authorization list, not key possession (ref. §1);ng-proxy.ts:28-48guards everysparql_update. → keep a WriteCap = membership track; possession concerns ONLY reading. - P2 "possession without crypto" = the ACL renamed. Without crypto, "who holds which token" =
Map<doc, Set<holder>>= the currentreaders: 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". - Non-retroactive revocation NOT EMULABLE without versioning:
read-model.ts:112-118only 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. - cap-less "without exposing the content" ILLUSORY in the emulation: content in plaintext in the shared wallet;
sparqlQuery/inbox.readbypass the filter;read-filter.ts:30-35is all-or-nothing. → cap-less anonymity requires either real crypto or a masked read-model projection (counting without reading). "Replacement not overhaul" is overstated. - Keyless-fetch = INFERRED and load-bearing: add a P0 spike that verifies it before P1 (otherwise the model — polyfill AND Festipod — is not constructible).
- Migration ≠ API swap.
declareConnectionsis replayed every session because the map is ephemeral; durable seals move the grant to connection acceptance + persist "already sealed" — no analogue ofprotectedDocsOf+ the re-derivation loop. Consumer re-architecture. - (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/client/src/caps.ts. On the consumer side, the Festipod brief "realign the sign-ups" depends on this effort.