Files
ng-eventually/.project/concepts/app-contract/knowledge_what-a-failed-read-answers-upstream.md
T
Sylvain Duchesne de9b8a0d67 feat!: une seule reponse a « ce document est-il ouvert dans cette session »
Trois reponses coexistaient et pouvaient se contredire : un ensemble de
souscriptions bootstrap et une carte d'etat de barriere dans `open-repo`, et les
auditeurs du fan-out dans `surface/subscribe`. Les deux premieres survivaient a
la disparition de la troisieme, parce que `resetDocSubscriptions()` - ce que fait
le `configure()` publie - largue ses auditeurs sans rien dire a personne.

Il n'en reste qu'une, l'etat de l'unique souscription reelle du document, exposee
par `docChannel(nuri)`. `open-repo` n'enregistre plus que l'auditeur qu'il a pose
- « est-ce que j'ecoute encore », rien de plus - et derive `getSyncState` a chaque
lecture, si bien qu'aucune reponse ne peut survivre au canal qu'elle decrit.
Disparaissent : `held`, `syncState`, `inFlight`, `settleInFlight` et le compteur
de generation, remplace par l'identite de l'enregistrement.

Les deux echecs prouves deviennent des tests qui echouent sans le changement :
un second `configure()` apres une synchro reussie (`"synced"` au lieu de
`"timed-out"`, zero re-souscription, et `isSuccess` cote application), et un
etablissement encore en cours quand la couche de souscription est demontee - cas
que le canal `onSetupFailed` ne peut pas rattraper, puisqu'il ne rapporte que la
tentative encore attendue.

La ligne qui ne casse pas : un document synchronise qui ne contient rien repond
toujours un succes a zero objet. `1.0.0-dev.10`, les deux non-garanties
nommees sont retirees et la garantie livree porte le test qui l'epingle.
2026-09-24 12:57:43 +02:00

7.2 KiB

type, summary, last_checked
type summary last_checked
knowledge 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 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. That held only within one ordinary session until 1.0.0-dev.10: a second configure(...) tore down the subscription layer without telling the open registry, and a broker refusing a subscription after this package had stopped waiting left the document recorded as open - in both, a document recorded as open was not, and the read answered an empty success. Closed by giving the question ONE answer: openness is the state of the document's one real subscription, read rather than remembered, so no record of it can outlive the channel it describes. The REACHABILITY half was never affected. 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.