feat!: retirer readUnion de la surface publiee, et lire un document par requete ancree

readUnion etait N requetes ancrees plus une politique de lot : une lecture
qui echoue avalee en reponse vide, un document dont on ne detient pas la cle
ecarte au lieu d'etre refuse, un regroupement en sacs de proprietes, et le
filtrage du prefixe reserve. Rien en amont ne repond a cette composition, et
son annotation citait sparql_query -- une citation exacte attachee a une
affirmation fausse. Une composition ne s'aligne pas sur une fonction.

Ce qui reste est ce qu'il y avait dessous : docs.sparqlQuery ancre sur un
document, qui va deja chercher la cle servie par le magasin public -- donc
une reference nue suffit toujours pour lire un document public, sans rien
changer a useShapeQuery. Lire plusieurs documents, c'est cet appel une fois
par document, et la tolerance a une lecture qui echoue revient a l'appelant,
la ou l'amont la laisse : une requete ancree sur un depot que le verifier ne
detient pas rejette.

Cote src, l'unique lecture interne restante devient read-document.ts : un
document, une requete ancree, precedee de l'ouverture du depot -- barriere
que index-deposit consulte pour distinguer une absence d'un depot non
synchronise. Le harnais e2e et l'exemple composent la leur, avec leur catch
visible au point d'appel.

