docs: deux concepts pour ce que la journée a appris

sign-in — comment un utilisateur passe de rien à une identité qui agit. La
barrière, l'identifiant qui franchit une frontière de partition par l'URL, et
surtout : la redirection vers le broker appartient à @ng-org/web, vérifié dans
son bundle. Le polyfill ne la réimplémente pas ; ce qui lui revient est la seule
chose qu'init() ne peut pas faire, écrire l'identifiant dans l'URL avant qu'il
ne la lise.

La feuille centrale dit pourquoi régler l'identité et se connecter sont deux
actes séparés : l'un ne demande aucune session, l'autre en exige une, et les
confondre bloque dans un sens et casse le partage en silence dans l'autre. Le
piège encore vivant — une connexion abandonnée qui reste joignable — est
consigné comme tel, non corrigé.

e2e-harness — ce que chaque suite juge, et deux choses qu'un agent doit savoir
avant de diagnostiquer : le raccourci qui pré-injectait l'identifiant dans l'URL
a tenu deux défauts invisibles pendant des mois (un lien de téléchargement vers
un 404 que rien ne servait, et une barrière inatteignable pour tout nouvel
arrivant) ; et un tuyau devtools qui lâche tue une exécution sans que Playwright
n'émette d'événement, ce qui ressemble à un défaut produit et n'en est pas.

