fix!: la reprise cesse de dependre d-un front, et une ouverture abandonnee se termine
Quatrieme tour. Ce n'est pas un drapeau de plus : c'est la forme qui change. Les tours 2 et 3 construisaient tous deux la reprise ainsi - noter qu'on a refuse, puis reagir a un evenement ulterieur - et c'est la note qui etait fautive les deux fois : une fois conditionnee a quelque chose qui ne pouvait pas devenir vrai, une fois ecrite apres la fenetre ou arrive l'evenement decisif. Un front manque n'est pas rattrapable par construction. Donc : - LA DECISION EST UN NIVEAU. Les documents nommes sont reinterroges a chaque publication, jamais reportes depuis la barriere. Une capacite qui arrive change `isError` en document par le canal qui existait deja, et rien n'etant memorise, il n'y a aucun evenement a manquer. C'est aussi ce qui rend sans objet la question du porteur : une requete ouverte pendant qu'un autre etait connecte ne peut pas revenir a cet abonne en succes auquel il n'a pas droit. - UNE TENTATIVE QUI A COURU CONTRE UN CHANGEMENT S'EN APERCOIT au lieu d'attendre. `open()` echantillonne `CapRegistry.version()` avant la barriere et compare apres son echec ; un ecart signifie qu'elle a decide sur un terrain perime, et elle retente d'elle-meme. Cela termine : une tentative ne se repete que parce que le trousseau a bouge, les mouvements sont finis, et ceux qu'une tentative provoque elle-meme sont les capacites qu'elle depose - au plus une par document nomme. La comparaison de porteur disparait : `holderKey()` vaut "" tant que personne n'est connecte, donc un refus pris avant que l'identite se fixe - la ou les composants se montent - ne correspondait plus jamais ensuite. Un instantane d'echec inchange n'est plus republie : la surface documente sa stabilite, et huit arrivees sans rapport produisaient huit objets porteurs de huit `Error` distinctes pour un etat identique. Enfin, un defaut a moi, trouve en mesurant : le changement rendait la suite intermittente. `resetOpenedRepos()` abandonnait les ouvertures en vol sans les terminer, donc un appelant attendait les 8s du repli sur un abonnement demonte, puis repartait avec son etat de synchro efface - et « unknown » est la lecture LAXE, celle qui accepte qu'une lecture a zero ligne soit une absence averee. Une absence-qui-cache-un-echec latente, de la famille meme de cette tache. Une reinitialisation termine desormais ce qu'elle abandonne, en « timed-out ». Deux fixtures qui se couraient apres sont corrigees, raison ecrite a cote.
This commit is contained in:
@@ -141,11 +141,11 @@ 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 - the repair makes the module smaller than round 2 left 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 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.
|
||||
|
||||
**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.
|
||||
|
||||
**ESTABLISHED rather than asserted, as asked: an application cannot change identity mid-session.** `ensureIdentity()` is the only published call that settles one, and it delegates to `settleIdentity` -> `resolveIdentity`, whose first act is `const already = getCurrentUser()` - so a second call returns the identity already set. `setCurrentUser` is not exported from `src/index.ts`. So Finding 2's sequence is reachable only from a harness playing several identities on one page. It is fixed anyway, because a new access issued in someone else's name is a defect in kind whatever can reach it, and because the fix costs one comparison.
|
||||
**CORRECTED: "an application cannot change identity mid-session" was FALSE.** `ensureIdentity` does return the already-settled identity, which is what I checked - but that is not the only path. `shared-wallet/access-gate.ts:386` calls `adoptCurrentUser(normalized)` when a REVIVED barrier is confirmed, reached through the published `init()`, with a comment saying in as many words that the value may well not be the one that left. I checked one call site and generalised from it. The identity CAN change under a live page, which is the second reason the holder comparison had to go.
|
||||
|
||||
**Finding 3 - the determinism claim was false.** `Promise.all` short-circuits on the first rejection to ARRIVE, and a fetch that rejects does not wait for the sync barrier, so it overtakes a refusal on a document named before it. I chose to fix the BEHAVIOUR rather than the claim: `Promise.allSettled` plus a walk in scope order, so the document reported is the first one the caller named that failed, whatever it failed of, and the parallelism is kept. Pinned by (x), which fails on `Promise.all`.
|
||||
|
||||
@@ -155,13 +155,35 @@ Fix, round 2: the keyring signal RE-OPENS a scope the barrier refused, gated on
|
||||
|
||||
Taken deliberately, at the second offer. Removing it would restore, on a published read, a silent `isSuccess` with `data: []` for a document the caller cannot reach - the defect this task exists for, now proven live rather than argued. The escape hatch's own premise is "a stuck error is worse than a silent empty"; after these three repairs there is no stuck error left to weigh against it. Every class that can refuse is re-attempted on the only readiness signal this library has, and the one class that is not - the identity changed under the observable - cannot arise in an application and is answered honestly rather than by acting in someone else's name. The repairs are also subtractive: a circular predicate deleted, a short-circuit replaced by an ordered walk, a flag given the one extra fact it was missing. What remains is a scope refused in a session whose keyring never moves again, which stays `isError` until `refetch()` - the published meaning of `isError` on this surface, and what it already meant for every other failure it carries.
|
||||
|
||||
### ROUND 4 - refuted again, and the recovery changed SHAPE rather than gaining another flag
|
||||
|
||||
Three defects and two false claims, all reproduced here before anything was touched.
|
||||
|
||||
**Finding 1, blocking: the recovery was EDGE-TRIGGERED, so the edge that caused a refusal was the one it could not see.** `refusedFor` was only set after the async barrier, so an `onChange` firing WHILE the barrier crossed found no query and no record and did nothing - and that same change is what made the walk refuse. Reproduced as (y): inert at subscribe, so the barrier skips the public-store ask entirely; the connect burst files the holder's first cap mid-barrier; the walk then refuses; no keyring event is left to come. `refetch()` answers at once, so the error was for nothing.
|
||||
|
||||
**This is the shape that had to change, not the flag.** Rounds 2 and 3 both built recovery as "record that we refused, then react to a later event", and both records were the fault - one gated on a condition that could not become true, one written after the window in which the deciding event arrives. So the recovery stopped depending on edges:
|
||||
|
||||
- **the decision is a LEVEL.** Which named documents the holder may reach is re-read on every publication ({@link publish} calls `namedRefusal()`), never carried from the barrier. A cap arriving turns `isError` into the document through the channel that already existed, and nothing is remembered, so there is no event to miss. This is also what makes the holder question moot: a query opened while somebody else was connected cannot come back to this subscriber as a success it is not entitled to, so the comparison that Finding 2 breaks is not needed at all.
|
||||
- **an attempt that RACED a change notices, instead of waiting.** `open()` samples `CapRegistry.version()` synchronously before the barrier and compares after it fails; a mismatch means it decided on stale ground, so it makes another attempt itself. It terminates: an attempt repeats only because the keyring moved, moves are finite, and the moves an attempt causes itself are the caps it files - at most one per named document, after which that document is held and asks nothing (`file()` does not notify when the value is unchanged, and `markInPublicStore` does not notify at all - both checked).
|
||||
|
||||
`CapRegistry.version()` is the one new primitive: a counter bumped where `notify` is, with a note saying why a listener alone is not enough.
|
||||
|
||||
**Finding 2, major: the holder comparison broke the ordinary sign-in.** `holderKey()` is `""` while nobody is connected, so a refusal taken before the identity settles - which is where components mount - never matched again once it did. Reproduced as (z). The comparison is GONE, replaced by the publish-time check above.
|
||||
|
||||
**Finding 3: "costs one snapshot" held only for the absence class.** Over a FAILED fetch, each unrelated arrival re-asks the broker - that is the repair for that class and it stays - but it also published a fresh snapshot carrying a fresh `Error` for an unchanged state, which contradicts the stability this surface documents. FIXED rather than documented away: `setUnknown` compares by message and keeps the standing snapshot, so subscribers see no update while nothing has changed. Reproduced and pinned by (aa), which asserts snapshot IDENTITY.
|
||||
|
||||
**A defect of my own, found by measuring rather than by review.** The round-4 change made the unit suite intermittently fail - two 5s timeouts in `cold-read-for-document`, about one run in four, where round 3 was stable 4/4. Traced by instrumenting the open registry rather than guessed: `resetOpenedRepos()` abandons in-flight opens WITHOUT settling them, so a caller awaiting one waits out the full 8s fallback on a subscription that has been torn down, then proceeds with its sync state cleared - and `"unknown"` is the LAX reading (`index-deposit.noRowsMeansAbsent` accepts it as a definitive absence). A latent failure-as-absence bug in this task's own family, which the re-attempts made fire. Fixed: a reset now ENDS the opens it abandons and marks them `"timed-out"`, the reading that refuses to call nothing an absence. Pinned by a new test in `open-repo.test.ts`; six full-suite runs are green at the usual 2.4s.
|
||||
|
||||
Two fixtures were also racing themselves and are corrected, with the reason written beside them: `use-shape-query.test.ts`'s fake minted into `did:ng:o:doc<n>`, the same namespace as `wallet-fake.ts`, so two fakes aliased each other's repos in the process-global open registry - the lesson `wallet-fake` already carries at its own scale, one scope wider; and the INERT test asserted `isEnforcing() === false` and then subscribed, while signing in arms the registry a tick later, so it was pinning the schedule. It now runs with nobody signed in, which is inert for the whole test.
|
||||
|
||||
### Gate
|
||||
|
||||
- `bun test` -> `438 pass / 0 fail`, `Ran 438 tests across 40 files` (baseline `421 pass / 0 fail`, measured before any change; +17 new tests, no test deleted or skipped - (s) was REWRITTEN, not weakened: it asserted the targeting predicate that round 3 proved circular, and now asserts the property that is actually true and worth guarding, on both halves so it cannot pass vacuously)
|
||||
- `bun test` -> `442 pass / 0 fail`, `Ran 442 tests across 40 files` (baseline `421 pass / 0 fail`, measured before any change; +21 new tests, none deleted or skipped). **Run six times: green every time, 2.25-2.53s.** 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, five ways.** 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 29s`. 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 eleven 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 29s`. 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 twelve 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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user