Version majeure : 1.0.0-dev.8.
This commit is contained in:
Sylvain Duchesne
2026-09-21 14:08:05 +02:00
parent 05a0f7b148
commit 0c21adebf2
18 changed files with 659 additions and 516 deletions
@@ -40,8 +40,8 @@ import { insertObject } from "../src/surface/orm";
import type { InsertObjectLike } from "../src/model/types";
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
import { post, postToDocument, read as readInbox } from "../src/surface/inbox";
import { readUnion } from "../src/surface/read-model";
import { sparqlUpdate } from "../src/surface/docs";
import { readDocumentSubjects } from "../src/surface/read-document";
import { sparqlQuery, sparqlUpdate } from "../src/surface/docs";
import type { Nuri } from "../src/model/types";
/**
@@ -242,7 +242,7 @@ function makeFakeNg() {
if (query.includes(`<${SHIM}:contains>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:contains`).map((q) => ({ e: { value: q.o } })) } };
}
// Anchored per-doc read (readUnion `SELECT ?s ?p ?o`) — the document's content.
// The anchored per-document read (`SELECT ?s ?p ?o`) — the document's content.
return {
results: {
bindings: quads
@@ -281,12 +281,25 @@ async function write(doc: Nuri, p: string, o: string): Promise<void> {
await sparqlUpdate(SESSION.sessionId, `INSERT DATA { <${doc}> <${p}> "${o}" }`, doc, "test");
}
/** The values `p` carries in the documents `docs`, as the current holder reads them. */
async function readValues(docs: Nuri[], p: string): Promise<string[]> {
const subjects = await readUnion(docs);
/**
* The values `p` carries in `doc`, as the current holder reads them.
*
* ONE document, one anchored query - what `readUnion` did per entry before it was
* removed in `1.0.0-dev.8`. The difference that shows up all over this file: a document
* the holder may NOT reach used to be dropped from the batch and read as `[]`, and now
* the read is REFUSED. Both say "you cannot read this"; only the second says it out
* loud, which is why {@link expectRefused} exists rather than another `toEqual([])`.
*/
async function readValues(doc: Nuri, p: string): Promise<string[]> {
const subjects = await readDocumentSubjects(doc);
return subjects.flatMap((s) => s.props[p] ?? []);
}
/** Assert the current holder is REFUSED `doc`: the answer to "I hold no cap for this". */
async function expectRefused(doc: Nuri, p: string): Promise<void> {
await expect(readValues(doc, p)).rejects.toThrow(/does not hold this document/i);
}
/**
* Alice's world: a protected document holding a secret, and a public document that
* REFERS to it by bare NURI.
@@ -326,13 +339,13 @@ test("Bob: reads the public document, sees the reference, and cannot read throug
// store, so the store serves him its cap — he never received a key from anyone.
// He reads the public document and finds the reference.
const refs = await readValues([pubDoc], REFERS_TO);
const refs = await readValues(pubDoc, REFERS_TO);
const ref = referenceFoundIn(refs);
expect(ref).toBe(protDoc); // he can NAME Alice's protected document
// …and that is all it gets him: no cap, no read. Publication is NOT recursive.
expect(hasCap(ref)).toBe(false);
expect(await readValues([ref], SECRET)).toEqual([]);
await expectRefused(ref, SECRET);
});
test("Charlie: same public document, same reference — and he reads through it", async () => {
@@ -348,10 +361,10 @@ test("Charlie: same public document, same reference — and he reads through it"
setCurrentUser("charlie");
await readInbox(CHARLIE_INBOX); // processing the inbox files the cap
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
const ref = referenceFoundIn(await readValues(pubDoc, REFERS_TO));
expect(ref).toBe(protDoc);
expect(hasCap(ref)).toBe(true);
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
expect(await readValues(ref, SECRET)).toEqual(["the-protected-content"]);
});
test("the ONLY difference between Bob and Charlie is each of them holds", async () => {
@@ -363,13 +376,18 @@ test("the ONLY difference between Bob and Charlie is each of them holds", async
await share(protDoc, "charlie");
setCurrentUser("bob");
const bobSees = await readValues([protDoc], SECRET);
// Obtained under BOB, so what is compared below is each holder's own answer to the
// same question. His is a refusal; hers is the content.
const bobIsRefused = await readValues(protDoc, SECRET).then(
() => false,
() => true,
);
setCurrentUser("charlie");
await readInbox(CHARLIE_INBOX);
const charlieSees = await readValues([protDoc], SECRET);
const charlieSees = await readValues(protDoc, SECRET);
expect(bobSees).toEqual([]);
expect(bobIsRefused).toBe(true);
expect(charlieSees).toEqual(["the-protected-content"]);
});
@@ -381,10 +399,10 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl
const BOB_INBOX = await userInbox("bob", "protected");
setCurrentUser("bob");
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
const ref = referenceFoundIn(await readValues(pubDoc, REFERS_TO));
// Before: named, unreadable.
expect(await readValues([ref], SECRET)).toEqual([]);
await expectRefused(ref, SECRET);
// A reader that re-reads whenever what it holds changes — this is exactly what
// `watchShape` wires internally, played here on an ad-hoc read.
@@ -392,7 +410,13 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl
let latest: string[] = [];
const unsub = getCaps().onChange(() => {
reread += 1;
void readValues([ref], SECRET).then((v) => (latest = v));
// While the cap is still missing the read is REFUSED rather than empty, and a reader
// keeps what it had - which is what any real view does, and what makes `latest`
// meaningful when the cap finally lands.
void readValues(ref, SECRET).then(
(v) => (latest = v),
() => {},
);
});
// Alice delivers the cap. Bob's client processes his inbox — the only thing that
@@ -410,7 +434,7 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl
// …and the read that was empty now yields the content.
expect(hasCap(ref)).toBe(true);
expect(latest).toEqual(["the-protected-content"]);
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
expect(await readValues(ref, SECRET)).toEqual(["the-protected-content"]);
unsub();
});
@@ -420,14 +444,27 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl
// (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`) — so the same value transmitted
// (a bare reference) yields a different outcome depending on the store, and never
// because a key travelled.
//
// It is also the property `readUnion`'s removal rests on. `readUnion([doc])` was how a
// stranger reached a public index from its reference alone; what actually did the work
// was the anchored `docs.sparqlQuery` underneath, which fetches the public store's key
// at its own door (`surface/docs.ts`). This test exercises exactly that path with no
// batch above it, which is why the key fetch is asserted here rather than assumed.
test("a bare reference is enough for a PUBLIC document, and not for a protected one", async () => {
inject();
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
setCurrentUser("bob");
// Bob has been given nothing but the two NURIs.
expect((await readValues([pubDoc], REFERS_TO)).length).toBe(1);
expect(await readValues([protDoc], SECRET)).toEqual([]);
// Bob has been given nothing but the two NURIs — no key, for either of them.
expect(getCaps().isEnforcing()).toBe(true);
expect(hasCap(pubDoc)).toBe(false);
expect(hasCap(protDoc)).toBe(false);
expect((await readValues(pubDoc, REFERS_TO)).length).toBe(1);
// …and the key he now holds for it came from the DOCUMENT, on the way through the
// anchored read. Nothing in this file handed it to him.
expect(hasCap(pubDoc)).toBe(true);
await expectRefused(protDoc, SECRET);
// And what he obtained for the public one is a READ grant, not a write right: a
// public store serves its read cap, no store hands out the write cap.
@@ -449,7 +486,7 @@ test("a Link is APPLIED durably: the cap survives with the inbox emptied", async
// Bob connects: the library restores + drains, with nothing asked of the app.
setCurrentUser("bob");
await connectedUser();
expect(await readValues([protDoc], SECRET)).toEqual(["the-protected-content"]);
expect(await readValues(protDoc, SECRET)).toEqual(["the-protected-content"]);
// Now EMPTY the inbox — as a consumed queue would be — and drop every in-memory
// cap, then re-arm the emulation so the boundary is actually in force again.
@@ -468,7 +505,7 @@ test("a Link is APPLIED durably: the cap survives with the inbox emptied", async
// Connecting restores it — from the User branch, since the inbox has nothing left.
await connectedUser();
expect(hasCap(protDoc)).toBe(true);
expect(await readValues([protDoc], SECRET)).toEqual(["the-protected-content"]);
expect(await readValues(protDoc, SECRET)).toEqual(["the-protected-content"]);
});
test("connecting a user that does not exist provisions nothing", async () => {
@@ -494,7 +531,7 @@ test("a deposit is addressed to an inbox, never to a document", async () => {
await expect(post(protDoc, { payload: { x: 1 }, ts: 1 })).rejects.toThrow(/not an inbox/i);
setCurrentUser("alice");
expect(await readValues([protDoc], SECRET)).toEqual(["alice's own"]); // untouched
expect(await readValues(protDoc, SECRET)).toEqual(["alice's own"]); // untouched
});
// REGRESSION (second adversarial pass). The write guard reads ownership from the store
@@ -538,10 +575,10 @@ test("the owner writes to her own public note, even after opening it from its re
setCurrentUser("bob");
await createEntityDoc("bob", "private"); // re-arms the emulation
setCurrentUser("alice");
await readValues([pubDoc], SECRET); // this is what files the served cap
await readValues(pubDoc, SECRET); // this is what files the served cap
await write(pubDoc, SECRET, "v2"); // must not throw
expect((await readValues([pubDoc], SECRET)).includes("v2")).toBe(true);
expect((await readValues(pubDoc, SECRET)).includes("v2")).toBe(true);
});
// Direction 2 — TOO LAX. A cap received in an inbox let its recipient WRITE into the
@@ -557,11 +594,11 @@ test("a cap received in an inbox reads, and does NOT write", async () => {
setCurrentUser("bob");
await readInbox(BOB_INBOX);
expect(await readValues([protDoc], SECRET)).toEqual(["alice's own"]); // he reads it
expect(await readValues(protDoc, SECRET)).toEqual(["alice's own"]); // he reads it
await expect(write(protDoc, SECRET, "bob was here")).rejects.toThrow(/WRITE cap/i);
setCurrentUser("alice");
expect(await readValues([protDoc], SECRET)).toEqual(["alice's own"]); // untouched
expect(await readValues(protDoc, SECRET)).toEqual(["alice's own"]); // untouched
});
// PER-DOCUMENT INBOXES. Upstream a repo carries `inbox: Option<PrivKey>` and its
@@ -726,18 +763,37 @@ test("concurrent opens by a NON-owner are all refused, and leave no residue", as
expect(await documentInboxAddress(doc)).toBe(aliceInbox);
});
test("the inbox address is machinery: it never surfaces as the document's data", async () => {
test("the inbox address is machinery: a SUBJECT beside the data, never mixed into it", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
await write(doc, SECRET, "s1");
await openDocumentInbox(doc);
// The consumer read returns the entity's properties and nothing of the compartment
// that carries the address — the Header branch is beside the content, not in it.
const subjects = await readUnion([doc]);
// Both halves are asserted, because `readUnion`'s removal changed which one an
// APPLICATION sees. The compartment is a distinct reserved SUBJECT, so it is never
// mixed into the entity's properties whichever read is used - that is the property
// that protects the data, and it is structural.
const subjects = await readDocumentSubjects(doc);
const props = subjects[0]?.props ?? {};
expect(Object.keys(props)).toEqual([SECRET]);
// The filter that DROPS it, however, lives in this library's internal read. A raw
// anchored `docs.sparqlQuery` - which is what an application composes now that
// `readUnion` is gone - returns the Header-branch rows too. That is not a gain of
// rights: the two registers an entity document carries (`inboxAddress`,
// `exposedReadCap`) are the compartment meant for what any reader may see, and the
// cap-bearing `inboxCap` register lives in its owner's PRIVATE store, not here.
const raw = await sparqlQuery(
SESSION.sessionId,
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
undefined,
doc,
"test",
);
const rawSubjects = (raw as { results: { bindings: Array<{ s: { value: string } }> } })
.results.bindings.map((b) => b.s.value);
expect(rawSubjects).toContain(`${SHIM}:headerBranch`);
});
// CONNECTING APPLIES WHAT WAS DEPOSITED — and the honest scope of that claim.
@@ -775,7 +831,7 @@ test("connecting applies the Links waiting for me, and leaves consumer deposits
await connectedUser();
expect(hasCap(carolDoc)).toBe(true); // the Link was applied
expect(await readValues([protDoc], SECRET)).toEqual([]); // (protDoc holds no secret here)
expect(await readValues(protDoc, SECRET)).toEqual([]); // (protDoc holds no secret here)
const left = await readInbox(docInbox);
expect(left.map((d) => d.payload)).toEqual([{ onTheDocument: true }]); // consumer data stays
});