fix: une liste qu'on ne peut pas ouvrir n'est pas une liste

listMyEntityDocs rendait la liste des documents même quand la lecture des clés
échouait. Vu de l'appelant, une liste dont les documents ne s'ouvrent pas est
INDISCERNABLE d'une liste dont ils s'ouvrent : rien ne signale la différence
jusqu'à une lecture ultérieure qui revient vide, la cause étant alors loin
derrière.

C'était un choix délibéré — ne pas transformer un appel publié en levée, la
liste étant déjà en main à ce moment-là. C'est précisément ce qui en faisait une
demi-vérité plutôt qu'un raccourci. Et c'était le dernier membre connu de la
famille qui a produit une panne chez une application cette semaine.

Les deux lectures sur lesquelles l'appel repose remontent désormais : la branche
Main dit quels documents sont là, la branche Store dit ce qui ouvre chacun. Un
tableau vide signifie donc que ce compte n'a rien créé dans cette portée, et
jamais que le store n'a pas été lu.

readUserStore avalait le même échec pour son propre compte ; son autre appelant,
ownsDocument, garde le comportement actuel par un catch explicite et documenté —
toutes ses réponses étant des refus, il échoue en fermeture, ce qui est la
règle qu'e32b6d0 avait posée.

C'est un changement de comportement d'un appel publié, donc le contrat le dit,
et docs/api-contract.md aussi.

