Files
ng-eventually/docs/briefs/2026-08-10-consumer-harness-identity-switching.md
T
Sylvain Duchesne cdc09a1a1d 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.
2026-08-10 10:31:15 +02:00

6.0 KiB

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.

1. The contract does not say how an identity comes to be established

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 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.

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.

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

## Surface publishes:

export function watchShape(query: ShapeQuery): ShapeObservable;

ShapeQuery is the result type ({ data, isPending, isSuccess, isError, error }, per docs/api-contract.md § 5), so as written the call takes its own return value. The signature the consumer has always called, and the one § 5 documents, is watchShape<T>(shapeType, scope) — two positional arguments. One of the two documents is wrong; the contract is the one consumers read.

3. There is no synced read for the per-document form

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.