docs: le contrat passe en 1.0.0-dev.9, et ce qu'une lecture echouee repond est consigne
Majeur : trois appels existants se resserrent. `useShapeQuery` publie `isError` pour un document NOMME qu'il ne peut pas lire, la ou il rendait `isSuccess` avec `data: []` - la seule image que cette surface reserve a « ce scope ne contient rien ». `inbox.postToDocument`, `readForDocument` et `readSyncedForDocument` rejettent la ou elles annoncaient « pas d'inbox ». Rien de ce qui etait deja une reponse ne bouge : un document vide, `graphs: []`, un document sans inbox, un document public qui n'expose aucune cle. Deux non-garanties corrigees parce que le changement les rend fausses : « aucune des deux lectures par forme ne va chercher la cle d'un store public » ne vaut plus que pour `useShape` et pour `did:ng:i`, et le pending eternel d'un demarrage refuse se referme sur le cas nomme. Nouveau `knowledge_what-a-failed-read-answers-upstream` : ce que la cible repond pour un document injoignable, non synchronise ou vide, et les deux collisions que l'appelant ne peut pas demeler - « n'existe pas » contre « pas connu de moi » d'un cote, « non synchronise » contre « vraiment vide » de l'autre. C'est le fait durable qui justifie la direction prise, et il vaut quel que soit le remede. `docs/internal-contract.md`, section open-repo seulement : numeros de ligne, `ensurePhysicalRepoOpen` qui n'a jamais ete un export de ce module, `ensureReposOpen` supprime, `openRepoUnguarded` absent, et le second parametre de `ensureRepoOpen`. Le reste du document porte une derive du meme age, laissee.
This commit is contained in:
@@ -47,6 +47,7 @@ This repo is the **provider** of `polyfill-surface`; consuming applications live
|
||||
- `knowledge_what-an-app-deletes-at-migration` — the two fates a published symbol can have.
|
||||
- `knowledge_what-an-iri-says-upstream` — the same two fates for a written *triple*: NextGraph's own vocabulary, ours, and which of our IRIs must outlive the migration.
|
||||
- `knowledge_what-a-read-scope-is-upstream` — what the target offers for reading many objects at once, and the one question it cannot express.
|
||||
- `knowledge_what-a-failed-read-answers-upstream` - the other half of the same question: what an unreachable, an unsynced and an empty document each answer, and which of them a caller can tell apart.
|
||||
- `knowledge_what-the-orm-writes-and-deletes-upstream` — the writing half: removal is a set mutation wired end to end, and an ORM write never passes through `sparql_update`.
|
||||
- `knowledge_what-an-inbox-is-upstream` — what the target actually offers around inboxes, and where a choice of ours begins.
|
||||
- `knowledge_who-processes-an-inbox` — the division of labour along a deposit's path: what this package applies, what it merely delivers, and what a layer above owes itself.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
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, not by an error.** Where this package can cross the sync barrier before answering, it does (`emulated-verifier/open-repo.ts`), which is what makes a zero-row answer mean something. Where it cannot, the answer is still a success, and `getSyncState` is what says whether it was authoritative. Turning an unsynced read into a rejection would be inventing a third outcome the engine does not have.
|
||||
|
||||
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.
|
||||
@@ -161,6 +161,8 @@ Only a document's owner writes to it. Holding its read key never grants a write.
|
||||
|
||||
**`insertObject(shapeType, object)` needs the object's `@graph`**, and refuses a document you do not own. The `@graph` is the only thing that says which document the write lands in - upstream the insert subscribes to no document at all - and it is also what decides the refusal, so there is nothing extra to pass. An object with no `@graph` is forwarded and NextGraph refuses it in its own words.
|
||||
|
||||
**"This document has no inbox" is an answer, and only an answer, since `1.0.0-dev.9`.** `inbox.postToDocument` refuses a document whose owner never opened an inbox, and `inbox.readForDocument` / `readSyncedForDocument` answer `[]` for one - both unchanged, both still the ordinary case. What changed is that a read which could not HAPPEN no longer arrives as one of those: a broker that cannot answer for the document, or a public store that cannot be asked for its key, now rejects. Until this version a depositor was told the owner had opened nothing, and a reader was handed an empty list, for a document whose inbox is full.
|
||||
|
||||
`inbox.share(doc, toUser)` names the document and the person; the recipient calls nothing. It refuses a recipient nobody has signed in as, rather than creating them.
|
||||
|
||||
`inbox.post` refuses a target that is not an inbox; to reach a document's owner, use `inbox.postToDocument(doc, …)`. Anyone may deposit into an inbox; only its owner reads it.
|
||||
@@ -191,6 +193,8 @@ Where a call must first find out whether something already exists — a document
|
||||
|
||||
**A reactive read says "nothing" and "I could not find out" differently.** `useShapeQuery` answers in three states and only two of them are answers about your data: `isPending` while the question is still open, `isSuccess` once it has been answered, `isError` when it could not be. An empty `data` under `isSuccess` means this scope holds nothing of that shape — the distinction the surface exists for, and the one thing it will never do is publish that snapshot for a read that did not happen. And because an observable cannot take back a list a subscriber has already rendered, `data` under `isError` keeps the **last read that answered** rather than emptying — so an empty `data` is never handed to you as a failure's answer. Read the load state before `data`: **a rejection means "unknown", never "absent"** here too.
|
||||
|
||||
**A document you NAME in `graphs` is reached before the read opens, since `1.0.0-dev.9`.** Naming a document in a scope is the same act as anchoring `docs.sparqlQuery` on it, and it is now answered the same way: the key is asked of a public store if you do not already hold it, the document is refused out loud if you hold no key for it, and its repo is synced before the question is put. A named document you cannot read publishes `isError`, where until this version it reached the engine, came back, and was emptied by the per-document filter into `isSuccess` with `data: []` - the one snapshot this surface reserves for "this scope holds nothing". A named document that has never synced published that same snapshot for a document that is not empty; it too is now `isError` or a longer `isPending`, never a false emptiness. **Naming SEVERAL documents fails as a whole** when one of them cannot be reached: a short list is the same lie as an empty one, so nothing is answered rather than half of it. What is NOT touched, and each stays a success: `graphs: "did:ng:i"` or `""` (you named no document - the per-document filter is the whole of the answer there, and an empty result means you hold nothing of that shape), `graphs: []` (no objects, by NextGraph's own definition), and a named document you can read that holds nothing of the shape. The practical cost is that this read now spends round-trips a named scope did not spend before, once per document per session.
|
||||
|
||||
**Which failure lands in `isError` changed in `1.0.0-dev.6`, and one of them now stays PENDING.** The read goes through the SDK's own shape query, so what `isError` carries is the query failing to open: a shape the ORM cannot subscribe, or a `configure` that was never given `ormSubscription`. A start the underlying engine REFUSES does not reach it: NextGraph catches that failure and logs it, and its readiness signal is only ever resolved, never rejected, so such a read stays `isPending` indefinitely. The guarantee above is unaffected — you are still never told "there is nothing" for "nobody answered" — but an interface that handles only `isError` will show its loading state where it used to show an error. Handle a `isPending` that does not end.
|
||||
|
||||
**A read is scoped by document and by subject, never by store.** `Scope` has two axes and there is no third: `graphs` names one document's graph per entry — `did:ng:i` or `""` for the whole local dataset, `[]` or `undefined` for no objects at all — and `subjects` filters subject IRIs literally, with no prefix or hierarchy matching. There is no call that lists the documents in a store, and naming a store in `graphs` is not the question you meant: a store's graph holds its containment triples and nothing of the shape you asked for, so it answers empty rather than raising. That is the target's own shape, not a narrowing this package chose — and since `1.0.0-dev.6` nothing is translated on the way: the `Scope` object you write is the object the SDK receives.
|
||||
@@ -219,7 +223,7 @@ Where a call must first find out whether something already exists — a document
|
||||
|
||||
**The wrapped `ng` is a wrapper, and a wrapper can be wrong.** It forwards all 88 members to the object you gave `configure`, adding two refusals of its own and nothing else. Until `1.0.0-dev.7` it did not forward them at all - every member came back as a promise, and calling one failed with NextGraph's own `<member> is not a function`. The lesson to keep is that a member of `ng` that misbehaves is worth reporting here rather than worked around: nothing in this package's own code goes through that wrapper, so this surface is the only place such a defect shows.
|
||||
|
||||
**A shape query the engine refuses never finishes loading.** Both shape reads — `useShape` and `useShapeQuery` — open a subscription through NextGraph's ORM, and a start the engine refuses is caught there, written to the console and nothing more: the set stays empty, its readiness never settles, and the only trace is that one console line. `useShapeQuery` reports it as a `isPending` that does not end (see the guarantee above); `useShape` returns a set you cannot tell from an empty one. The cause is upstream and nothing in this package can work around it. This was a total outage until the client packages caught up with the deployed broker on 2026-09-21; it is the residual failure mode, not the ordinary case.
|
||||
**A shape query the engine refuses never finishes loading.** Both shape reads — `useShape` and `useShapeQuery` — open a subscription through NextGraph's ORM, and a start the engine refuses is caught there, written to the console and nothing more: the set stays empty, its readiness never settles, and the only trace is that one console line. `useShapeQuery` reports it as a `isPending` that does not end (see the guarantee above); `useShape` returns a set you cannot tell from an empty one. The cause is upstream and nothing in this package can work around it. This was a total outage until the client packages caught up with the deployed broker on 2026-09-21; it is the residual failure mode, not the ordinary case. Since `1.0.0-dev.9` the commonest way in is closed for `useShapeQuery`: a document NAMED in `graphs` is opened and refused before the query is handed over, so an unreachable named document reads `isError` rather than pending for ever. What remains is the start the engine refuses for a document you can perfectly well reach, and everything under `graphs: "did:ng:i"`, where there was no name to check.
|
||||
|
||||
**No object removal call, and no document deletion at all.** There is no `deleteObject` and no `removeDocument` at any level of NextGraph, so there is none here. You remove an object by deleting it from the LIVE set a shape query handed you - the item the set is holding, reached by iterating it, by `.first()` or by `.getBy(graph, subject)`. What that removes is the predicates your SHAPE declares on that subject: triples under a predicate the shape does not name survive, the subject survives, and the document survives. Deleting an item of a `getObjects` result removes nothing, because that result is a clone. `@ng-org/orm@0.1.2-alpha.21` ships a `removeObject(graphNuri, subjectIri)`; it is not published here, because it exists in the package and not in the source this package is verified against, and a symbol whose only evidence is what an npm build happens to export is not something to build a guarantee on.
|
||||
|
||||
@@ -245,7 +249,7 @@ Where a call must first find out whether something already exists — a document
|
||||
|
||||
**`getObjects` does not fetch a public store's key for you either**, for the same reason as the two shape reads below: it goes through the same ORM query and spends no round-trip asking. Read the document once by naming it — `docs.sparqlQuery` anchored on it — and it appears.
|
||||
|
||||
**Neither shape read fetches a public store's key for you.** A document in a public store gives up its key to whoever asks, but asking is a round-trip and neither `useShape` nor `useShapeQuery` spends one — since `1.0.0-dev.6` the second reads through the SDK and no longer opens a document of its own. So a public document you have read nowhere else does not appear in either. Read it once by naming it — `docs.sparqlQuery` anchored on it, which is the whole of what `readUnion` did for this before it was removed in `1.0.0-dev.8` — and both pick it up: the key lands in what you hold, and that is a change both of them re-read on.
|
||||
**`useShape` does not fetch a public store's key for you, and `useShapeQuery` does so only for what you NAME.** A document in a public store gives up its key to whoever asks, but asking is a round-trip: `useShape`'s signature is NextGraph's own and has no await to spend, so a public document you have read nowhere else never appears in it. `useShapeQuery` spent none either until `1.0.0-dev.9`; it now asks once per document named in `graphs`, which is what lets a bare reference to a public document be read through that call. It still asks nothing under `graphs: "did:ng:i"` - the whole local dataset names no document, so there is nobody to ask about. For those two cases, read the document once by naming it - `docs.sparqlQuery` anchored on it, which is the whole of what `readUnion` did for this before it was removed in `1.0.0-dev.8` - and both reads pick it up: the key lands in what you hold, and that is a change both of them re-read on.
|
||||
|
||||
**A watch on one inbox that could not be opened does not come back on its own.** Opening it can fail — a broker that does not answer in that moment — and the failure is reported rather than passed over, but what follows is event-driven and this package deliberately never polls. The watch is opened again at the next moment this identity comes to hold something it did not: it creates a document, it opens an inbox on a document, a `ReadCap` reaches it through an inbox still being watched, it reads a public-store document for the first time. A session that does none of those goes on without that inbox — deposits made into it are not applied, nothing raises, and they wait unconsumed for the next connection. The exposed case is the identity that only ever READS, since it produces none of those events, where an identity that goes on creating recovers as a by-product of its own work. Awaiting `ensureIdentity()` again applies what is waiting — it drains every one of this identity's queues before it resolves, and shows no barrier a second time — but the watching itself comes back only on a fresh page.
|
||||
|
||||
@@ -259,11 +263,19 @@ What each level means here, in this package's own terms:
|
||||
- **minor** — a symbol is added and nothing existing moves (`inbox.readSyncedForDocument` arrived this way).
|
||||
- **patch** — a fix that changes neither the published surface nor anything above under `## Guarantees`.
|
||||
|
||||
**A tag says where it comes from.** A release cut on `main` carries a **full version** (`1.0.0`), and the three rules above govern what changes between two full versions. Work still on a branch carries a **pre-release** of the version it is heading for (`1.0.0-dev.8`), which sorts *below* that version by construction - so you can pin what exists today while the tag itself tells you the surface has not been released and may still move before it is. Between two pre-releases of the same version nothing is promised: re-pull and read this leaf again. When the branch lands, the full version appears alongside; the pre-release keeps resolving, so no reference you pinned is ever withdrawn from under you.
|
||||
**A tag says where it comes from.** A release cut on `main` carries a **full version** (`1.0.0`), and the three rules above govern what changes between two full versions. Work still on a branch carries a **pre-release** of the version it is heading for (`1.0.0-dev.9`), which sorts *below* that version by construction - so you can pin what exists today while the tag itself tells you the surface has not been released and may still move before it is. Between two pre-releases of the same version nothing is promised: re-pull and read this leaf again. When the branch lands, the full version appears alongside; the pre-release keeps resolving, so no reference you pinned is ever withdrawn from under you.
|
||||
|
||||
**Tags carry the package name**, because this repository publishes more than one engagement and their versions move independently: `polyfill/v1.0.0-dev.8` is this package, `ng-e2e-helpers/v...` is the other one. A bare `v...` tag would say nothing about which surface it froze the day the two diverge - which is the day one of them takes a major and the other does not.
|
||||
**Tags carry the package name**, because this repository publishes more than one engagement and their versions move independently: `polyfill/v1.0.0-dev.9` is this package, `ng-e2e-helpers/v...` is the other one. A bare `v...` tag would say nothing about which surface it froze the day the two diverge - which is the day one of them takes a major and the other does not.
|
||||
|
||||
`1.0.0` is a baseline, not a claim of maturity: it is the number that makes your pin mean something. Nothing was released before it, so none of the changes named above is a bump from anything - but the next release very likely is a major. What exists today is `1.0.0-dev.8`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it - `against: @ng-eventually/polyfill@1.0.0-dev.8`, the string you pinned, never the version it is heading for.
|
||||
`1.0.0` is a baseline, not a claim of maturity: it is the number that makes your pin mean something. Nothing was released before it, so none of the changes named above is a bump from anything - but the next release very likely is a major. What exists today is `1.0.0-dev.9`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it - `against: @ng-eventually/polyfill@1.0.0-dev.9`, the string you pinned, never the version it is heading for.
|
||||
|
||||
`1.0.0-dev.9` is a **major**, and all of it is one rule: **a read that could not reach its document now FAILS, where it used to answer as though the document were empty.** Nothing is added, nothing is renamed, nothing is removed from the entry - three existing calls narrow.
|
||||
|
||||
- **`useShapeQuery` publishes `isError` for a document you NAME and cannot read.** Until this version a document named in `scope.graphs` was handed straight to the engine; whatever came back was filtered per document by what you hold, and a document you hold no key for was emptied out of the result. The snapshot was `isSuccess` with `data: []` - indistinguishable, byte for byte, from the scope genuinely holding nothing. It now asks the public store for the key, refuses out loud if there is none to be had, and crosses the sync barrier first, so the same read answers `isError`. **Handle `isError` on a named scope**; an interface that only branched on `isPending` / `isSuccess` will show its success state with nothing in it. Naming several documents fails as a whole when one of them fails.
|
||||
- **A named scope now costs round-trips it did not cost before**, once per named document per session: the key fetch, and the subscribe that syncs the repo. `graphs: "did:ng:i"` is unchanged and spends nothing.
|
||||
- **`inbox.postToDocument`, `inbox.readForDocument` and `inbox.readSyncedForDocument` reject where they used to report "no inbox".** Resolving a document's inbox address reads that document, and when the read could not happen - the broker could not answer for it, or its public store could not be asked for the key - all three used to render the breakdown as the document's owner having opened nothing: a refusal on the deposit side, an empty list on the read side. They now reject. A document that genuinely has no inbox is unchanged, and still answers exactly as before.
|
||||
- **Nothing that was already an ANSWER moves.** An empty document is a success holding zero objects, `graphs: []` is a success holding none, a document in a public store that exposes no key is a plain "no", and a document with no inbox answers `[]`. The whole change is that a *failure* stopped borrowing those shapes.
|
||||
- **Why this direction, and not a richer set of outcomes.** The step that collapsed the two was this package's own emulation of a public store serving its key; NextGraph has no counterpart for it, because upstream nothing consults a capability on the read path at all. So there is no signature to mirror, and the alignment falls to the engine's own observable: an anchored query on a repo the verifier does not hold answers `NgError::RepoNotFound`, a rejected promise - **a read rejects, or it does not**. Inventing a third outcome for a caller to branch on would have been a vocabulary the target will never grow.
|
||||
|
||||
`1.0.0-dev.8` is a **major**, and it is one removal: **`readUnion` and `UnionSubject` are gone from this surface, and nothing replaces them.**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user