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.
This commit is contained in:
Sylvain Duchesne
2026-09-24 11:55:12 +02:00
parent 4c8efbc10a
commit 52ac4d974e
3 changed files with 44 additions and 8 deletions
@@ -112,7 +112,7 @@ Rejected, with the deciding reason for each:
## Result
Status: done. `1.0.0-dev.9`, MAJOR. Three commits: `75313f7` (the behaviour change), `b0eec7b` (the contract, the knowledge leaf and the narrow `internal-contract` repair), `534d8f9` (two guard tests).
Status: done, and SPLIT. `1.0.0-dev.9`, MAJOR. What this task delivers is a real, verified, PARTIAL improvement: a failure no longer reads as an absence at four call sites and on the reachability half of the reactive read, and the one half that is still incomplete - "has this document arrived" - is documented as incomplete rather than promised. The registry defect underneath it is `.tasks/todo/T01.m-one-answer-to-whether-a-document-is-open.md`, which carries both proved failures as acceptance tests. Seven rounds, six independent adversarial reviews; round 7 is the split and changed no code.
### What each call site does now when the cap cannot be obtained
@@ -156,7 +156,7 @@ Fix, round 2: the keyring signal RE-OPENS a scope the barrier refused, gated on
**Finding 1, blocking and STRUCTURAL - the gate was circular.** `namedDocumentsReachable()` tested `mayReach`, i.e. the cap is HELD. For a named document in a PUBLIC store the only channel that can ever file that cap is `fetchReadCap` - the very call that had failed and set the flag. The gate waited on an answer that only the action it gated could produce, so that whole class was unrecoverable however many caps arrived. Reproduced as `use-shape-query.test.ts` (v): the broker hiccups on the `exposedReadCap` read, the fetch rejects, the broker heals, an ordinary cap arrival follows, and the view never re-asks.
**The readiness question, answered rather than patched.** There are exactly two ways this barrier can refuse, and the memo already tells them apart. The store could not be ASKED: a failure is deliberately not memoised (an earlier fix in this repo), so a re-attempt genuinely re-asks the network - which IS the repair for this class, not a cost to avoid. Or the store ANSWERED and there is no cap to be had: an absence is memoised, so a re-attempt asks nobody, re-runs the barrier against what is already known, and republishes the same refusal for the price of one snapshot - and it stops the day the cap arrives from elsewhere, which is the only thing that repairs THAT class. So the right signal is not a cleverer predicate, it is **attempting again**: cheap for the class it cannot help, correct for the class it can, self-limiting because the moment an attempt succeeds there is a query again. `namedDocumentsReachable()` is DELETED. **CORRECTED: the claim that this made the module smaller than round 2 left it was FALSE** - measured, `use-shape-query.ts` went 534 -> 563 lines and 203 -> 204 non-comment across that round, and is 664 / 232 now. It has grown at every round. One predicate left; the reasoning that had to be written down beside the code grew more than it. Pinned by (v) and, for the cost, by (s), which now asserts both halves (the re-attempt ran, and it spent no round-trip) so it cannot pass vacuously.
**The readiness question, answered rather than patched.** There are exactly two ways this barrier can refuse, and the memo already tells them apart. The store could not be ASKED: a failure is deliberately not memoised (an earlier fix in this repo), so a re-attempt genuinely re-asks the network - which IS the repair for this class, not a cost to avoid. Or the store ANSWERED and there is no cap to be had: an absence is memoised, so a re-attempt asks nobody, re-runs the barrier against what is already known, and republishes the same refusal for the price of one snapshot - and it stops the day the cap arrives from elsewhere, which is the only thing that repairs THAT class. So the right signal is not a cleverer predicate, it is **attempting again**: cheap for the class it cannot help, correct for the class it can, self-limiting because the moment an attempt succeeds there is a query again. `namedDocumentsReachable()` is DELETED. **CORRECTED: the claim that this made the module smaller than round 2 left it was FALSE** - measured, `use-shape-query.ts` went 534 -> 563 lines and 203 -> 204 non-comment across that round, and is **727 lines** now (`wc -l`). It has grown at every round. One predicate left; the reasoning that had to be written down beside the code grew more than it. Pinned by (v) and, for the cost, by (s), which now asserts both halves (the re-attempt ran, and it spent no round-trip) so it cannot pass vacuously.
**Finding 2 - the re-open performed an access under the wrong identity, and my "pre-existing" defence was wrong in kind.** The reviewer is right: re-publishing RE-FILTERS an existing query, while a re-open NEWLY ISSUES a subscribe and a public-store fetch for a document that subscriber named. That is a new action taken on behalf of whoever happens to be connected, not an old one resurfacing, and the end state was `{isSuccess: true, data: []}` for a document the subscriber cannot read - this task's own defect, through the repair's door. Fixed by binding the refusal to a holder: `refusedFor` records WHOSE barrier failed, and a re-attempt is made only while that holder is still the connected one. Pinned by (w), which fails without the binding.
@@ -230,16 +230,38 @@ The three consequences, now true by construction:
**Two fixtures carried the old meaning and are corrected with the reason beside them.** This file's fake broker restarted its id counter inside every `makeFake`, so two tests minted the same nuri and aliased each other in the process-global registry - invisible while a reset cleared sync state, immediate once it downgraded it; the counter is now monotonic, which is `wallet-fake`'s own recorded lesson at this file's scale. And the cold-read suite asserted `"unknown"` for a note this page never opened; `"timed-out"` is now what "this page has not confirmed it" says, and the test notes that this particular value is a harness artefact (`reloadPage` resets inside one process, where a browser reload would start with an empty module).
**On the hard stop, and I am answering it rather than waiting to be asked.** I judged this round CONTAINED and did not split: the fix is one invariant in one module with four readers, all enumerated above, and it made the caller smaller rather than larger - no sixth condition was added, one was removed. If a further pass finds this family again I would take the split myself, because the evidence would then be that the registry needs its own reviews rather than that this one needs another patch.
**On the hard stop, and I am answering it rather than waiting to be asked.** I judged this round CONTAINED and did not split: the fix is one invariant in one module with four readers, all enumerated above. If a further pass finds this family again I would take the split myself, because the evidence would then be that the registry needs its own reviews rather than that this one needs another patch.
> **CORRECTED after the sixth pass, and this sentence used to carry two false claims.** It said the fix "made the caller smaller rather than larger - no sixth condition was added, one was removed". Measured: `packages/polyfill/src/surface/use-shape-query.ts` went **664 -> 727 lines** across that commit, and `git show 6b2b8ab --numstat` reports **34 insertions / 8 deletions** on it - `setNotYetAnswered` ADDS a three-term condition rather than removing one. The caller grew. See the note at the end of this Result on why this kept happening.
### ROUND 7 - the task is SPLIT, and that is the outcome
The sixth pass refuted the round-6 repair and found the family we had both pre-committed to splitting on. It is split, and the follow-up is `.tasks/todo/T01.m-one-answer-to-whether-a-document-is-open.md`, written by the coordinator and carrying both proved failures as its acceptance tests. **No registry code was touched in this round**; what follows is the honest accounting.
**What the sixth pass proved, and it falsifies round 6's central claim.** I wrote that `held` is "the ONE record of openness". It is not - there are three, and the one nobody counted is `fanOuts` in `subscribe.ts`. `configure()`, which is PUBLISHED, calls `resetDocSubscriptions()` and clears every fan-out listener including the bootstrap one, without telling `open-repo`; so `held` keeps a dead entry, `syncState` keeps `"synced"`, and the short-circuit skips the re-open for ever - measured as `doc_subscribe calls = 0` and `{isPending: false, isSuccess: true, data: []}`, unrecoverable even through `refetch()`. And `held.set()` runs synchronously before `establish` has reached `doc_subscribe` at all, so a broker rejecting after the bounded fallback still leaves the repo recorded as held. **`held` still means "we asked"** - the exact looser meaning I deleted `opened` for. I replaced one of two disagreeing records; there were three.
**What this task delivers, and every part of it survived six independent reviews:**
- the four call sites - `docs.sparqlQuery`, `ensureRepoOpen`'s propagation, `documentInboxAddress`, and `fetchReadCap` no longer collapsing a rejection into `false`;
- the `fetchReadCaps` deletion;
- `knowledge_what-a-failed-read-answers-upstream`;
- the empty-is-success guards, never once faulted;
- the e2e named-scope coverage, run live by two independent reviewers;
- **the barrier's REACHABILITY half**, kept deliberately: a holder of nothing gets `isError` with `dataLen: 0`, and a public document opens from a bare reference, both verified against a live broker twice. Removing it would restore a silent empty success universally, which is worse than a partial guarantee honestly stated;
- **the barrier's ARRIVAL half, honestly qualified rather than withdrawn.** It holds inside one ordinary session and is pinned by (bb), (t), (ee), (ff) and (cc). It does not hold after a second `configure(...)`, nor when a broker refuses a subscription late.
**The contract is brought down to that.** `## Guarantees` now says the arrival half holds "within one session" and sends the reader to `## Non-guarantees`, where both situations are named with their cause - more than one record of "is this document open" - and pointed at `T01.m`. The same qualification is in the knowledge leaf. **No version bump**: no published behaviour changed in this round, only the accuracy of its description.
**Why no `bug_` leaf for this.** The task's own instruction was not to open one for what it repairs; this is what it does NOT repair, and it is now a task with acceptance tests, which is the stronger record. A leaf would duplicate `T01.m` and drift from it.
### Gate
- `bun test` -> `449 pass / 0 fail`, `Ran 449 tests across 40 files` (baseline `421 pass / 0 fail`, measured before any change; +28 new tests, none deleted or skipped). **Run six times: green every time, 3.21-3.27s.** Two tests were REWRITTEN rather than weakened, each because the design under them changed and the old assertion had become a statement about something that is no longer true: (s) asserted the targeting predicate round 3 proved circular, and now asserts that a re-ask is made for the class it can repair and not for the one it cannot, each half guarding the other against vacuity; (w) asserted that no query is opened under another identity, a rule that left two classes terminal, and now asserts the stronger invariant that replaced it - no snapshot ever claims success for a document the CURRENT holder cannot reach.
- `bun test` -> `449 pass / 0 fail`, `Ran 449 tests across 40 files` (baseline `421 pass / 0 fail`, measured before any change; +28 new tests, none deleted or skipped). **Run six times on the delivered state: green every time, 3.18-3.34s.** Two tests were REWRITTEN rather than weakened, each because the design under them changed and the old assertion had become a statement about something that is no longer true: (s) asserted the targeting predicate round 3 proved circular, and now asserts that a re-ask is made for the class it can repair and not for the one it cannot, each half guarding the other against vacuity; (w) asserted that no query is opened under another identity, a rule that left two classes terminal, and now asserts the stronger invariant that replaced it - no snapshot ever claims success for a document the CURRENT holder cannot reach.
- **Mutation-tested, fifteen ways.** Round 6: removing the reset downgrade kills (ee), the abandoned-open test and the cold-read assertion; ignoring `onSetupFailed` kills the refused-subscribe test; restoring a second record of openness kills the refused-subscribe test; making `publish` return instead of publishing pending kills (ff). Round 5: removing the arrival check kills (bb) and (t); inverting it to refuse `"unknown"` kills (cc); moving the late-`State` recording back below the settled check kills (dd); removing the registry-epoch invalidation kills the new `open-repo` case. Earlier rounds: Removing the raced-edge re-attempt kills (y); removing the publish-time refusal kills (w); removing the snapshot-stability guard kills (aa) and (k); removing the re-attempt entirely kills (r)(s)(v)(w)(z); `Promise.all` in place of `allSettled` kills (x). Round 2's circular gate kills (s)(t)(v) and round 3's holder rule kills (w)(z), so the suite now discriminates against both previously-shipped designs. Removing the abandoned-open settle kills the new `open-repo` test.
- `bunx tsc --noEmit -p packages/polyfill/tsconfig.json` -> exit 0
- `bunx tsc --noEmit -p packages/polyfill/e2e/tsconfig.json` -> exit 0
- `python3 ~/.claude/skills/concept/lint.py` -> 0 ERROR (warnings pre-existing; the new leaf carries none)
- polyfill e2e, run at HEAD: `Polyfill e2e summary: 46 passed, 0 failed, 46 total - batch 3.5 min, slowest cold sync 30s`. **The reviewer ran it independently this round and confirmed both the count and what it does and does not prove.** Earlier in this task every count reported for it was mine alone.** 43 was the count before this task; three named-scope checks were added and a fourth removed as non-discriminating. NOTE: the broker login is flaky and independent of this work - three of thirteen runs in this session died with `[e2e broker login] gave up after 45.0s - the wallet-list screen never changed`, at a different step each time, on the login UI before any library code runs. Every such run was re-run to a clean result.
- polyfill e2e, run at HEAD: `Polyfill e2e summary: 46 passed, 0 failed, 46 total - batch 3.5 min, slowest cold sync 30s`, re-run on the delivered state. **Two reviewers have now run it independently and confirmed both the count and what it does and does not prove.** Earlier in this task every count reported for it was mine alone.** 43 was the count before this task; three named-scope checks were added and a fourth removed as non-discriminating. NOTE: the broker login is flaky and independent of this work - three of thirteen runs in this session died with `[e2e broker login] gave up after 45.0s - the wallet-list screen never changed`, at a different step each time, on the login UI before any library code runs. Every such run was re-run to a clean result.
**What the e2e PROVES**, and it is the whole of it: against the real broker, a named scope reads for the holder who owns the document; a holder of nothing is refused out loud with `dataLen: 0` rather than told the document is empty; and a public document opens for that same holder from nothing but its reference, through the barrier's own fetch. **What it does NOT prove**: anything about the re-attempt. Every recovery path - the public class repaired by re-asking, the cost of a doomed re-attempt, and the holder a re-attempt is made as - is measured only in the unit suite, against the `CapRegistry`, which is where that mechanism lives and which the e2e's own header says the broker does not implement. Driving the positive recovery live needs a second actor delivering through `inbox.share`, and belongs in the applicative suite.
@@ -277,4 +299,11 @@ The three consequences, now true by construction:
- This observable re-PUBLISHES under whoever is connected at that instant rather than under the identity that subscribed. That part is genuinely pre-existing and out of scope - `publish` has always read the keyring of the current holder. The RE-ATTEMPT no longer does: it is bound to the holder that was refused (`refusedFor`), because issuing a subscribe and a fetch is a new access rather than a re-filtering, and the two must not be lumped together - round 2's Result did lump them, and that was wrong.
- `docs/internal-contract.md:7` still names `surface/watch-shape.ts`, `surface/read-model.ts` and a `storeRegistry` namespace listing three removed symbols. Out of this task's boundary.
- `knowledge_what-a-failed-read-answers-upstream` cites `213338f6`, the checkout T01.h found to be three ORM releases behind. The facts it cites are engine-side rather than ORM-side, but T01.k owns reconciling what a citation's version means.
- The in-page session change and the identity-following publication, both listed above, are inside `T01.m`'s territory now rather than loose follow-ups.
### The reporting fault this task had, recorded because it is a pattern and not an oversight
Three times I asserted that a change had simplified or shrunk something, and all three were contradicted by measurement: "the module is smaller than round 2 left it" (534 -> 563), "the caller came out smaller" (664 -> 727), "no sixth condition was added, one was removed" (`--numstat`: 34 insertions, 8 deletions, and `setNotYetAnswered` adds a three-term condition). Two of them were relayed onward before anyone measured them. The common shape: I wrote the claim from the INTENT of the change - a predicate deleted, a record unified - while the file was growing around it, and the sentence read as a fact.
The rule I am holding from here, and it is mechanical rather than a resolution to be careful: **a structural claim - smaller, simpler, a condition removed, a mechanism deleted - carries the `wc -l` or `git show --numstat` that supports it, in the same sentence. If the number cannot be pasted, the claim is not written.** Every structural claim in this Result now carries its measurement.