Files
ng-eventually/.project/concepts/app-contract/knowledge_what-a-failed-read-answers-upstream.md
T
Sylvain Duchesne 52ac4d974e docs: le contrat descend a ce que le code tient vraiment, et T01.j est scindee
La sixieme revue a refute la reparation du tour 6 et a trouve la famille sur
laquelle nous nous etions engages a scinder. `held` n'est pas le registre unique :
il y en a trois, et celui que personne n'avait compte est `fanOuts` dans
`subscribe.ts`. `configure()`, qui est PUBLIE, vide les auditeurs sans le dire a
`open-repo` ; et `held.set()` s'execute avant meme que `establish` atteigne
`doc_subscribe`. `held` veut donc toujours dire « on a demande » - le sens plus
lache pour lequel j'avais supprime `opened`. J'ai remplace l'un des deux registres
qui se contredisaient ; il y en avait trois.

Aucun code touche ici. Le defaut de registre part dans T01.m, avec ses deux echecs
prouves comme tests d'acceptation.

Le contrat dit desormais ce que le code tient : la moitie « le document a-t-il
repondu » vaut DANS UNE SESSION ordinaire, et pas apres un second `configure(...)`
ni quand le courtier refuse un abonnement tardivement. Les deux situations sont
nommees en non-garanties avec leur cause et le renvoi vers T01.m. La moitie
« peut-on joindre le document » n'a pas cette reserve : elle est verifiee deux fois
contre un vrai courtier, et la retirer ramenerait un succes vide silencieux
partout, ce qui est pire qu'une garantie partielle honnetement enoncee.

Pas de changement de version : aucun comportement publie ne bouge, seule la
justesse de sa description.

Et une faute de compte-rendu consignee, parce que c'est un motif : trois fois j'ai
affirme qu'un changement avait reduit ou simplifie quelque chose, trois fois la
mesure disait le contraire. Regle desormais mecanique - toute affirmation
structurelle porte son `wc -l` ou son `--numstat` dans la meme phrase, sinon elle
ne s'ecrit pas.
2026-09-24 11:55:12 +02:00

46 lines
7.0 KiB
Markdown

