refactor(api): l'application ne nomme plus son identité — elle l'apprend

Lot C de la revue adverse. Cinq corrections, dont une qui change la forme de la surface.

**L'application ne pouvait pas obtenir son identité par l'API.** `ensureIdentity()`
rendait `void`, `getCurrentUser` n'est plus publié — et pourtant `createEntityDoc(id, …)`
et `listMyEntityDocs(id, …)` l'exigeaient. L'app d'exemple s'en sortait en lisant
`localStorage["ng-eventually:identity"]` et le paramètre `?ng-id`, deux constantes
PRIVÉES du portail d'accès. Une frontière qu'aucun consommateur ne devrait voir, et
encore moins dont il devrait dépendre.

Vérifié au niveau 2 avant de trancher : `session_start(wallet_name, user_id)` prend
l'identité — donc en amont l'application la DÉTIENT, elle la tient du portefeuille
qu'elle a ouvert. Ici c'est le portail qui la choisit, donc c'est au portail de la
rendre. Deux changements, tous deux vers la cible :

- `ensureIdentity()` rend l'identité qu'il a établie ;
- `createEntityDoc(scope)`, `listMyEntityDocs(scope)`, `resolveWriteGraph(scope)`
  perdent leur paramètre d'identité. En amont `doc_create(session_id, …)` ne porte
  aucun utilisateur : une session EST celle d'un utilisateur. Passer la sienne à chaque
  appel de placement était un geste sans successeur.

L'application garde l'identité pour l'afficher, et ne la passe plus à rien.

**`inbox.share` provisionnait un destinataire inexistant.** Une faute de frappe créait
les trois stores et l'inbox de ce nom, et la clé atterrissait où personne ne regarde —
sans la moindre erreur. En amont on ne peut pas viser un nom qu'on invente : un dépôt est
scellé vers une clé d'inbox qui vous est parvenue par un contact entrant. Refuser est
fidèle ; provisionner était l'invention.

**`createEntityDoc` avalait l'échec de ses deux écritures** et rendait quand même une
référence — le document n'était dans aucun store, donc la session suivante ne le listait
pas et sa lecture rendait vide, en silence. Il lève maintenant, comme `doc_create` en
amont propage les siennes.

**Deux entrées prenaient `Nuri` au lieu de `NuriLike`** (`inbox.watch`,
`openDocumentInbox`), ce qui contredisait la raison même pour laquelle aucune garde de
type n'est publiée. Et **deux messages d'erreur nommaient des symboles retirés**
(`storeRegistry.documentInboxAddress`, `setCurrentUser`) : une erreur qui envoie vers une
fonction inexistante est pire qu'une erreur muette.

Contrat d'API et feuille `contract_sdk-surface` mis à jour ; `readForDocument` et le refus
de `share` obtiennent enfin leur règle en §9.

