fix!: une lecture qui n'atteint pas son document echoue, elle ne repond plus vide

`fetchReadCap` rendait `false` pour trois situations sans rapport : l'emulation
inerte, le document qui n'expose aucune capacite, et le reseau qu'on n'a pas pu
interroger. Les trois appelants vivants lisaient ce `false` comme « pas de
capacite » et transformaient une panne en affirmation sur le document :
`ensureRepoOpen` renoncait en silence et laissait la lecture suivante accuser le
lecteur de ne rien detenir, et `documentInboxAddress` repondait « ce document n'a
pas d'inbox », ce qu'une application lit comme zero depot.

Le telechargement rejette desormais jusqu'a l'appelant, avec le document nomme et
la cause d'origine conservee. `false` ne veut plus dire qu'une chose : le document
n'expose rien, et c'est une reponse. Le memo garde la meme asymetrie qu'avant.

`documentInboxAddress` propage aussi l'echec de sa propre lecture d'adresse : le
`catch` qui rendait `undefined` se justifiait par un refus que `mustNotAttempt` a
deja ecarte une ligne plus haut, donc il n'avalait que des pannes.

`useShapeQuery` franchit la barriere sur chaque document NOMME par `scope.graphs`
avant d'ouvrir la requete : capacite demandee au store public, refus a voix haute
si elle n'est pas detenue, ouverture et synchronisation. Un document nomme
qu'on ne peut pas lire donnait `isSuccess` avec `data: []` apres que le filtre de
capacites l'eut vide - la seule reponse que cette surface existe pour reserver a
« ce scope ne contient rien ». Restent intacts et succes : `did:ng:i`, `graphs:
[]`, un document joignable et vide, et tout le cas inerte.

