docs: documenter l-unique reponse a l-ouverture d-un document

This commit is contained in:
Sylvain Duchesne
2026-10-01 13:53:11 +02:00
parent f5478643be
commit 00127ac09f
2 changed files with 69 additions and 1 deletions
+3 -1
View File
@@ -2,13 +2,14 @@
type: overview
summary: What an application may rely on from @ng-eventually/polyfill, and what it will have to delete
triggers:
keywords: [polyfill, sdk, surface, contract, publish, published, entry, export, migration, unlearn, consumer, app-facing, inbox, deposit, IRI, urn, namespace, vocabulary, ontology, predicate, orm, useShape, getObjects, insertObject, OrmSubscription, ngSession, read filter]
keywords: [polyfill, sdk, surface, contract, publish, published, entry, export, migration, unlearn, consumer, app-facing, inbox, deposit, IRI, urn, namespace, vocabulary, ontology, predicate, orm, useShape, getObjects, insertObject, OrmSubscription, ngSession, read filter, docChannel]
paths:
- "packages/polyfill/src/index.ts"
- "packages/polyfill/src/surface/**"
- "packages/polyfill/src/emulated-verifier/machinery.ts"
- "packages/polyfill/src/emulated-verifier/index-deposit.ts"
- "packages/polyfill/src/emulated-verifier/read-filter.ts"
- "packages/polyfill/src/emulated-verifier/open-repo.ts"
- "packages/polyfill/README.md"
- "examples/notebook/**"
- "docs/api-contract.md"
@@ -53,3 +54,4 @@ This repo is the **provider** of `polyfill-surface`; consuming applications live
- `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.
- `knowledge_what-stands-between-identities` - the one module that isolates identities sharing a wallet: two views repaired together, a decided-or-refused default, and the hole host introspection leaves open.
- `knowledge_one-answer-to-whether-a-document-is-open` - openness is asked of the document's one real subscription and never remembered, and the sweep that established it counted eleven records where the problem had named three.
@@ -0,0 +1,66 @@
---
type: knowledge
summary: Openness is the state of the document's ONE real subscription, asked and never remembered - the sweep that established it counted eleven pieces of state where the problem had named three, and the lesson is to count exhaustively, including what merely implies the fact, before designing
last_checked: 2026-10-01
---
# One answer to whether a document is open in this session
"Is this document open in this session" must have exactly ONE answer. It had several, and they disagreed - six rounds of work on the predecessor task kept finding the same defect in a narrower corner before anyone named the cause. This leaf records the invariant that closed it, why each displaced record had to GO rather than be repaired, and the counting discipline that is the transferable part.
## The invariant, and where it is written
**The answer is the state of the document's ONE real subscription - `docChannel(nuri)` in `surface/subscribe.ts` - and `emulated-verifier/open-repo.ts` records only the bootstrap open it ASKED FOR: the listener it took out, which fan-out that listener joined, and the bounded wait around it. Never whether that listener became a live channel or crossed a barrier.** So no answer in `open-repo` can outlive the channel it describes, because none is kept.
It is written three times, each beside the state it governs: `open-repo.ts`'s header block, `subscribe.ts`'s header under *This module OWNS "is this document open in this session"*, and on the `DocChannel` type. A branch upstream holds exactly one subscriber - a second `doc_subscribe` EVICTS the first - which is why the fan-out exists at all and why it is the physical truth: a listener exists or it does not.
`docChannel` answers from the fan-out and nothing else. `establishing` says an attempt STANDS (left true after success, so on its own it cannot tell "asked" from "open"), `realUnsub` says that attempt's call ANSWERED, `lastState` is the barrier, `incarnation` says WHICH fan-out stands so a caller holding a listener can tell its entry was replaced under it, and `attempt` qualifies the rest so a late outcome can tell whether it is still the one being waited on.
## The count was ELEVEN, and the gap is the finding
The problem was stated with THREE records, and that number is still in the published contract's `1.0.0-dev.10` entry - **it is the count of the opening hypothesis, not of the finding, and the gap is the point.** An inventory pass over every module-level `Map`/`Set` in `src`, plus every reader, then checked by hand, found eleven.
Five in `open-repo.ts`, all deleted: `held` ("this session holds a live bootstrap subscription"), `syncState` (the barrier), `inFlight` ("an open is under way", read as a short-circuit), `settleInFlight` (the same set of documents with a different payload - a second record of `inFlight`), and `registryEpoch` plus each open's `myEpoch` (a record ABOUT the records, which existed only because the records did). **The last two merely IMPLIED openness through a short-circuit and had never been counted by anyone**, including six adversarial rounds on the predecessor task.
Five in `surface/subscribe.ts`, which survive as the single answer: `fanOuts`, `establishing`, `realUnsub`, `lastState` and `attempt`. **`lastState` turned out to have been a second barrier record all along, and the correct one** - it is forgotten when the channel is released, when the session changes under it, and when the attempt that produced it fails, which is exactly when a barrier stops meaning anything.
The eleventh is `boundSessionId`, counted and KEPT: it is not per-document, and says which session the registry belongs to.
Also counted and ruled OUT, each with its reason, because ruling out is part of counting: `inbox-observer`'s `obs.inboxes` (a different fact - watched by the observation, not open in the verifier - and its teardown is already paired with the one that broke the others), `public-store`'s `attempted` (nominally a cap memo, but a memo hit skips an `ensurePhysicalRepoOpen` inside it, and nothing resets it on `configure` - left alone and reported), and the in-flight or being-serviced maps in `branch-registers`, `inbox-drain`, `inbox-processor`, `connect`, `surface/inbox` and `account-registry`. ORM land holds no openness record at all: `orm_start_graph` is deliberately unused, and there is no graph registry or refcount anywhere in `src`.
## Why two records of one fact cannot be reconciled
They disagreed in both directions, and each failure was measured:
- **A reset cleared one and left the other standing.** `configure()` is PUBLISHED and calls `resetDocSubscriptions()`, which drops every fan-out listener and tells `open-repo` nothing. `held` kept a dead entry and `syncState` kept `"synced"`, so the short-circuit skipped the re-open for ever: after a second `configure`, a named `shape read` over a previously synced document made zero `doc_subscribe` calls and published `{isPending:false, isSuccess:true, data:[]}` - an empty success asserting AUTHORITY for a document the session had never subscribed, unrecoverable even through a refetch.
- **A subscribe the broker REJECTED was recorded as opened**, blocking the retry the subscription layer already offered through a channel nothing had wired. Measured with a `doc_subscribe` rejecting at 200ms against a 20ms fallback: one subscribe call after the retry where the claimed behaviour needs two.
The repair is therefore deletion, not notification: nothing outside `subscribe.ts` records openness, so teardown is safe BY CONSTRUCTION and no cross-module notification was added. **A guard at the READER cannot fix a fact that is wrong at the source** - the first draft put the channel question in `getSyncState` instead of forgetting the barrier at the source, which only MASKED a stale barrier while the fan-out went on replaying a dead `State` to the next joiner.
## A record is written when the fact becomes TRUE, not when it is requested
`held` was written the moment `subscribeDocUnguarded` returned - which is BEFORE the `doc_subscribe` behind it has been placed, since `establish` awaits the session id first - so it meant *"we asked"*: the exact looser meaning an `opened` set had been deleted for one round earlier. That is a claim about what a call's return implies, written and never run; see [[justify-a-return-value-by-running-it]].
`BootstrapOpen.release` is held to the same rule and says only *"am I still listening"*. It cannot answer *"is this document open"*, because `resetDocSubscriptions` drops a listener from under this module without a word - so `stillStanding` asks three questions, every one of them of the thing that owns the answer: do I hold a listener at all, is it in the fan-out that stands NOW (`channel.incarnation !== open.incarnation`), and does an attempt stand behind it. An attempt that has not ANSWERED yet counts as standing, deliberately: a second `doc_subscribe` cannot be placed while one is in flight, so a fresh attempt buys nothing and costs the caller the whole bounded fallback again.
## The four sync states, and why `"unknown"` is authoritative
`getSyncState` DERIVES all four on every call and stores none. `"synced"` - a `State` has arrived on the channel standing NOW; the truth signal, so a zero-row read MEANS zero. `"syncing"` - inside the bounded wait. `"timed-out"` - opened here with no barrier to show for it, whether the wait gave up, the subscribe was refused, or the channel was taken away; never `"unknown"`, which is why a reset DISOWNS a document rather than forgetting it. `"unknown"` - never opened here, or the no-`doc_subscribe` path with no `State` semantics.
**`"unknown"` passes as authoritative, deliberately: where there are no `State` semantics there is no barrier to have missed.** Refusing it instead is an inert-case regression that has shipped twice. Its two readers in `src` disagree only in the direction each must be safe in - `index-deposit`'s `noRowsMeansAbsent` accepts `"synced"` and `"unknown"`, and `use-shape-query`'s `namedDocumentsArrived` refuses `"timed-out"` and `"syncing"`. `inbox-observer` reads NEITHER; it keeps its own watch record, and counting is what settled that (see `bug_a-lost-inbox-watch-is-never-retried`).
## Every teardown path, and what each leaves behind
- `configure()` and `resetConfig()` (PUBLISHED) call `resetDocSubscriptions` and `stopObservingInboxes`, and NOT `resetOpenedRepos`. They leave `open-repo`'s records holding a listener whose fan-out is gone - now inert by construction: `stillStanding` sees a changed `incarnation` and re-opens, and `getSyncState` reads `"timed-out"` off a channel in state `"none"`.
- `resetDocSubscriptions()` clears every listener, releases each fan-out and empties the map. It leaves `obs.inboxes`, `public-store.attempted` and `seenByInbox`.
- `resetOpenedRepos()` - ONE caller in `src`, `syncSession` on a session-id change - releases each bootstrap listener, ENDS the abandoned wait (without which a caller hangs the full fallback on a subscription that no longer exists), replaces each record with a disowned one, clears `boundSessionId`, then calls `resubscribeDocs`. It leaves the same three.
- `resubscribeDocs()` re-opens each channel ON THE SAME entry, forgetting `lastState` because the barrier belonged to the session that is gone, and keeps the keys and listeners on purpose so an `inbox.watch` survives the change. This is why entry identity cannot answer which attempt is current, and `attempt` must.
- `releaseFanOut()` takes one entry out of the map with its barrier; `reportSetupFailure()` clears `establishing` (retryable) and `lastState` (a barrier belongs to the attempt that produced it) for the CURRENT attempt only - a superseded one is logged and nothing else.
- `setCurrentUser()` calls `stopObservingInboxes` only, and correctly: an identity change is not a session change. There is no sign-out or `session_stop` path in `src`; `setCurrentUser(null)` is the disconnection.
- `forgetEverything()` (TEST-ONLY) never clears `seenByInbox`, `running`, `openInboxInFlight` or `connect.inFlight`.
## The lesson, which is the transferable part
**Count exhaustively FIRST - including anything that merely INFERS the fact - and write the count down before designing anything.** Two attempts failed by fixing a WRITER without knowing who read it, and the two records nobody had counted were both short-circuits that implied openness rather than stating it. Enumerate the readers and the teardown paths from the code and verify the list rather than trusting it; the count is what produced the invariant, not the other way round.
The caller-facing consequence is in `knowledge_what-a-failed-read-answers-upstream` (its third rule) and in `polyfill-surface/contract_polyfill-surface` at `1.0.0-dev.10`, where the pending-until-the-barrier guarantee lost its session-scoped qualification. The other half of what a shape read may show is `knowledge_what-stands-between-identities`: this leaf decides whether a named document is an answer, that one bounds what the answer may contain.