Pour démarrer, une application devait écrire une promesse autour du callback
d'init(), attraper l'événement loggedin, puis fournir un thunk getSession qui
dépiaute session_id et les trois identifiants de store dans notre forme. Plus un
normalizeId. C'est précisément la plomberie que ce paquet existe pour absorber :
chaque application la réécrirait à l'identique, et c'est elle qui a produit deux
défauts aujourd'hui — un blocage et un partage cassé en silence.
En amont, une session est RENDUE ; une application n'en assemble jamais une à
partir de champs bruts. Et les identités virtuelles sont une invention du
polyfill, donc leur normalisation lui appartient.
Le wrapper init() enveloppe désormais le callback de l'appelant : il capture
l'événement, en dérive la session, puis appelle le callback avec le même
événement. Le paquet n'appelle jamais init de sa propre initiative — il
l'enveloppe. Sans callback, il capture quand même.
getSession et normalizeId quittent la surface publiée. Le chemin d'injection
reste pour les harnais, mais inatteignable depuis l'entrée : vérifié par un
import à l'exécution et par un configure() refusé à la compilation.
Défaut trouvé et corrigé en route : le broker envoie session_id en NOMBRE, et le
convertir en chaîne faisait refuser tous les appels par le binding wasm. La
valeur ne fait que transiter, elle est relayée telle quelle. Reste que toute la
chaîne la type string — inexactitude antérieure à ce commit, à traiter à part.
Une application écrit maintenant : configure({ ng, useShape, init, sharedWallet }).
11 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.
A larger claim was drafted here and is retracted before it could mislead. It said the contract offers no way to determine which identity comes up, and that a consumer therefore has no reproducible test isolation. That is wrong: EventuallyConfig publishes both sharedWallet and currentUser, so bootstrap is exactly where a caller says which wallet to open and, when it must, which identity to come up as. The consumer had simply not wired those two fields yet — its own migration was incomplete, and the library's error message said so precisely ("no shared wallet configured. Pass sharedWallet to configure()"). A good error message did the work the brief was trying to do.
What remains is small and is prose, not surface. ## Guarantees describes ensureIdentity() as "the whole of signing in… it resolves who you are" without ever connecting that sentence to the two EventuallyConfig fields that determine the answer. The ## Surface block carries them as bare lines in a config interface, where nothing marks them as the inputs to the one call a consumer must get right before anything renders. Naming that link — these fields are what ensureIdentity() resolves from — would have saved a wrong diagnosis here, and it is one sentence.
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 were open. The library has now answered, by refusing — and the answer is measured, not argued.
The consumer bet on the harmless reading (id = a collection key inside the already-connected identity, which listMy… suggests) and routed all its call sites through one constant, because the other reading offered it no legal move at all. Running the broker-backed suite settles it in one line. Each scenario opens as its own identity — verified, the access log shows test-…-2, -3, -4, -5, -6, -7 — and every one of them fails on the same document:
[test-…-2][polyfill] createEntityDoc cap append failed:
[ng-eventually] createEntityDoc:addRepo: refused — the connected user does not hold
this document's cap. Naming a document does not grant access to it.
"did:ng:o:TFYUlJQnOkYUlC4T9XtGOBoz70opW_IO21VJ_ouF_Y0A:v:…"
The first identity creates that document and owns it; every later identity names the same one and is refused. So id is per-identity, and a constant collapses every user onto one owner's document.
Two things follow, and the second is the blocker.
The good news, worth saying plainly: the guarantee held. The failure mode feared here was a silent merge — every user's documents in one collection, no error, isolation quietly gone. That is not what happened. "Writing is ownership" refused loudly and named the reason, and a consumer's wrong guess became a red test instead of a data leak. This is the surface doing exactly what it promises.
The blocker: the three calls require the connected identity, and no published call returns it. ensureIdentity() resolves it and returns void; getCurrentUser was removed on the argument that an application knows who it signed in — which was true while the application named the identity and stopped being true the moment it no longer does. So the consumer must pass a value it has no way to obtain. It currently works only where a deployment plants one (its test environment, through configure({ currentUser })); a real deployment plants nothing, and there the application is stuck.
The pair has to close, either way round: the three calls stop taking an id (the session IS the identity — what the API contract already predicts for the target), or the surface answers "who am I" again. Right now it does neither, and this is no longer a documentation nicety — it is the one thing standing between a consumer and a working multi-user deployment.
6. The barrier is the library's, and nothing can test that a first-time device gets in
The consumer has now deleted its own access screen and relies on the barrier ensureIdentity() shows — which is right, and is what the library asked for when it absorbed ~300 lines of gate from this same application.
Its one end-to-end check of a first-time device — a real browser with an empty profile, offered the wallet file and its password, importing it, and coming back signed in — drove that deleted screen's own DOM, so it went with the screen. The consumer still needs that coverage: a first-time device getting in is the single most consequential path a real user takes, and it is currently covered by nothing.
The consumer is not asking for testids, and is rebuilding the check on its own side. An end-to-end test should interact the way a person does — visible text and roles — rather than reach for identifiers planted inside someone else's component, so driving your barrier that way needs nothing published and is the more honest test besides.
One question, because it decides whether that is possible at all: is the barrier reachable by ordinary browser automation? A screen rendered inside a shadow root, or in its own iframe, is invisible to a locator that searches the light DOM, and no amount of "test it like a user" gets past that. If it is isolated, then either the isolation needs a documented way through for automation, or the flow is yours to cover and the contract should say so plainly — because right now each side can reasonably assume the other has it.
Unrelated signature seen in the same run, reported without diagnosis (78 occurrences, none fatal — the scenarios fail on the cap refusal above, not on this): call_sdk Deserialization error of config Error: invalid type: JsValue(Function), expected any valid JSON value. It appears only once sharedWallet is configured and ensureIdentity() actually opens a wallet — it was absent from every earlier run. The consumer passes sharedWallet as three plain strings, per ## Surface, so whatever crosses that boundary as a function does not come from its call site.
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.
5. "Permissive in" is stated as a guarantee but is not uniform
## Guarantees opens with "Every entry accepts NuriLike and validates at the door", and makes a point of it: a value read from storage, a URL or a form goes straight in, no guard to call, no cast to write. The signature block does not honour it uniformly — storeRegistry.openDocumentInbox(doc: Nuri) takes the precise type where its own deposit-side counterpart inbox.readForDocument(doc: NuriLike) takes the permissive one. Verified by the consumer's typechecker, not read from your source.
Either the guarantee is narrower than stated (say which entries it covers), or openDocumentInbox should widen. As written, a consumer that trusts the sentence gets a type error at exactly one call site and has no way to tell whether that is the rule or the exception.