`fetchReadCaps` supprime : aucun appelant, et une politique de tolerance par lot
sans proprietaire au-dessus d'un appel qui rejette desormais.
This commit is contained in:
Sylvain Duchesne
2026-09-21 15:00:01 +02:00
parent 6cd96b0182
commit 75313f75a2
8 changed files with 463 additions and 60 deletions
@@ -302,6 +302,14 @@ export async function restoreOwnCaps(holderKey: string): Promise<void> {
*
* **Never creates.** Asking where to deposit must not bring an inbox into existence —
* only its owner opens one, and only on its own document.
*
* **`undefined` is an ANSWER, and only an answer** (`1.0.0-dev.9`). It means the document
* carries no address — nobody opened an inbox on it — or that it is not this holder's to
* see one on. It no longer means "the read did not happen": both routes by which a
* breakdown used to arrive here as `undefined` now reject. That mattered because two
* published calls turn this value into a statement an application acts on:
* `inbox.postToDocument` says "its owner never opened one", and `inbox.readForDocument`
* says zero deposits — for a document whose inbox is full.
*/
// @provenance documentInboxAddress kind=divergent level=1 ref=engine/net/src/types.rs:ContactDetails — the deposit-side half of the same divergence: upstream you are TOLD an address, you never read one off a document
export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined> {
@@ -313,7 +321,9 @@ export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined>
// …but ask the (emulated) network first: a document in a public store serves its cap
// to whoever asks (public-store.ts), and "where do I deposit for this public
// document" is exactly the question a third party arrives with, holding nothing but
// the reference.
// the reference. Its rejection is NOT caught: "the store could not be asked" is not
// "this document has no inbox", and the whole value of the answer below is that the
// two are told apart.
await fetchReadCap(doc);
if (mustNotAttempt(doc)) return undefined;
const s = await session();
@@ -330,10 +340,17 @@ export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined>
if (a && isNuri(a)) return a;
}
} catch (error) {
// Unreadable document (no cap) or not synced → no address to give. Refusing to
// read is the boundary doing its job, not an error to propagate here.
// **Propagates a failed read** (`1.0.0-dev.9`). This used to answer `undefined`, on
// the ground that "refusing to read is the boundary doing its job" — but the boundary
// has already had its say at `mustNotAttempt` above, which returned before reaching
// here. What this catch could still meet is a breakdown: a repo that would not open,
// a broker that did not answer, a query that rejected. Answering `undefined` for that
// told the caller the document has no inbox, which is the failure-as-absence this
// path has paid for before. A caller that genuinely prefers to carry on catches it.
console.error(accessLogPrefix() + " documentInboxAddress failed:", error);
throw error;
}
// No address on the Header branch: nobody opened an inbox on this document. An answer.
return undefined;
}
@@ -188,8 +188,16 @@ async function syncSession(): Promise<void> {
* `"synced"`). Idempotent: a repo already opened (or in flight) is not re-opened.
*
* Tolerant by construction: if the injected `ng` exposes no `doc_subscribe` (e.g.
* the fake `ng` in the unit suite), this is a no-op — the read proceeds unchanged.
* Never throws; a failed open just leaves the read to behave as it did before.
* the fake `ng` in the unit suite), this is a no-op — the read proceeds unchanged. The
* open itself never throws: a `doc_subscribe` that never pushes a `State` is bounded by
* the fallback timeout and leaves the read to behave as it did before.
*
* **It DOES throw when the public store could not be asked** (`1.0.0-dev.9`). That is the
* one step here that can fail to ANSWER, and until then it answered `false` — "this
* document exposes no cap" — so the open declined silently and the anchored read that
* followed refused the reader for holding no cap, which was true but was not the reason.
* A caller that means to carry on regardless says so with a `catch` of its own; none does
* today, because every one of them is about to read the document.
*
* `holderKey` names WHOSE possession decides, when it is not the connected identity's —
* the emulated inbox processor opening an inbox repo for its owner during someone else's
@@ -205,7 +213,10 @@ export async function ensureRepoOpen(nuri: Nuri, holderKey?: string): Promise<vo
// the outer overlay (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`). So ASK
// before deciding whether we may touch it, or the answer would be "no" purely for
// want of asking, and a bare reference to a public document would never suffice.
// Memoised and inert once the cap is held (see public-store.ts).
// Memoised and inert once the cap is held (see public-store.ts). REJECTS when the
// store could not be asked at all, which is deliberately not the same event as "this
// document exposes no cap": the open has not happened and the caller must not read as
// though it had.
if (holderKey === undefined) await fetchReadCap(nuri);
// RULE 2 — do not even attempt. Opening a repo IS an access: it subscribes and
// pulls its state. A user that holds no cap for it has no business asking.
@@ -181,10 +181,21 @@ export async function exposeReadCap(doc: Nuri, cap: ReadCap): Promise<void> {
* Nothing is asked when the cap is already held: a document you can read needs no
* fetching, and skipping it keeps the ordinary path free of physical reads.
*
* Never throws. Two different negatives reach the caller as the same `false`, and the
* caller is right not to tell them apart — it obtained no cap either way. They are kept
* apart where it matters, in the memo: an absence is remembered, a failure is not (see
* the note on {@link attempted}).
* **Two outcomes only, and a THIRD that is not an outcome.** `false` means the network
* answered and the document exposes no cap — an absence, and the normal case. `true` means
* a cap was obtained and filed. A download that REJECTED is neither: it REJECTS here too.
*
* It used to answer `false` for that as well, which is the defect this function was the
* head of. `false` then meant "no cap" to three call sites at once, and each of them
* turned a breakdown into a statement about the document: {@link ensureRepoOpen} declined
* to open and let the read that followed blame the reader for holding no cap, and
* `documentInboxAddress` answered "this document has no inbox", which an application reads
* as zero deposits. `downloadReadCap` below produces the distinction deliberately and says
* why; collapsing it one line later destroyed it. A failure must not read as an absence.
*
* The memo keeps the same asymmetry it always did, and it is now the same asymmetry: an
* absence is remembered, a failure is not (see the note on {@link attempted}), so the next
* caller asks the network again instead of replaying a rejection.
*/
// @provenance fetchReadCap kind=declared-not-wired level=1 ref=engine/net/src/types.rs:PublicRepoLinkV0 — the type DECLARES the cap is downloaded from the outer overlay if the brokers allow; it is constructed nowhere, both `PinRepo` constructors hard-code `expose_outer: false`, and `ExtTopicSyncReq` is `unimplemented!()`
export async function fetchReadCap(docLike: Nuri): Promise<boolean> {
@@ -200,7 +211,10 @@ export async function fetchReadCap(docLike: Nuri): Promise<boolean> {
pending = downloadReadCap(doc);
attempted.set(doc, pending);
// A failure is not an answer, so it must not be filed as one: drop the memo the
// moment the download rejects, and the next caller reaches the broker again.
// moment the download rejects, and the next caller reaches the broker again. The
// report stays even though the rejection now reaches the caller: this handler runs
// once per download however many callers shared it, and it runs at all when every one
// of them has given up, which is the only trace left in that case.
// Attached HERE — once per download, before anybody awaits — so the eviction and the
// report each happen exactly once however many callers share this one in-flight
// fetch, and happen at all even if every one of them has already given up. The
@@ -215,12 +229,24 @@ export async function fetchReadCap(docLike: Nuri): Promise<boolean> {
let cap: ReadCap | null;
try {
cap = await pending;
} catch {
// Reported and forgotten by the handler attached where the download started, just
// above — not swallowed here. The answer to the caller is the same as for an absence
// (no cap was obtained), but the memo now holds nothing for this document, so the
// next call asks the network instead of replaying this failure.
return false;
} catch (error) {
// The download REJECTED: the repo would not open, the broker did not answer, the read
// threw. Whether this document may be read is UNKNOWN, and that is not what `false`
// says. Rethrown rather than returned so the caller's read fails instead of continuing
// on a document it never obtained anything for — upstream's observable is that a read
// rejects or it does not, and this is the side of it that was missing.
//
// Wrapped so the reason survives the trip: the message a caller would otherwise meet
// is `assertMayReach`'s "you do not hold this document's cap", which is a different
// event calling for a different action (give up, rather than retry). The original is
// on `cause`, and the handler attached where the download started has already logged
// it and dropped the memo, so the next caller asks the network again.
throw new Error(
"[ng-eventually] fetchReadCap: the public store could not be asked for this " +
"document's read cap, so whether it may be read is UNKNOWN — this is not " +
`"the document exposes no cap". ${JSON.stringify(doc)}`,
{ cause: error },
);
}
if (cap === null) return false;
// Filed for whoever is connected NOW, on every call — the memo spares the round-trip,
@@ -266,13 +292,10 @@ async function downloadReadCap(doc: Nuri): Promise<ReadCap | null> {
return null;
}
/**
* Ask for a SET of documents' caps, in parallel — what a batch read does before it
* decides which documents it may touch. Each fetch is independent and tolerant.
*/
// @provenance fetchReadCaps kind=declared-not-wired level=1 ref=engine/net/src/types.rs:PublicRepoLinkV0 — the batch form of the same unwired mechanism
export async function fetchReadCaps(docs: Nuri[]): Promise<void> {
const unique = [...new Set(docs.filter(Boolean))];
if (unique.length === 0) return;
await Promise.all(unique.map((d) => fetchReadCap(d)));
}
// `fetchReadCaps(docs)` was REMOVED in `1.0.0-dev.9`. It was the batch form — a
// `Promise.all` over the singular, described as "independent and tolerant" — and it had
// ZERO callers, the last one having gone with `readUnion` in `1.0.0-dev.8`. Keeping it
// would have left an unowned tolerance policy standing over a call that now rejects:
// "each fetch is independent" is exactly the skip-and-carry-on this line of work has
// removed twice. Whoever needs several caps asks for each, at the call site that knows
// what a missing one costs it. Internal throughout, so nothing published moves.