43aadbeb45
98 annotations posées à côté des déclarations, et un test qui les exige sur la surface publiée. Elles portent trois choses : le niveau qui répond, la référence amont, et la catégorie parmi les cinq. La cinquième est celle qui manquait : declared-not-wired, quand la cible DÉFINIT la forme et ne la câble pas. Neuf symboles en relèvent, dont readLinks — que j'avais classé « notre invention » en raisonnant depuis l'absence, alors que c'est le meilleur alignement disponible. Les références citent un SYMBOLE, jamais une ligne : trois citations du document avaient déjà pourri. Cinq corrections au passage, toutes vérifiées à la source — un chemin ORM qui n'existe pas, deux plages de lignes fausses, et surtout docs.* et subscribeDoc étiquetés PASSTHROUGH alors qu'ils sont alignés : nos noms, plus un argument jamais transmis. La sémantique survit à la migration, les sites d'appel non, et la nuance disparaissait sous une étiquette trop flatteuse. Le test échoue à l'annotation retirée, à la catégorie mal orthographiée, et à une invention qui prétendrait citer une référence — vérifié en cassant les trois. Il a aussi attrapé un défaut en lui-même : le gabarit de format placé dans index.ts se faisait analyser comme une annotation. La classification couvre l'interne qui prétend ressembler à la cible — tout emulated-verifier — et exclut ce qui ne le prétend pas. La faute d'origine portait sur une fonction non exportée ; n'être pas publié n'a protégé personne. Quatre symboles ont résisté et sont annotés avec leur catégorie dominante, la seconde nommée dans la note plutôt que lissée.
426 lines
20 KiB
TypeScript
426 lines
20 KiB
TypeScript
/**
|
|
* Capability emulation — key POSSESSION, not an authorization list.
|
|
*
|
|
* In NextGraph a ReadCap **is** the document's read key: whoever holds it reads,
|
|
* and there is no read-ACL anywhere. This module emulates that shape (see
|
|
* `docs/briefs/2026-07-27-p1a-cap-surface.md`), which means it answers exactly one
|
|
* question — *do I hold this document's cap?* — and cannot answer "may principal P
|
|
* read document D", because the real model cannot either.
|
|
*
|
|
* ── Where caps come from — and why this is NOT "a keyring" ────────────────
|
|
* There is no keyring object in NextGraph, and calling this one invited a wrong
|
|
* mental model: that some single place holds every key. It does not. Upstream the
|
|
* caps of a user are in **two** places, by origin (see
|
|
* `docs/readcap-and-nuri-model.md` §4quater/§4quinquies):
|
|
*
|
|
* - documents the user CREATED → `AddRepo { read_cap }` on the **Store branch**
|
|
* of the store they live in — one such branch per store;
|
|
* - caps RECEIVED for someone else's documents → `AddLink { read_cap }` on the
|
|
* **User branch** of the private store.
|
|
*
|
|
* The wallet itself holds exactly one key per user: the private store's read cap,
|
|
* from which everything else is reached. Hence the invariant:
|
|
*
|
|
* > You do not derive a cap from a bare reference. You look it up in what you
|
|
* > hold — or you were given it.
|
|
*
|
|
* This class is the in-memory record of what the connected holder currently holds:
|
|
* upstream's local user storage, not a durable register. The durable ones are
|
|
* emulated in `store-registry.ts` — for created documents, `holdOwnCap` writes and
|
|
* `readStoreCaps` reads the Store branch back; for received ones, `addLink` /
|
|
* `readLinks` on the User branch. `connect.ts` restores the Links at connection;
|
|
* the own-document caps come back through `listMyEntityDocs`.
|
|
*
|
|
* One record PER holder, since one shared wallet hosts every identity. Switching
|
|
* identity therefore SWITCHES records; it never wipes one (a wipe would make
|
|
* durability a lie and bring per-session re-declaration back under another name).
|
|
*
|
|
* ── Sharing ───────────────────────────────────────────────────────────────
|
|
* Not here: the unit of sharing is the document and the recipient is an INBOX, so
|
|
* sharing is `inbox.share(doc, toUser)` — a **Link** deposit — and receiving is
|
|
* the recipient processing their inbox. Handing over a store's cap is NOT the
|
|
* gesture: it would give away everything that store contains, present and future.
|
|
*
|
|
* And for a document in a PUBLIC store there is no sharing act at all: the store hands
|
|
* its cap to whoever asks (`public-store.ts`), so what circulates is the bare
|
|
* reference. Filed apart (`learnFromPublicStore`) because it grants reading only.
|
|
*
|
|
* ── What this module does NOT do ──────────────────────────────────────────
|
|
* Enforce. The shape is right, delivered by cap-surface; the isolation is still
|
|
* fake. Per-document encryption and closing the read paths that bypass the guard
|
|
* (an ANCHORLESS `docs.sparqlQuery`, the inbox, `store-registry`, `subscribe`,
|
|
* `open-repo`) are cap-enforcement's job. Nothing may be claimed "anonymous" or
|
|
* "private" until then.
|
|
*
|
|
* The write caps below (`grantWrite`, `governsWrite`, `canWrite`, `hasWritePolicy`) are
|
|
* **inert, not partial** — a distinction the docs got wrong until 2026-08-07, when an
|
|
* adversarial review measured it. `grantWrite` has NO production caller, so
|
|
* `hasWritePolicy()` is permanently false and the `ng-proxy` guard they feed never fires
|
|
* at all. Writing is governed instead by OWNERSHIP, at the write door (`reach.ts`
|
|
* `assertMayWrite`) — which is what upstream's `verify_permission` actually checks. These
|
|
* four are dead surface kept for cap-enforcement; do not read them as a working policy.
|
|
*/
|
|
|
|
import { CAP_SEGMENT, hasReadCap, targetOf } from "../model/nuri";
|
|
import type { Nuri, PrincipalId, ReadCap, Scope } from "../model/types";
|
|
|
|
/**
|
|
* The stand-in cap value, and the minting point — moved here from `model/nuri.ts`
|
|
* on 2026-08-03 because it did not belong to the model.
|
|
*
|
|
* `model/` transcribes the target's addressing vocabulary; minting is not part of
|
|
* that vocabulary. Upstream nothing on the surface turns a bare reference into a cap:
|
|
* the engine mints at repo creation and you afterwards look a cap up in what you hold,
|
|
* or you were given it. Keeping `mintCap` in the model module contradicted that module's
|
|
* own header, and put the emulation's one invented value in the file that claims to hold
|
|
* only verified target vocabulary.
|
|
*
|
|
* cap-enforcement replaces this single constant with a real key; migration deletes both.
|
|
*/
|
|
const STAND_IN_CAP = "OK";
|
|
|
|
/**
|
|
* Build the cap-bearing form of `nuri` — `{target}:r:OK`. Passing an already
|
|
* cap-bearing reference yields the same value. INTERNAL to the emulated verifier.
|
|
*/
|
|
// @provenance mintCap kind=divergent level=1 ref=engine/repo/src/types.rs:readcap_nuri — upstream a cap is SERIALIZED from the ObjectRef at repo creation; here it is DERIVED from a NURI with a stand-in key. Unreachable from outside on purpose
|
|
export function mintCap(nuri: Nuri): ReadCap {
|
|
return `${targetOf(nuri)}${CAP_SEGMENT}${STAND_IN_CAP}`;
|
|
}
|
|
|
|
/** The map key of the anonymous holder (no identity established yet). */
|
|
const ANONYMOUS = "";
|
|
|
|
// @provenance CapRegistry kind=aligned level=1 ref=engine/repo/src/repo.rs:Repo — the session's record of which repos it holds a `read_cap` for; upstream that record is the verifier's opened repos, never a queryable registry
|
|
export class CapRegistry {
|
|
/** holder → the caps they hold, indexed by the cap-less NURI. */
|
|
private heldByHolder = new Map<string, Map<Nuri, ReadCap>>();
|
|
/**
|
|
* holder → the documents they CREATED in this session, through {@link mint}.
|
|
*
|
|
* Authorship, for the one path that records it nowhere else. `storeRegistry`'s
|
|
* documents are recorded durably on a Store branch (the emulated `AddRepo`, which is
|
|
* what upstream's `doc_create` commits), so `ownsDocument` finds them on a later
|
|
* session. The raw `docs.docCreate` has no store to record into — so nothing about
|
|
* such a document survives its session, and an in-session note of who made it is
|
|
* exactly as durable as the thing it describes.
|
|
*
|
|
* Consulted by the write guard before it pays for a Store-branch read. Without it the
|
|
* guard refused a caller a write to a document it had just created — caught by the
|
|
* live-broker e2e, seven steps red, after the unit suite stayed green.
|
|
*/
|
|
private mintedByHolder = new Map<string, Set<Nuri>>();
|
|
/**
|
|
* Documents this session knows to sit in a PUBLIC store — a fact about each
|
|
* DOCUMENT, so global rather than per-holder, unlike everything else here.
|
|
*
|
|
* It is not itself a right. What being in a public store buys is that the document's
|
|
* cap can be DOWNLOADED by anyone who asks (`emulated-verifier/public-store.ts`,
|
|
* emulating `PublicRepoLinkV0`'s *"downloaded from the outerOverlay"*); once it has
|
|
* been, the holder holds it like any other and this set records only how it got there.
|
|
*/
|
|
private inPublicStore = new Set<Nuri>();
|
|
/** doc NURI → principals holding its WRITE cap. Decorative until cap-enforcement. */
|
|
private writers = new Map<Nuri, Set<PrincipalId>>();
|
|
/** Fired whenever a holder gains a cap — a cap delivered asynchronously must
|
|
* re-trigger the reads that were empty for want of it. */
|
|
private listeners = new Set<() => void>();
|
|
/** Has any cap been issued at all? Gates the whole emulation (see {@link isEnforcing}). */
|
|
private issued = false;
|
|
|
|
/**
|
|
* @param holder resolves WHO is holding — the current identity. Looked up through it on every
|
|
* call, so an identity switch switches records with nothing to reset. Defaults to the anonymous holder.
|
|
*/
|
|
constructor(private readonly holder: () => PrincipalId | null = () => null) {}
|
|
|
|
// --- what the holder holds ----------------------------------------------
|
|
|
|
/**
|
|
* The key of the holder currently connected — capture it when you DECIDE that a cap is
|
|
* someone's, and hand it back to {@link learnFor} when you file.
|
|
*
|
|
* **A hazard closed, not a leak observed** — the distinction matters and I got it wrong
|
|
* once while writing this. Filing resolves the holder at the moment it runs, and three
|
|
* paths file several `await`s after the check that authorised them (connecting, reading
|
|
* an inbox, listing one's own documents). So an application switching identity in the
|
|
* gap COULD have the first identity's caps filed into the second one's ring. That is
|
|
* structural and visible by reading. What was NOT established is that it happens: the
|
|
* reproduction that seemed to show it turned out to be a broken test fake, and once the
|
|
* fake was corrected the leak did not reproduce.
|
|
*
|
|
* The pairing stays because it costs one argument and removes the hazard by
|
|
* construction, where a re-check at each of three sites is a discipline. It is not
|
|
* evidence of a bug that was found.
|
|
*/
|
|
holderKey(): string {
|
|
return this.holder() ?? ANONYMOUS;
|
|
}
|
|
|
|
/** What the current holder holds, created on first use. */
|
|
private heldCaps(): Map<Nuri, ReadCap> {
|
|
return this.ringFor(this.holderKey());
|
|
}
|
|
|
|
private ringFor(key: string): Map<Nuri, ReadCap> {
|
|
let ring = this.heldByHolder.get(key);
|
|
if (!ring) this.heldByHolder.set(key, (ring = new Map()));
|
|
return ring;
|
|
}
|
|
|
|
/**
|
|
* File `cap` among what the current holder holds — the ONE door in, so
|
|
* the invariant is carried here rather than by each caller remembering it.
|
|
*
|
|
* A reference with no `:r:` is REFUSED. `Nuri` and `ReadCap` are both `string`
|
|
* (deliberately — the real SDK takes `nuri: String`), so the compiler cannot
|
|
* catch a caller passing the naming form where the reading form is meant. Left
|
|
* unchecked, that mistake files a bare reference under its own name, `capFor`
|
|
* then returns it, and the document reads — turning "naming is not reading" into
|
|
* "naming is reading", which is the exact inversion this batch exists to remove.
|
|
* The check is cheap and it is the only thing standing between the two.
|
|
*
|
|
* Returns whether the cap was new.
|
|
*/
|
|
private file(cap: ReadCap, key: string = this.holderKey()): boolean {
|
|
if (!hasReadCap(cap)) {
|
|
throw new Error(
|
|
"[ng-eventually] caps: expected a ReadCap (a NURI carrying `:r:`), got a bare " +
|
|
`reference — naming is not reading, and no cap derives from one: ${JSON.stringify(cap)}`,
|
|
);
|
|
}
|
|
const target = targetOf(cap);
|
|
const ring = this.ringFor(key);
|
|
if (ring.get(target) === cap) return false;
|
|
ring.set(target, cap);
|
|
this.issued = true;
|
|
this.notify();
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* The cap of a document I just CREATED, filed among what I hold — the emulated
|
|
* `AddRepo { read_cap }`. Idempotent. Returns the cap.
|
|
*/
|
|
mint(nuri: Nuri): ReadCap {
|
|
const cap = mintCap(nuri);
|
|
this.file(cap);
|
|
const key = this.holder() ?? ANONYMOUS;
|
|
let made = this.mintedByHolder.get(key);
|
|
if (!made) this.mintedByHolder.set(key, (made = new Set()));
|
|
made.add(targetOf(nuri));
|
|
return cap;
|
|
}
|
|
|
|
/**
|
|
* Did the current holder CREATE this document in this session? Authorship, and
|
|
* therefore the right to write — see {@link mintedByHolder}.
|
|
*/
|
|
mintedHere(nuri: Nuri): boolean {
|
|
return this.mintedByHolder.get(this.holder() ?? ANONYMOUS)?.has(targetOf(nuri)) ?? false;
|
|
}
|
|
|
|
/**
|
|
* File a cap I was GIVEN — an inbox deposit of kind `cap`, or a repo link found
|
|
* in world-readable content. This is the ONLY way a cap arrives from
|
|
* outside: nothing turns a bare reference into a cap.
|
|
*
|
|
* @throws if `cap` carries no `:r:` — see {@link file}. Passing a bare `Nuri`
|
|
* here is the one type confusion that would silently invert the model, and both
|
|
* forms are `string`, so it is rejected at runtime instead.
|
|
*/
|
|
learn(cap: ReadCap): void {
|
|
this.file(cap);
|
|
}
|
|
|
|
/**
|
|
* File a cap for a NAMED holder — the one the caller decided for, not whoever happens
|
|
* to be connected when the `await` resumes. See {@link holderKey}.
|
|
*/
|
|
learnFor(key: string, cap: ReadCap): void {
|
|
this.file(cap, key);
|
|
}
|
|
|
|
/**
|
|
* File a cap a PUBLIC STORE served me — `emulated-verifier/public-store.ts`, the
|
|
* emulated *"downloaded from the outerOverlay"*. Held like any other cap, so reading
|
|
* needs no special case anywhere; recorded apart because of what it is NOT.
|
|
*
|
|
* It is a READ grant and nothing else. Upstream a public store makes its repos
|
|
* world-readable, never world-writable — writing needs the write cap, and
|
|
* `verify_permission` fires on WRITE only. Here the write guard still consults the
|
|
* read cap (write caps are decorative until cap-enforcement, see the module header), so without
|
|
* this distinction a bare reference to a public document would buy a WRITE — a
|
|
* consumer would build on it, and have to unlearn it at migration.
|
|
*
|
|
* A stronger claim on the same document erases the mark: {@link mint} (I created it)
|
|
* and {@link learn} (it was deposited for me) both go through {@link file}, which
|
|
* clears it. So a public document of my own is never read-only to me.
|
|
*/
|
|
learnFromPublicStore(cap: ReadCap): void {
|
|
this.file(cap);
|
|
}
|
|
|
|
|
|
/**
|
|
* Do I hold the cap of `nuri`? Returns it, or `undefined` when I hold
|
|
* none — which is the whole answer the model can give. Absorbs the former
|
|
* `canRead(doc, principal)`: there is no principal parameter, because there is
|
|
* no list to look a principal up in.
|
|
*/
|
|
capFor(nuri: Nuri): ReadCap | undefined {
|
|
return this.heldCaps().get(targetOf(nuri));
|
|
}
|
|
|
|
/**
|
|
* Does the NAMED holder hold `nuri`'s cap? The reading counterpart of {@link learnFor},
|
|
* and it exists for the same caller: work decided for one holder that runs while ANOTHER
|
|
* one is connected — the emulated inbox processor
|
|
* (`emulated-verifier/inbox-processor.ts`), which drains an inbox on behalf of its owner
|
|
* during someone else's session. Asking `capFor` there would consult the connected
|
|
* identity's ring, which is not the ring the question is about.
|
|
*
|
|
* Reads without creating a ring, unlike {@link heldCaps}: asking about a holder must not
|
|
* file one. Still no principal parameter in the model's sense — the question is "does
|
|
* THIS ring hold the key", never "may principal P read D".
|
|
*/
|
|
capForHolder(key: string, nuri: Nuri): ReadCap | undefined {
|
|
return this.heldByHolder.get(key)?.get(targetOf(nuri));
|
|
}
|
|
|
|
// --- publication (the public store) -------------------------------------
|
|
|
|
/**
|
|
* Record that `nuri` sits in a PUBLIC store. A fact about the DOCUMENT, not a right
|
|
* of anyone — hence a global set rather than a per-holder one, and hence no minting
|
|
* here: what sitting in a public store buys is that the cap is **obtainable** by
|
|
* whoever asks (`emulated-verifier/public-store.ts`), which is a separate act from
|
|
* this one holding it.
|
|
*
|
|
* Marking and minting were one method (`recordInPublicStore`) until they were split:
|
|
* the fetch path files the cap it DOWNLOADED, and minting a second one beside it
|
|
* would produce a different key the day the stand-in constant becomes a real one —
|
|
* the same trap `holdOwnCap` already documents.
|
|
*
|
|
* Upstream nothing corresponds to this call: the store IS public, and the broker
|
|
* exposes its outer overlay (`expose_outer`,
|
|
* `engine/broker/src/server_storage/core/overlay.rs:103-133`). We record it because
|
|
* one broker here serves every virtual user identically.
|
|
*
|
|
* NOT recursive: a document in a public store may REFERENCE private ones, and the
|
|
* reference grants nothing on what it references. That non-recursiveness is what lets
|
|
* a public object point at private content without disclosing it.
|
|
*/
|
|
markInPublicStore(nuri: Nuri): void {
|
|
this.inPublicStore.add(targetOf(nuri));
|
|
}
|
|
|
|
/** Is `nuri` recorded as sitting in a public store? A fact about the document. */
|
|
isInPublicStore(nuri: Nuri): boolean {
|
|
return this.inPublicStore.has(targetOf(nuri));
|
|
}
|
|
|
|
/**
|
|
* Record a document the current holder owns in `scope`: its cap lands among what
|
|
* they hold, and a `public` one is additionally marked as sitting in a public store.
|
|
* Returns the cap. Idempotent — the registry calls it both when creating a document
|
|
* and when listing the holder's own documents back, which is how a holder's caps are
|
|
* rebuilt on a fresh session.
|
|
*
|
|
* Deliberately does NOT touch write caps: those are decorative until cap-enforcement, and
|
|
* arming their guard here would be enforcement this batch does not do.
|
|
*/
|
|
open(nuri: Nuri, scope: Scope): ReadCap {
|
|
// `file`, NOT `mint` — and the difference is a hole that was open for one commit.
|
|
//
|
|
// Every caller of this method files a STRUCTURAL document: one of the holder's three
|
|
// store documents, or an inbox. Those are not authored content, they are registers —
|
|
// written only through `emulated-verifier/register-write.ts`. Minting them marked
|
|
// them "created by me", which let the write guard through, which let a holder append
|
|
// `contains "<anyone's document>"` to their own store index through the PUBLISHED
|
|
// `docs.sparqlUpdate` and forge ownership of it. `ownsDocument` reads that very
|
|
// index, so the guard was fully bypassable from the surface.
|
|
//
|
|
// Found by re-running the adversary on the fix (2026-08-07). Filing without minting
|
|
// closes it at the source: a structural document is owned by nobody in the authorship
|
|
// sense, so both halves of `assertMayWrite` say no, which is correct.
|
|
const cap = mintCap(nuri);
|
|
this.file(cap);
|
|
if (scope === "public") this.markInPublicStore(nuri);
|
|
return cap;
|
|
}
|
|
|
|
// --- enforcement gate ---------------------------------------------------
|
|
|
|
/**
|
|
* Is the cap emulation in force? False until the first cap is issued, so a
|
|
* consumer that never touches caps keeps reading everything (no regression).
|
|
* Once ANY cap exists the regime is possession for EVERY holder — including one
|
|
* who holds nothing, which is exactly the isolation being emulated.
|
|
*/
|
|
isEnforcing(): boolean {
|
|
return this.issued;
|
|
}
|
|
|
|
// --- change signal ------------------------------------------------------
|
|
|
|
/**
|
|
* Subscribe to changes in what the holder holds. A cap that arrives asynchronously (an inbox
|
|
* deposit) must make the views that were empty for want of it re-read; without
|
|
* this signal they stay stale until an unrelated change happens to fire.
|
|
*/
|
|
onChange(listener: () => void): () => void {
|
|
this.listeners.add(listener);
|
|
return () => {
|
|
this.listeners.delete(listener);
|
|
};
|
|
}
|
|
|
|
private notify(): void {
|
|
for (const l of this.listeners) {
|
|
try {
|
|
l();
|
|
} catch (error) {
|
|
console.error("[caps] change listener threw", error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- write caps (decorative until cap-enforcement) ----------------------------------
|
|
|
|
/** Grant `principal` the WRITE cap of document `doc`. */
|
|
grantWrite(doc: Nuri, principal: PrincipalId): void {
|
|
const target = targetOf(doc);
|
|
let s = this.writers.get(target);
|
|
if (!s) this.writers.set(target, (s = new Set()));
|
|
s.add(principal);
|
|
}
|
|
|
|
/** Is `doc` under any WRITE-cap policy? */
|
|
governsWrite(doc: Nuri): boolean {
|
|
return this.writers.has(targetOf(doc));
|
|
}
|
|
|
|
/** Does `principal` hold a WRITE cap for `doc`? */
|
|
canWrite(doc: Nuri, principal: PrincipalId | null): boolean {
|
|
if (principal === null) return false;
|
|
return this.writers.get(targetOf(doc))?.has(principal) ?? false;
|
|
}
|
|
|
|
/** No WRITE policy declared → the write guard stays inert (passthrough). */
|
|
hasWritePolicy(): boolean {
|
|
return this.writers.size > 0;
|
|
}
|
|
|
|
/** Drop every holder's caps and every publication. Tests / a fresh wallet only —
|
|
* NOT what an identity change does (that switches heldByHolder, see the header). */
|
|
clear(): void {
|
|
this.heldByHolder.clear();
|
|
this.mintedByHolder.clear();
|
|
this.inPublicStore.clear();
|
|
this.writers.clear();
|
|
this.issued = false;
|
|
this.notify();
|
|
}
|
|
}
|