189 tests unitaires, e2e 40/40 et applicatif 12/12.
This commit is contained in:
Sylvain Duchesne
2026-08-10 10:31:15 +02:00
parent b7dc8ca2c3
commit cdc09a1a1d
10 changed files with 193 additions and 91 deletions
@@ -1,24 +1,16 @@
# A consumer's test harness has no way to play several identities
# What a consumer's tests need from the contract, and cannot find in it
Raised by the first consumer (Festipod) on 2026-08-10, while migrating onto `@ng-eventually/sdk` against `contract_sdk-surface` @ `30f6263`. Three findings, one substantive and two defects in the contract's own text.
Raised by the first consumer (Festipod) on 2026-08-10, while migrating onto `@ng-eventually/sdk` against `contract_sdk-surface` @ `30f6263`. Three findings.
## 1. The substantive gap — a consumer harness cannot switch identity
## 1. The contract does not say how an identity comes to be established
`contract_sdk-surface` § *Guarantees* states the whole of signing in: `ensureIdentity()`, which takes no identifier, deliberately. The API contract adds, about `setCurrentUser`'s removal, that *"the e2e harness plays several identities on one page and reaches it by its internal path, which is what a harness is allowed to do and an application is not."*
The first version of this brief asked for a test entry exposing identity switching, on the strength of the API contract's remark that *"the e2e harness plays several identities on one page and reaches it by its internal path, which is what a harness is allowed to do and an application is not"* — true of this library's own harness, and unreachable for a consumer's, since `packages/sdk/package.json` maps exactly one entry and the resolver refuses a deep import (verified: `Cannot find module '@ng-eventually/sdk/src/shared-wallet/access-gate'`).
That sentence holds for **this library's own** harness. It does not hold for a consumer's, and the package makes sure of it: `packages/sdk/package.json` maps exactly one entry, `"." : "./src/index.ts"`. A deep import is refused by the resolver, verified from the consumer's tree:
**That request is withdrawn**, and the reason is worth recording because it is the library's own argument turned around. The consumer decided that its tests take no shortcut through the SDK and validate the application's behaviour rather than the SDK's. Under that rule, "two identities on one page" is not a capability to restore: it is not something a user does, it exists only because one wallet hosts several identities, and a test that used it would be testing the emulation. Multi-user behaviour gets tested the way it is lived — several browser contexts, each signing in as itself. So the surface is right as it stands, and the library should not add a testing entry on this consumer's account.
```
ROOT OK: configure, docChangeType, docs, ensureIdentity, inbox, init, initNg,
ng, readUnion, storeRegistry, subscribeDoc, subscribeDocs, useShape, watchShape
DEEP FAIL: Cannot find module '@ng-eventually/sdk/src/shared-wallet/access-gate'
```
What is genuinely missing is one step lower. `## Guarantees` says `ensureIdentity()` *"is the whole of signing in… it resolves who you are"* — and nowhere does the contract say **how** it resolves it, or what a deployment must arrange so that a given browser context comes up as a given identity. A consumer driving N real sessions has to arrange exactly that, and today it can only learn how by reading the library, which is the one thing the contract exists to prevent. `SharedWalletConfig` is described as *"what a DEPLOYMENT hands out"*, which is the same subject seen from the other side and equally silent on the mechanism.
So a consumer's multi-actor suite has **no path at all** — published or internal — to act as a second identity. The consequence is not cosmetic: a test that cannot obtain a second actor is forced to hand the first actor's values across the identity boundary through a shared variable, which is precisely the shape that hid a real bug in this library once already (a third party never reached the owner's inbox, and the green test proved nothing because the address crossed the boundary by JS scope). Losing the ability to write that test correctly costs more than the surface it saves.
What the consumer needs is narrow: **act as identity X for the duration of a block, then restore**. It is a harness capability, not an application one — the request is not to re-publish `setCurrentUser` on the application surface. A separate, explicitly-named test entry (`@ng-eventually/sdk/testing`, say) would keep the application surface exactly as it is while making the capability reachable; it would also carry its own deletion signal, since a consumer harness that plays several identities on one page is itself pure shared-wallet scaffolding.
Not proposing the shape — this is the library's call. Stating the need, and that it currently has no answer.
This is a documentation gap, not a surface gap: state, in the contract, what determines the identity `ensureIdentity()` resolves to, and which of those inputs a deployment controls. That is enough for a consumer to bring up several genuine sessions without touching anything internal.
## 2. `watchShape`'s published signature contradicts its own types
@@ -35,3 +27,20 @@ export function watchShape(query: ShapeQuery): ShapeObservable;
The inbox surface publishes `readSynced(targetInbox)` and `readForDocument(doc)`, but not their intersection. The consumer's materialization path depends on the **synced** guarantee specifically (`read` and `readSynced` differ by contract), and it addresses by document. Today it must therefore resolve an address itself to get the synced form — which is the exact gesture § *Guarantees* says an application never performs (*"an application never handles a key or an inbox address"*).
Either `readForDocument` carries the synced guarantee, or a `readSyncedForDocument(doc)` completes the pair. As it stands the document-addressed path is strictly weaker than the address-addressed one, and the contract does not say that is intentional.
## 4. Identity was taken out of the application's hands, but three published calls still demand one
This is the finding that actually cost the migration, and it is one incoherence seen from two sides.
`ensureIdentity()` returns `void`, and nothing else answers *"who am I"*`getCurrentUser` was removed on the sound argument that an application knows who it signed in. Under the previous surface that was true: the application named the identity, so it held the value. It no longer names it, and the gate that resolves it hands nothing back. So the premise the removal rested on has quietly stopped holding.
Meanwhile `storeRegistry.createEntityDoc(id, scope)`, `listMyEntityDocs(id, scope)` and `resolveWriteGraph(id, scope)` all take a mandatory `id: string`, and **the contract never says what it is**. Two readings are open and the contract separates them nowhere:
- `id` designates the **identity** — in which case an application that cannot obtain its own identity cannot call any of the three correctly, and a consumer forced to pass a constant merges every user's documents into one collection. Silently: nothing errors, the writes succeed, and isolation is gone.
- `id` designates a **collection key scoped inside the already-connected identity** — the reading `listMy…` suggests — in which case a constant is harmless and the parameter is just unexplained.
The consumer has taken the second reading and routed all six call sites through one documented constant, because the first reading offers it no legal move at all. That is a bet on an unstated semantic, recorded as a bet. **Please rule.**
Whichever way it goes, the pair needs to close: either the three calls stop taking an `id` (the session is the identity, which is what the API contract predicts for the target), or the surface answers *"who am I"* again. Right now it does neither, and the gap is invisible — a consumer that guesses wrong gets working code and broken isolation.
A second, smaller consequence of the same hole: the consumer's `currentUserId` now has to be read out of its own profile document, so it is empty until that read lands, where it used to be available synchronously and invariant. An action taken in that window is silently dropped instead of written.