Au passage, deux citations pourries corrigées en citant un SYMBOLE plutôt qu'une
ligne — types.rs:4251 désignait DialogRequest et non Link, index.d.ts:138
désignait const ng et non le type NG. Les douze autres références numériques du
voisinage ont été vérifiées : aucune n'avait bougé.
This commit is contained in:
Sylvain Duchesne
2026-08-17 08:59:34 +02:00
parent 43aadbeb45
commit 90712e0ad0
8 changed files with 228 additions and 44 deletions
-8
View File
@@ -1,8 +0,0 @@
# Doc-debt — app-contract
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED docs/api-contract.md @2026-08-16 (session f93872b5-293a-4916-a353-181409a96d42)
- TOUCHED packages/polyfill/src/index.ts @2026-08-16 (session f93872b5-293a-4916-a353-181409a96d42)
@@ -124,6 +124,8 @@ It resolves **only once that work has actually completed**: if what was shared w
Where a call must first find out whether something already exists — a document's record in its store, a user's inbox — it throws when it could not find out, instead of proceeding as though the answer were "nothing". So `createEntityDoc` throws if the document cannot be recorded in its store, and resolving an inbox throws rather than handing back a second one. **A rejection means "unknown", never "absent"** — retry it or surface it, but do not read it as an empty result. Where a call must first find out whether something already exists — a document's record in its store, a user's inbox — it throws when it could not find out, instead of proceeding as though the answer were "nothing". So `createEntityDoc` throws if the document cannot be recorded in its store, and resolving an inbox throws rather than handing back a second one. **A rejection means "unknown", never "absent"** — retry it or surface it, but do not read it as an empty result.
The same rule reaches what a call hands BACK, not only what it looked up first: **`listMyEntityDocs` returns a listing whose documents you can open, or it throws.** It reads which documents are in the store and what opens each, and it throws if either did not answer — including when the documents came back and their keys did not. Nothing about a keyless listing is visible to you: it is the same `Nuri[]`, and the difference would only appear at the next read, empty, long after the cause. An empty array therefore means this account created nothing.
## Non-guarantees ## Non-guarantees
**No display name.** `ensureIdentity()` returns an opaque identifier: do not parse it, split it, or render it as a readable name. **No display name.** `ensureIdentity()` returns an opaque identifier: do not parse it, split it, or render it as a readable name.
+2
View File
@@ -591,6 +591,8 @@ export async function openDocumentInbox(doc: NuriLike): Promise<Nuri>;
// session is RETURNED, never assembled, so no application has a session shape to declare. // session is RETURNED, never assembled, so no application has a session shape to declare.
``` ```
**`listMyEntityDocs` answers a VERIFIED listing, or it rejects — since 2026-08-17.** It stands on two reads of the store document, and both propagate: the Main branch says which documents are in there, the Store branch says what opens each. Handing back the listing over a key read that never answered was tolerated until then, on the ground that the array was already in hand by that point — which is exactly what made it a half-truth rather than a shortcut, since nothing distinguishes a listing you can open from one you cannot. It is the same `Nuri[]`; the difference shows at the next read, empty, with the cause long gone. It is the ruling the connection path already runs on, applied to the last call that escaped it: **a rejection means "unknown", never "absent"**, and only "there was nothing to do" resolves quietly — so an empty array here means this account created no document in that scope, and never that the store went unread.
### Target — split by what each piece maps to ### Target — split by what each piece maps to
- **`createEntityDoc(id, scope)` → level 2, VERIFIED direction.** Target: `doc_create(session_id, crdt, class_name, destination, store_repo)` aimed at the identity's real per-scope store (see § 7 for the store-targeting nuance — the nodejs SDK already takes `store_type`/`store_repo` strings). The two writes the lib performs by hand are **native side effects** of `doc_create` upstream: the `ldp:contains` listing on the store's Main branch and the `AddRepo { read_cap }` on its Store branch (`engine/verifier/src/request_processor.rs:697-710`). The `id` parameter is already gone from the published call (2026-08-10); expect `createEntityDoc(scope)` to become `doc_create(sid, …, storeOf(scope))` with no listing/cap bookkeeping. - **`createEntityDoc(id, scope)` → level 2, VERIFIED direction.** Target: `doc_create(session_id, crdt, class_name, destination, store_repo)` aimed at the identity's real per-scope store (see § 7 for the store-targeting nuance — the nodejs SDK already takes `store_type`/`store_repo` strings). The two writes the lib performs by hand are **native side effects** of `doc_create` upstream: the `ldp:contains` listing on the store's Main branch and the `AddRepo { read_cap }` on its Store branch (`engine/verifier/src/request_processor.rs:697-710`). The `id` parameter is already gone from the published call (2026-08-10); expect `createEntityDoc(scope)` to become `doc_create(sid, …, storeOf(scope))` with no listing/cap bookkeeping.
@@ -200,7 +200,8 @@ export async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise<void>
* "I own nothing in this store" against "every document I own is invisible and nothing * "I own nothing in this store" against "every document I own is invisible and nothing
* said so". {@link restoreOwnCaps} is the caller that cannot survive the confusion, since * said so". {@link restoreOwnCaps} is the caller that cannot survive the confusion, since
* its whole contract is that the connection either did the restore or says it did not. * its whole contract is that the connection either did the restore or says it did not.
* A caller that genuinely prefers to carry on catches it itself, and one of them does. * `listMyEntityDocs` used to be the exception; it stopped being one on 2026-08-17, because
* a listing handed back without its keys is the same confusion one layer up (see it).
*/ */
// @provenance readStoreCaps kind=aligned level=1 ref=engine/repo/src/types.rs:AddRepoV0 — the replay of the Store branch — what reloads a store's documents with their keys // @provenance readStoreCaps kind=aligned level=1 ref=engine/repo/src/types.rs:AddRepoV0 — the replay of the Store branch — what reloads a store's documents with their keys
export async function readStoreCaps(storeDoc: Nuri): Promise<ReadCap[]> { export async function readStoreCaps(storeDoc: Nuri): Promise<ReadCap[]> {
@@ -340,6 +341,15 @@ export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined>
* register of the documents it created — across the three scopes, which is the only * register of the documents it created — across the three scopes, which is the only
* place that records authorship. Holding a cap is NOT ownership: a cap can be * place that records authorship. Holding a cap is NOT ownership: a cap can be
* received, and a recipient must not be able to open an inbox on what it merely reads. * received, and a recipient must not be able to open an inbox on what it merely reads.
*
* The ONE caller that catches `readUserStore` rather than propagating it, deliberately.
* Every answer this function feeds is a REFUSAL — the write guard (`reach.mustNotAttempt`)
* and `openDocumentInbox` both only ever ask it for permission — so a store that did not
* answer closes the door, which is the safe side and writes nothing. That is the ruling
* made when this family was swept (commit `e32b6d0`): the sites that fail CLOSED were
* left, the ones that answer a caller with a fabricated value were not. The refusal names
* the wrong reason ("not yours" for "could not tell"), which is the price, and it is
* bounded — nobody acts on this answer except by being denied.
*/ */
// @provenance ownsDocument kind=aligned level=1 ref=engine/repo/src/types.rs:AddRepoV0 — authorship is what the Store branch records; holding a cap is NOT ownership, since a cap can be received // @provenance ownsDocument kind=aligned level=1 ref=engine/repo/src/types.rs:AddRepoV0 — authorship is what the Store branch records; holding a cap is NOT ownership, since a cap can be received
export async function ownsDocument(doc: Nuri): Promise<boolean> { export async function ownsDocument(doc: Nuri): Promise<boolean> {
@@ -350,7 +360,11 @@ export async function ownsDocument(doc: Nuri): Promise<boolean> {
for (const scope of ["public", "protected", "private"] as const) { for (const scope of ["public", "protected", "private"] as const) {
const store = storeOf(record, scope); const store = storeOf(record, scope);
if (!store) continue; if (!store) continue;
try {
if ((await readUserStore(store)).includes(doc)) return true; if ((await readUserStore(store)).includes(doc)) return true;
} catch (error) {
console.error(accessLogPrefix() + " ownsDocument: store unreadable:", error);
}
} }
return false; return false;
} }
+1 -1
View File
@@ -163,7 +163,7 @@ import { makeNg } from "./surface/ng-proxy";
/** /**
* SDK-identical `ng` (wrapped). Drop-in replacement for `@ng-org/web`'s `ng`. * SDK-identical `ng` (wrapped). Drop-in replacement for `@ng-org/web`'s `ng`.
* *
* Declared `NG` — upstream's own type for its `ng` (`index.d.ts:138`). It was published as * Declared `NG` — upstream's own type for its `ng` (`index.d.ts:NG`). It was published as
* `Record<string, any>` until 2026-08-14, which announced a different surface from the one * `Record<string, any>` until 2026-08-14, which announced a different surface from the one
* it forwards to: an application got no completion and no check on any of the 88 members. * it forwards to: an application got no completion and no check on any of the 88 members.
*/ */
@@ -85,7 +85,7 @@ import { hasReadCap, isNuri } from "../model/nuri";
import { mintCap } from "../emulated-verifier/caps"; import { mintCap } from "../emulated-verifier/caps";
import { mustNotAttempt } from "../emulated-verifier/reach"; import { mustNotAttempt } from "../emulated-verifier/reach";
import { accessLogPrefix, logStage, shortNuri } from "./access-log"; import { accessLogPrefix, logStage, shortNuri } from "./access-log";
import type { InboxScope, Nuri, ReadCap, Scope } from "../model/types"; import type { InboxScope, Nuri, Scope } from "../model/types";
// --- sharedWalletShim model ---------------------------------------------- // --- sharedWalletShim model ----------------------------------------------
@@ -1219,7 +1219,20 @@ export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
return entityNuri; return entityNuri;
} }
/** Read the entity-document NURIs contained in ONE scope index document. */ /**
* Read the entity-document NURIs contained in ONE scope index document.
*
* **Propagates a failed read**, like {@link readStoreCaps} and `readLinks` next to it, and
* for the same reason: empty and unreadable are the same value here — "this account put
* nothing in this store" against "the store did not answer" — and the first is a fact an
* application acts on while the second is a breakdown it must be told about. Swallowing the
* difference is what {@link listMyEntityDocs} exists downstream of, and answering an empty
* listing for a read that never happened is the failure-as-absence this library was bitten
* by twice.
*
* A caller that genuinely prefers to carry on catches it itself, and one does: `ownsDocument`,
* whose answer is a REFUSAL either way (see it).
*/
export async function readUserStore(indexDoc: Nuri): Promise<Nuri[]> { export async function readUserStore(indexDoc: Nuri): Promise<Nuri[]> {
const s = await session(); const s = await session();
const out: Nuri[] = []; const out: Nuri[] = [];
@@ -1231,7 +1244,6 @@ export async function readUserStore(indexDoc: Nuri): Promise<Nuri[]> {
// before reading it. Idempotent per session; no-op with the unit fake ng. See // before reading it. Idempotent per session; no-op with the unit fake ng. See
// open-repo.ts. // open-repo.ts.
await ensureRepoOpen(indexDoc); await ensureRepoOpen(indexDoc);
try {
const res = await sparqlQuery( const res = await sparqlQuery(
s.sessionId, s.sessionId,
// NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see // NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see
@@ -1247,9 +1259,6 @@ export async function readUserStore(indexDoc: Nuri): Promise<Nuri[]> {
const v = bindingValue(row, "e"); const v = bindingValue(row, "e");
if (v && isNuri(v)) out.push(v); if (v && isNuri(v)) out.push(v);
} }
} catch (error) {
console.error(accessLogPrefix() + " readUserStore failed:", error);
}
logStage("readUserStore(" + shortNuri(indexDoc) + ") → " + out.length + " entities"); logStage("readUserStore(" + shortNuri(indexDoc) + ") → " + out.length + " entities");
return out; return out;
} }
@@ -1276,6 +1285,20 @@ export async function userStoreDoc(id: string, scope: Scope): Promise<Nuri> {
* and reads the contained NURIs — NO cross-account fan-out, so it never touches * and reads the contained NURIs — NO cross-account fan-out, so it never touches
* another account's unsynced docs. This is the helper a consumer application uses * another account's unsynced docs. This is the helper a consumer application uses
* for its own my-entities path, instead of the all-accounts `listEntityDocs`. * for its own my-entities path, instead of the all-accounts `listEntityDocs`.
*
* **Only a VERIFIED answer comes back as a listing.** Both reads it stands on propagate —
* the store's Main branch, which says WHICH documents are there, and its Store branch,
* which hands back the KEY of each. A listing is a promise that what it names can be
* opened, and until 2026-08-17 the key read was tolerated here: the caller got the
* documents without the means to read them, and nothing on the surface told the two apart.
* From the outside a listing you can open and one you cannot are the same array — the
* difference only appears at the next read, coming back empty, long after the cause is
* gone. That is the shape of the outage this library shipped twice
* (`resolveAccount` answering "no such account" for a read that failed, `connectedUser`
* resolving over a restore that never ran), so the tolerance goes with it.
*
* Nothing to list is NOT a failure and stays silent: a store that answers with zero
* documents means this account created none, which is a fact.
*/ */
export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]> { export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]> {
const record = await ensureAccount(id); const record = await ensureAccount(id);
@@ -1294,18 +1317,11 @@ export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]
// holder when it runs — so hand the captured key back rather than trust that the // holder when it runs — so hand the captured key back rather than trust that the
// identity has not moved. See `caps.holderKey`. // identity has not moved. See `caps.holderKey`.
const holderKey = caps.holderKey(); const holderKey = caps.holderKey();
// Tolerant HERE, and only here: `readStoreCaps` propagates (see it, and `readLinks` // PROPAGATES, like `readStoreCaps` itself and like `restoreOwnCaps` next door. Holding
// beside it), because the connection's restore cannot report success over a read that // the listing already is exactly why this must not swallow: the array is in hand, so
// never answered. This caller returns the LISTING, which it already has by now, and a // returning it costs nothing and says something false — these are yours, here they are.
// listing without its keys is what it answered before this distinction existed — // A caller has no second question to ask that would reveal the keys are missing.
// unchanged rather than quietly turned into a throw on a published call. for (const cap of await readStoreCaps(store)) caps.learnFor(holderKey, cap);
let stored: ReadCap[] = [];
try {
stored = await readStoreCaps(store);
} catch (error) {
console.error(accessLogPrefix() + " readStoreCaps failed:", error);
}
for (const cap of stored) caps.learnFor(holderKey, cap);
// Which store a document sits in is a registry fact, not one recorded beside the // Which store a document sits in is a registry fact, not one recorded beside the
// caps, so it is re-applied here. Marking only — the caps just came from the Store // caps, so it is re-applied here. Marking only — the caps just came from the Store
// branch above, and minting a second one beside them is the trap `holdOwnCap` warns // branch above, and minting a second one beside them is the trap `holdOwnCap` warns
+1 -1
View File
@@ -363,7 +363,7 @@ function capOfPayload(payload: unknown): ReadCap | null {
* is the omission, not the length of the list.)* * is the omission, not the length of the list.)*
* *
* Do NOT read `InboxMsgContent::Link` as the intended channel either: it is a **unit * Do NOT read `InboxMsgContent::Link` as the intended channel either: it is a **unit
* variant carrying nothing** (`engine/net/src/types.rs:4251`). * variant carrying nothing** (`engine/net/src/types.rs:InboxMsgContent::Link`).
* *
* The shape is right; the implementation is absent at both ends, so we emulate it * The shape is right; the implementation is absent at both ends, so we emulate it
* meanwhile. * meanwhile.
@@ -0,0 +1,158 @@
/**
* `listMyEntityDocs` answers a VERIFIED listing, or it surfaces never a half-truth.
*
* The call stands on two reads of the same store document, one behind the other: the Main
* branch says WHICH documents are in there, the Store branch hands back the KEY of each
* (the emulated `AddRepo { read_cap }`). Both have to answer for the array it returns to
* mean what a caller reads into it *these are your documents, here they are*.
*
* Neither failure is visible from the outside, and that is the whole reason this file
* exists. A listing whose keys never arrived is the SAME `Nuri[]` as one whose keys did:
* the caller learns nothing at the call, and finds out at the next read, which comes back
* empty with the cause long gone. An empty listing over a store that did not answer is
* worse still it says "you created nothing", and an application renders that.
*
* That is the shape this library shipped twice already: `resolveAccount` answering "no such
* account" for a read that FAILED (`8c8ade7`), and `connectedUser` resolving over a restore
* that never ran (`f6d1734`, which reached a consuming application). The listing was the
* last member of the family, tolerated on the argument that this caller already holds the
* array by the time the key read fails which is exactly what makes returning it a lie
* rather than a shortcut.
*
* The fault is the broker's, and it is one a real one produces
* The two reads are two round-trips. Between them the connection can die that is all
* that is simulated here: the broker answers normally, then stops answering, and every
* later query rejects the way the wasm binding rejects a `RepoNotFound`/transport error.
* Nothing reaches into the library to make one of its functions throw artificially, and
* nothing plants a state the wallet could not be in: the documents below were CREATED
* through the surface, over the same wallet, in the session before.
*/
import { test, expect, describe, mock, beforeEach } from "bun:test";
import { configure, storeRegistry, readUnion } from "../src/index";
import { configureStoreRegistry } from "../src/shared-wallet/bootstrap";
import { sparqlUpdate } from "../src/surface/docs";
import type { NgLike, Nuri, Scope, UseShapeLike } from "../src/model/types";
import { SESSION, forgetEverything, makeWallet, signIn, type Quad } from "./wallet-fake";
const SHIM = "urn:ng-eventually:shim";
/** The Main-branch read — "which documents are in this store". */
const LISTING_READ = `<${SHIM}:contains>`;
/** The Store-branch read — "and what is the key of each". */
const KEY_READ = `<${SHIM}:readCap>`;
const TITLE = "urn:test:title";
/**
* Wire the library onto `quads` over a broker that STOPS ANSWERING from the first query
* `lostAt` accepts that one included, and every one after it, for the rest of the page.
*
* A dropped connection is not selective, so neither is this: the switch decides *when* the
* link dies, never *which* call is unlucky. Passing `() => false` is a broker that stays up.
*/
function bootPage(quads: Quad[], lostAt: (query: string) => boolean): void {
const wallet = makeWallet(quads);
let lost = false;
const ng = {
doc_create: wallet.doc_create,
sparql_update: wallet.sparql_update,
sparql_query: mock(async (...a: unknown[]) => {
if (lost || lostAt(a[1] as string)) {
lost = true;
throw new Error("BrokerError: connection lost");
}
return wallet.sparql_query(...a);
}),
};
configure({ ng: ng as unknown as NgLike, useShape: (() => {}) as unknown as UseShapeLike });
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
}
/**
* The session before: alice signs in and creates one document, over a broker that works.
*
* Returns the reference she keeps, and the quads the wallet now holds the durable half
* that outlives the page.
*/
async function aFirstSessionThatCreated(scope: Scope): Promise<{ quads: Quad[]; note: Nuri }> {
const quads: Quad[] = [];
forgetEverything();
bootPage(quads, () => false);
await signIn("alice");
const note = await storeRegistry.createEntityDoc(scope);
await sparqlUpdate(
SESSION.sessionId,
`INSERT DATA { <urn:test:note> <${TITLE}> "the note" }`,
note,
);
return { quads, note };
}
/**
* The page comes back over the same wallet and alice signs in and only THEN does the link
* die, on `read`. Signing in succeeds, so the failure lands inside the one call under test
* rather than upstream of it, which is what makes the assertion about `listMyEntityDocs`.
*/
async function aSecondSessionLosingTheBrokerOn(quads: Quad[], read: string): Promise<void> {
let armed = false;
forgetEverything();
bootPage(quads, (query) => armed && query.includes(read));
await signIn("alice");
armed = true;
}
const ALL_SCOPES: Scope[] = ["public", "protected", "private"];
beforeEach(() => {
forgetEverything();
});
describe("listMyEntityDocs answers a verified listing or surfaces", () => {
// The NORMAL case first, and it is not a formality: a call that threw unconditionally
// would pass both failure branches below. This is what the two of them are a departure
// from — the listing answers, and every document it names opens.
for (const scope of ALL_SCOPES) {
test(`[${scope}] a broker that answers gives a listing whose documents open`, async () => {
const { quads, note } = await aFirstSessionThatCreated(scope);
forgetEverything();
bootPage(quads, () => false);
await signIn("alice");
expect(await storeRegistry.listMyEntityDocs(scope)).toContain(note);
const subjects = await readUnion([note]);
expect(subjects.map((s) => s.props[TITLE]?.[0])).toEqual(["the note"]);
});
}
// The tolerance this file was written to remove: the listing is IN HAND when the key read
// fails, so returning it costs the library nothing and tells the caller something false.
for (const scope of ALL_SCOPES) {
test(`[${scope}] the keys not answering surfaces, instead of a keyless listing`, async () => {
const { quads, note } = await aFirstSessionThatCreated(scope);
await aSecondSessionLosingTheBrokerOn(quads, KEY_READ);
await expect(storeRegistry.listMyEntityDocs(scope)).rejects.toThrow(/BrokerError/);
// And the point of the refusal, stated as the caller sees it: nothing came back that
// could be mistaken for "here are your documents".
const answered = await storeRegistry.listMyEntityDocs(scope).catch(() => null);
expect(answered).toBeNull();
expect(answered).not.toEqual([note]);
});
}
// The other half of the same promise, and the more dangerous value of the two: an empty
// array reads as "this account created nothing", which an application renders as an
// empty screen rather than as a breakdown.
for (const scope of ALL_SCOPES) {
test(`[${scope}] the listing itself not answering surfaces, instead of "you own nothing"`, async () => {
const { quads } = await aFirstSessionThatCreated(scope);
await aSecondSessionLosingTheBrokerOn(quads, LISTING_READ);
await expect(storeRegistry.listMyEntityDocs(scope)).rejects.toThrow(/BrokerError/);
const answered = await storeRegistry.listMyEntityDocs(scope).catch(() => null);
expect(answered).toBeNull();
expect(answered).not.toEqual([]);
});
}
});