---
type: knowledge
summary: What the target answers for an unreachable, an unsynced and an empty document - a read rejects or it does not, so "not known to me" and "does not exist" are one answer while "unsynced" and "genuinely empty" are another
last_checked: 2026-09-21
---
# What a failed read answers upstream, and what a caller can tell apart
The question a caller actually asks: **when a read comes back with nothing, what can I conclude?** This leaf records the target's answer, because every version of the failure-as-absence defect in this package has come from getting it wrong in one direction or the other - either swallowing a breakdown into an empty result, or turning a real emptiness into an error.
## The engine's observable: a read REJECTS, or it does not
An anchored SPARQL query resolves its target through `resolve_target_for_sparql`, whose first act is `self.repos.get(repo_id).ok_or(NgError::RepoNotFound)?` (`engine/verifier/src/request_processor.rs:269`, at `213338f6`). A repo the verifier does not hold is an **error**, not an empty result. It crosses into JavaScript as a **rejected promise carrying the string `RepoNotFound`** (`sdk/js/lib-wasm/src/lib.rs:403` for nodejs, `:606` for the browser).
So the target offers exactly **two** observable outcomes for a read, and no third: it rejects, or it resolves with rows - possibly zero of them. There is no "partially answered", no "could not find out but here is what I have", and no status object. Any richer vocabulary in this package would be something a caller learns here and unlearns at migration.
## The three states a caller wants to distinguish, and the two it gets
| the document is... | what the engine does | what a caller can conclude |
|---|---|---|
| **not in `self.repos`** - never existed, or exists but this session does not know it | REJECTS with `RepoNotFound` | "I could not read it." **Not** *why*. |
| **in `self.repos` but unsynced** - a persistent profile reloads every known repo at `Verifier::load` (`engine/verifier/src/verifier.rs:535-560`), so a fresh session over the same wallet has the repo without its remote commits | resolves, **zero rows** | nothing distinguishes this from the row below |
| **in `self.repos`, synced, and genuinely empty** | resolves, **zero rows** | nothing distinguishes this from the row above |
Two collapses, and they run in opposite directions. Above the line, **"does not exist" and "exists but I do not know it" are indistinguishable** - the partition the engine exposes is membership of `self.repos`, and nothing finer. Below it, **"known but unsynced" collides with "genuinely empty"** - both are a legitimate zero-row answer, and the error channel is not involved at all.
That second collapse is the one a caller gets bitten by, because zero rows looks like an answer. The only thing that separates the two is a **sync barrier**: `doc_subscribe` pushes `TabInfo` and then the first `State`, and past that first `State` presence is guaranteed and absence definitive (pinned empirically by CONTRACT 3 in `packages/polyfill/e2e/`). Crossing it is what turns "zero rows" into a statement.
## There is no capability check on the read path at all
`resolve_target_for_sparql` reads `nuri.target` and nothing else. Upstream a document you cannot read is one whose `ReadCap` does not open it - the refusal is cryptographic and happens further down, never as a "may I?" question answered on the way in. So **a read never fails for want of a right granted to you**; it fails because the repo is not there, or it succeeds.
This is why this package's `fetchReadCap` (the emulated *"downloaded from the outer overlay"*, `PublicRepoLinkV0`) has **no upstream counterpart to mirror**: there is no signature at any level to copy, and the alignment falls to the engine's model instead. That is the reasoning behind `1.0.0-dev.9`: the emulation's own step was collapsing "the store could not be asked" into "the document exposes no key", so the honest projection of a failed emulation step is the one outcome the engine has for a read that cannot happen - a rejection.
## What this package does with it, and the line it must not cross
Three rules follow, and all three are load-bearing:
1. **A breakdown rejects.** Anything that stops a read from happening - a repo that will not open, a broker that does not answer, an emulation step that could not be performed - reaches the caller as a failure. `A rejection means "unknown", never "absent"` is the contract's own wording.
2. **An empty document is a SUCCESS holding zero objects.** The repair for (1) must never be bought by making a real emptiness into an error; that only moves the lie. An empty document, an empty scope, a document nobody opened an inbox on, a document in a public store that exposes no key - each is an answer, and each stays one.
3. **The unsynced/empty collision is closed by the barrier, and where the barrier was not crossed the read says so rather than answering.** Crossing it is what makes a zero-row answer mean something (`emulated-verifier/open-repo.ts`, which keeps `synced` and `timed-out` deliberately apart). A reactive read over documents it NAMES consults that distinction and stays PENDING when the barrier was not reached - "I have no authoritative answer" rather than "there is nothing". Turning it into a rejection would be inventing a third outcome the engine does not have, and answering a success would be the collapse this whole leaf is about.
**What an application can see of this, exactly.** `useShapeQuery` folds it into its own states, and that is the only published channel: a named scope whose barrier was not crossed does not reach `isSuccess` - **within one ordinary session**. Two situations break it, both known defects and both carried as acceptance tests by `T01.m` ("there must be one answer to whether a document is open"): a second `configure(...)`, which tears down the subscription layer without telling the open registry, and a broker refusing a subscription after this package has stopped waiting. In both, a document recorded as open is not, and the read answers an empty success. The REACHABILITY half is unaffected. `inbox.readSyncedForDocument` is the same guarantee on the inbox path. `getSyncState` is INTERNAL and is deliberately not published - this leaf offered it until 2026-09-24, which was a pointer at something no caller can import. It stays internal because it has no counterpart upstream at any level: publishing it would hand an application a polyfill-era symbol to unlearn at migration, on a surface that is busy removing exactly those. The consequence to state plainly rather than paper over: a caller using the anchored `docs.sparqlQuery` directly gets zero rows for an unsynced document and has **no published way to tell that from an empty one** - that is the collapse upstream has too, and it is why the reactive read is the one that carries the distinction.
Related: `knowledge_what-a-read-scope-is-upstream` (what a read request can SAY, including the `[]` that means the empty set and the `[]` that means no filtering), and `polyfill-surface/contract_polyfill-surface` `## Guarantees` for the caller-facing form of all of this.