Le vocabulaire gagne settle, barrier, journey et batch. J'avais aussi introduit
« hand-over » pour la redirection : retiré, c'est le mot de NextGraph qui
l'emporte.
This commit is contained in:
Sylvain Duchesne
2026-08-11 19:24:00 +02:00
parent 16e24f67f9
commit 7c2e8d8f1f
10 changed files with 234 additions and 10 deletions
-7
View File
@@ -1,7 +0,0 @@
# Doc-debt — app-contract
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED packages/polyfill/README.md @2026-08-11 (session f93872b5-293a-4916-a353-181409a96d42)
@@ -0,0 +1,27 @@
---
type: overview
summary: What each end-to-end suite judges, the shortcut that kept two defects invisible, and the failure mode that is not ours to fix
triggers:
keywords: [e2e, harness, playwright, chromium, journey, suite, profile, broker page, notebook, wallet profile]
paths:
- "packages/polyfill/e2e/**"
vocabulary:
- term: journey
gloss: one named end-to-end story in the applicative suite, reported pass or fail as a whole
not: [scenario, test case, spec]
- term: batch
gloss: one run of a suite, which mints its own physical user and discards the previous one
not: [session, campaign]
---
# e2e-harness — the suites, and what they can and cannot tell you
Three suites drive real browsers against the real broker and the real wallet application. They are the only place the emulation meets what it emulates, so a green unit suite says nothing about them.
They are also expensive and, on a machine whose network moves, unreliable in ways that look exactly like product defects. Most of what this concept holds exists so an agent does not spend hours diagnosing a product bug that is a transport failure — or, worse, trust a green run that measured nothing.
## Read first
- `knowledge_what-each-suite-judges` — which suite answers which question.
- `rule_never-shortcut-the-sign-in` — the shortcut that hid a 404 and a regression at once.
- `caveat_a-dropped-pipe-kills-a-run` — the failure mode that is not ours.
@@ -0,0 +1,23 @@
---
type: caveat
summary: Chromium's control pipe drops without Playwright emitting close or disconnected, so a run dies mid-flight — it looks like a product defect and is not one
last_checked: 2026-08-11
---
# A run can die from a dropped pipe, and it is not the product
Chromium's devtools pipe sometimes drops mid-run. It logs a terminated-pipe message and exits cleanly, and **Playwright emits neither `close` nor `disconnected`** — observed four times out of four. From the client's side the browser simply stops answering.
Before waits were bounded this was fatal in a specific way: the suite blocked in its own teardown, so it printed **neither its summary nor the failure already on its way out**. Hours went into diagnosing silence. Every wait is now bounded and names what it was waiting for, so a lost browser costs seconds and a report.
**How to recognize it.** The run dies without a coherent failure, or several unrelated interactions time out at once, or the summary is missing entirely. If the suite reports a named deadline on a browser operation rather than a failed assertion, suspect the transport before suspecting the code.
**It is not ours to fix.** It is not caused by how a child process is spawned, nor by a leftover holding the profile, nor by overlapping launches — all three were probed and ruled out. It looks like Playwright losing its file descriptors without telling its client. Worth reporting upstream.
## The host can be the cause too
A machine that reconfigures its network — a container in a crash-restart loop cycling its virtual interface, for instance — makes the applicative suite unmeasurable. The browser answers with a network-changed error, broker sockets fail, and every failure looks like a different product bug.
This has happened here: seven failures out of ten runs, all transport, none product. The check costs seconds — watch for repeated link events, and look for a container restarting.
**The discipline that follows:** a suite that fails for transport reasons has measured nothing. Do not read it as a red baseline, do not chase it as a regression, and do not commit against it. Restore a stable environment first, then measure.
@@ -0,0 +1,28 @@
---
type: knowledge
summary: Which suite answers which question, what a batch costs, and why two runs must never overlap
---
# What each suite judges
**The polyfill suite** exercises the published surface against the real broker: capabilities, reads, inboxes, reactivity. It is the one that judges whether the emulation behaves like the target.
**The applicative suite** drives the example application through a browser, as a person would — several identities, several pages, assertions on what is on screen rather than on what the library returns. It judges whether an application built on this package actually works, including the sign-in a person really performs.
**The reactivity suite** isolates document subscription.
A unit suite cannot replace any of them, and none of them replaces the unit suite: they are slow, they depend on live external services, and they cannot enumerate a case space.
## What a batch costs, and why
Every `batch` mints its own physical user by driving the wallet application's real interface, then discards the previous one. That is deliberate — identities must not leak between runs — and it puts a floor under every run that no test filter can remove.
Consequences worth knowing before optimizing anything: the setup runs before any `journey`, so a filter saves journey time only; and the wallet profile is a single directory shared by every suite.
## Two runs must never overlap
Because the profile is shared and each `batch` discards what it finds, a second run started while a first is alive destroys the first — which then fails in a way that reads as a product defect. Several measurements were lost to this before it was made structural.
It is now enforced by a lock rather than by discipline, and a browser left behind by a killed run is reclaimed. If you ever find yourself reasoning about "was another run going?", check the lock rather than your memory.
The related trap that made it self-perpetuating: discarding the profile was once conditioned on a marker written at the *end* of a batch, so a run killed before writing it left a profile the next run happily reused — and inherited its breakage. Discarding now keys on the profile itself.
@@ -0,0 +1,24 @@
---
type: rule
summary: An end-to-end journey must reach its starting state through the system, never by pre-seeding what the sign-in would have produced — the shortcut keeps whole features untested while staying green
---
# Never hand a journey the state the sign-in would have produced
**Rule:** An end-to-end `journey` must arrive at its starting state **through the system**. Never pre-seed the identifier in the URL, never pre-import a wallet into the profile under test, never set the acting identity directly. If reaching a state through the system is awkward, that awkwardness is the finding — not a reason to skip it.
**Why.** Every applicative `journey` used to append the identifier to the application URL before loading it. The identity then resolved from the URL, the `barrier` never appeared, and the suite was green for months. It was green over:
- a download link pointing at a file **nobody served** — the barrier handed out a 404, and no test had ever clicked it. Worse, the test server answered every unknown path with the application's own HTML, so a missing file could not fail even in principle;
- an ordering defect that made the barrier unreachable for any first-time user — the application handed the page to the broker before the barrier could appear, leaving a newcomer at a login page with no wallet and no way to get one;
- and, later, a regression in sharing.
The first journey that actually walked a newcomer's path — empty profile, no wallet, real download, real import — found all of it immediately. The coverage hole and the defects protected each other: the shortcut existed because the real path was tedious, and the real path stayed broken because nothing walked it.
**How to apply.** The tell is mechanical: a `journey` that *writes* a value the system is supposed to produce. Ask *"by which call would a real person obtain this?"* — no answer means the feature is incomplete, not that the test needs help.
This is not a blanket ban on fixtures. Shared setup — building the application, minting the `batch` user, serving files — is legitimate and belongs before the journeys. What is banned is seeding **the specific state under test**: a journey about sign-in may not start signed in.
Journeys that legitimately need an already-signed-in actor may use the shared sign-in helper. That is exactly why at least one journey must not: without it, the helper's own path is the untested one.
Read alongside the global rules on covering the case space and on each actor obtaining its inputs through the system — same failure shape, and it has now produced real defects here twice.
+32
View File
@@ -0,0 +1,32 @@
---
type: overview
summary: How a person gets from holding nothing to acting as an identity — the barrier, the broker hand-over, and the split between settling and connecting
triggers:
keywords: [sign-in, signin, identity, ensureIdentity, settleIdentity, barrier, gate, wallet, broker, redirect, ng-id, currentUser, session]
paths:
- "packages/polyfill/src/shared-wallet/**"
- "packages/polyfill/src/surface/lifecycle.ts"
- "packages/polyfill/src/emulated-verifier/connect.ts"
vocabulary:
- term: settle
gloss: decide which identity is acting, from the URL, storage, or the barrier — needs no session
not: [resolve identity, determine user, login]
see: knowledge_settling-is-not-connecting
- term: barrier
gloss: the full-screen overlay this package mounts to hand out the wallet and take an identifier
not: [modal, popup, login screen]
---
# sign-in — getting a person from nothing to acting
This concept covers everything between "someone opens the application" and "an identity is acting with a live session": the `barrier` this package mounts, the wallet a newcomer must obtain, the broker redirect, and the two distinct acts — `settle` and connect — that were once conflated and must stay apart.
None of it is app-facing. An application awaits one call and renders; it must never learn that a broker, an iframe or a redirect exist. What it may rely on lives in `app-contract`; what follows is how that promise is kept.
The whole thing is scaffolding: upstream, a person opens **their** wallet, it contains **their** site, and `session_start` takes an id that came from the wallet. There is nothing to name and nothing to choose. This concept exists because one shared wallet hosts several identities.
## Read first
- `knowledge_how-a-user-gets-in` — the flow end to end, and which layer owns each step.
- `knowledge_settling-is-not-connecting` — the split, and the cycle that forces it.
- `caveat_connect-memoizes-an-abandoned-run` — a live trap, unfixed.
@@ -0,0 +1,19 @@
---
type: caveat
summary: A connection attempt that finds no account returns silently and leaves its abandoned run joinable, so a later legitimate attempt does nothing
last_checked: 2026-08-11
---
# A connection run that gives up stays joinable
In `emulated-verifier/connect.ts`, a connection attempt that finds no account for the acting identity **returns silently** — no restore, no drain — and it has already registered itself as the in-flight run for that identity. Any concurrent caller joins that run and resolves having done nothing.
That is exactly how sharing broke once (see `knowledge_settling-is-not-connecting`): the trigger was fixed, this mechanism was not.
**It is still live.** Any future caller that starts connecting before its session can answer will reproduce the same silence: work skipped, promise resolved, no error anywhere. The trap is documented on the setter that fires the connection, but a comment is not a mechanism — nothing prevents it.
Two things make it nasty: giving up is indistinguishable from succeeding at the call site, and the memoization spreads the damage to callers that did nothing wrong.
To validate the state of this: look at what the connection routine does when the account lookup answers nothing, and at whether the in-flight registration happens before or after that decision.
Fixing it properly means deciding what an attempt that cannot answer should be — a failure, a retry, or something never memoized — and that decision has its own blast radius, which is why it was deliberately left out of the fix that closed the symptom.
@@ -0,0 +1,33 @@
---
type: knowledge
summary: The sign-in flow end to end, and which layer owns each step — in particular that the broker redirect belongs to @ng-org/web, not to this package
---
# How a user gets in
## The steps, and who owns them
A newcomer holds nothing. To act they need a wallet imported once into the wallet application, and an identifier naming their virtual space. The `barrier` hands out the first three — the wallet file, its password, a link to the wallet application — and takes the fourth.
Then the page is handed to the broker, which opens the wallet and reloads the application **inside an iframe it hosts**. Outside that iframe there is no session.
**The redirect is not ours.** `@ng-org/web`'s `init()` performs it as its first statement — same host, same URL shape, same frame check. Verified in its shipped bundle: when `window.self === window.top` it assigns `window.location.href` from a hard-coded `redirect` constant. This package must not reimplement it; doing so would double a function of the layer below and diverge from it in the meantime (see `app-contract/rule_no-divergence-from-nextgraph`).
What is genuinely ours is the one thing `init()` cannot do: **put the identifier into the URL before `init()` reads it**. `init()` hands over `window.location.href`; it does not know the parameter exists.
## Why the identifier travels by URL
The flow runs in **two contexts with separate storage partitions** — the top-level page and the broker iframe — because browsers partition storage by top-level site. A value written top-level is not the value the iframe reads.
What crosses is the URL: the redirect embeds the full application URL, query included, and reloads it in the iframe. Hence the resolution order, which must not be "simplified":
1. the identifier in the URL — it wins whenever present, being the only thing that crosses;
2. otherwise storage — same-partition convenience, and prefill on reload.
The parameter must therefore be written on **every** path that settles an identity, not only when the `barrier` asked. An identity coming from storage without the parameter being written is the silent failure: the iframe reads an empty identity, provisions a second virtual space, and the returning user lands somewhere empty with no error.
Writing to storage and writing to the address bar are two independent attempts, deliberately: a storage that refuses writes must not cost the round-trip its parameter.
## What survives migration
The `barrier` evaporates whole — handing out a shared wallet and naming a space exist only because the wallet is shared. The redirect does **not**: upstream's real multi-wallet flow has the same shape, and the barrier becomes the real per-user sign-in. The call site survives too, which is why the published call takes no identifier.
@@ -0,0 +1,37 @@
---
type: knowledge
summary: Deciding which identity acts needs no session; connecting does — conflating them deadlocks one way and silently breaks sharing the other
---
# Settling is not connecting
Two acts of different nature hide behind "sign in":
- **`settle`** — decide which identity is acting, from the URL, from storage, or by asking at the `barrier`, then persist it. Pure DOM and storage. **No session required.**
- **connect** — restore what was shared with that identity and drain its inbox. **Requires a live session.**
They must stay apart, and the reason is not tidiness.
## Why they cannot be one call
`init()` hands the page over synchronously when the page is top-level, so nothing placed after it runs. The `barrier` must therefore appear **before** `init()`.
But an identity cannot connect before `init()` either: connecting reaches the application's session thunk, which only `init()`'s own callback resolves. Await the whole of sign-in before `init()` and it deadlocks; call it after and the `barrier` never appears.
The way out is that only *half* of it has that dependency. Settling is awaited by this package's `init()` wrapper before it delegates; connecting stays in the published call, awaited where a session exists.
**The ordering invariant is carried by the composition, not by a documented call order** — which is why an application must call this package's `init`, not the one it injected. A note telling callers to order two calls correctly is not a mechanism.
## The failure this cost
Settling once reached the session, because recording who is acting also fired the connection. In the reference application `init()` is called *from inside the executor that builds the session promise*, so the thunk could not answer by construction. It threw, the account lookup answered null, and the connection run abandoned **without restoring or draining** — having already registered itself as in flight. The published call then joined that dead run and resolved having done nothing.
Symptom: a document shared with someone did not open for them. No error, just unreadable content.
Nothing had changed in the connection logic. What changed was *when* the identity was recorded. Before the split, nothing recorded an identity during module evaluation: a session existed, the run was healthy, and joining it was harmless.
Hence the shape of the fix — recording who acts and starting to connect are separate operations, and the session-free half only records. To validate: the session thunk must never be called while settling.
## The lesson worth keeping
A "session-free" half that calls something which fires a session lookup is not session-free. When splitting on a dependency, check what the *side effects* of each remaining call reach, not only what the call itself does.