diff --git a/docs/briefs/2026-08-10-consumer-harness-identity-switching.md b/docs/briefs/2026-08-10-consumer-harness-identity-switching.md new file mode 100644 index 0000000..a7226b8 --- /dev/null +++ b/docs/briefs/2026-08-10-consumer-harness-identity-switching.md @@ -0,0 +1,37 @@ +# A consumer's test harness has no way to play several identities + +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. + +## 1. The substantive gap — a consumer harness cannot switch identity + +`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."* + +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: + +``` +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' +``` + +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. + +## 2. `watchShape`'s published signature contradicts its own types + +`## Surface` publishes: + +```ts +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(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. diff --git a/packages/sdk/e2e/notebook.ts b/packages/sdk/e2e/notebook.ts index 95e4bb3..5452a28 100644 --- a/packages/sdk/e2e/notebook.ts +++ b/packages/sdk/e2e/notebook.ts @@ -126,21 +126,32 @@ async function signIn(ctx: BrowserContext, appUrl: string, id: string): Promise< * that has not re-rendered is green whether isolation holds or not — found adversarially, * 2026-08-10. */ -async function showScope(a: Actor, scope: string, settle = "les notes"): Promise { +async function showScope(a: Actor, scope: string, settle: string): Promise { await a.frame.locator('[data-testid="scope"]').selectOption(scope); // The list is rebuilt wholesale; waiting for the marker the caller expects (or for the // list to be empty) is the only signal the application offers. + // + // This wait is NOT a synchronisation point when the marker is ALREADY on screen — it + // matches on the first poll and returns before the in-flight `refresh()` has done its + // broker round-trips. A check reading the list right after is then reading the previous + // render. Where a journey needs a FRESH list, it must create its own synchronisation + // point (a write it awaits), not lean on this. Found adversarially, 2026-08-10. + // + // No `.catch` swallowing the timeout either: a list that never settles is a failure to + // see, not a degradation to absorb — swallowing it reinstated the very bug this wait + // was added to fix. await a.frame .locator(`[data-testid="notes"]:has-text("${settle}"), [data-testid="notes"]:empty`) .first() - .waitFor({ timeout: 60000 }) - .catch(() => {}); + .waitFor({ timeout: 60000 }); } async function writeNote(a: Actor, scope: string, title: string, body: string): Promise { await a.frame.locator('[data-testid="title"]').fill(title); await a.frame.locator('[data-testid="body"]').fill(body); - await showScope(a, scope); + // No settle marker to wait for here: the write below is its own synchronisation point, + // and the shelf we are switching to may legitimately be empty or hold anything. + await a.frame.locator('[data-testid="scope"]').selectOption(scope); await a.frame.locator('[data-testid="write"]').click(); await a.frame.locator(`li:has-text("${title}")`).waitFor({ timeout: 60000 }); } @@ -279,11 +290,14 @@ async function main(): Promise { await showScope(bob, "public", "Vélo"); const bobList = (await bob.frame.locator('[data-testid="notes"]').textContent()) ?? ""; - await showScope(alice, "public", "Courses"); - await alice.frame.locator('li:has-text("Courses")').waitFor({ timeout: 60000 }); + // Alice's list has to be re-rendered AFTER Bob's note exists, or "she does not see + // it" is read off a stale snapshot and holds whatever the boundary does. Writing a + // note is the synchronisation point the application offers: `writeNote` awaits the + // new entry appearing, so what follows is a render that post-dates Bob's. + await writeNote(alice, "public", "Timbres", "en acheter un carnet"); const aliceList = (await alice.frame.locator('[data-testid="notes"]').textContent()) ?? ""; - check("Alice sees her own note", aliceList.includes("Courses"), aliceList.slice(0, 60)); + check("Alice sees her own notes", aliceList.includes("Courses") && aliceList.includes("Timbres"), aliceList.slice(0, 60)); check("Bob sees HIS own note — the control that lets the next check fail", bobList.includes("Vélo"), bobList.slice(0, 60)); check("Bob's list does not contain Alice's note", !bobList.includes("Courses"), bobList.slice(0, 60)); check("Alice's list does not contain Bob's note", !aliceList.includes("Vélo"), aliceList.slice(0, 60)); diff --git a/packages/sdk/src/emulated-verifier/caps.ts b/packages/sdk/src/emulated-verifier/caps.ts index cd9da33..d7bf6c8 100644 --- a/packages/sdk/src/emulated-verifier/caps.ts +++ b/packages/sdk/src/emulated-verifier/caps.ts @@ -133,9 +133,33 @@ export class CapRegistry { // --- what the holder holds ---------------------------------------------- + /** + * The key of the holder currently connected — capture it when you DECIDE that a cap is + * someone's, and hand it back to {@link learnFor} when you file. + * + * **A hazard closed, not a leak observed** — the distinction matters and I got it wrong + * once while writing this. Filing resolves the holder at the moment it runs, and three + * paths file several `await`s after the check that authorised them (connecting, reading + * an inbox, listing one's own documents). So an application switching identity in the + * gap COULD have the first identity's caps filed into the second one's ring. That is + * structural and visible by reading. What was NOT established is that it happens: the + * reproduction that seemed to show it turned out to be a broken test fake, and once the + * fake was corrected the leak did not reproduce. + * + * The pairing stays because it costs one argument and removes the hazard by + * construction, where a re-check at each of three sites is a discipline. It is not + * evidence of a bug that was found. + */ + holderKey(): string { + return this.holder() ?? ANONYMOUS; + } + /** What the current holder holds, created on first use. */ private heldCaps(): Map { - const key = this.holder() ?? ANONYMOUS; + return this.ringFor(this.holderKey()); + } + + private ringFor(key: string): Map { let ring = this.heldByHolder.get(key); if (!ring) this.heldByHolder.set(key, (ring = new Map())); return ring; @@ -155,7 +179,7 @@ export class CapRegistry { * * Returns whether the cap was new. */ - private file(cap: ReadCap): boolean { + private file(cap: ReadCap, key: string = this.holderKey()): boolean { if (!hasReadCap(cap)) { throw new Error( "[ng-eventually] caps: expected a ReadCap (a NURI carrying `:r:`), got a bare " + @@ -163,7 +187,7 @@ export class CapRegistry { ); } const target = targetOf(cap); - const ring = this.heldCaps(); + const ring = this.ringFor(key); if (ring.get(target) === cap) return false; ring.set(target, cap); this.issued = true; @@ -206,6 +230,14 @@ export class CapRegistry { this.file(cap); } + /** + * File a cap for a NAMED holder — the one the caller decided for, not whoever happens + * to be connected when the `await` resumes. See {@link holderKey}. + */ + learnFor(key: string, cap: ReadCap): void { + this.file(cap, key); + } + /** * File a cap a PUBLIC STORE served me — `emulated-verifier/public-store.ts`, the * emulated *"downloaded from the outerOverlay"*. Held like any other cap, so reading diff --git a/packages/sdk/src/emulated-verifier/connect.ts b/packages/sdk/src/emulated-verifier/connect.ts index daf1e84..1ec4ba1 100644 --- a/packages/sdk/src/emulated-verifier/connect.ts +++ b/packages/sdk/src/emulated-verifier/connect.ts @@ -56,6 +56,24 @@ export async function connectedUser(): Promise { const pending = inFlight.get(holder); if (pending) return pending; + /** + * Is `holder` still the connected identity? + * + * This work is fired un-awaited by `setCurrentUser`, and everything below resolves the + * CURRENT holder when it reads a register — `readLinks` and `myInboxes` both ask + * `getCurrentUser()` at the moment they run. After a switch they would therefore read + * the WRONG user's registers. + * + * The observed symptom was narrower and entirely in the tests: in-flight work from one + * test file armed the cap emulation in the next, making the suite's green depend on + * file order. Abandoning is right for both reasons, and it is what upstream implies — + * a session belongs to one user, and switching user is another session. Nothing is + * lost: the next connection picks it up. + */ + const stillConnected = (): boolean => getCurrentUser() === holder; + // Captured with the identity, handed back at filing time — see `caps.holderKey`. + const holderKey = getCaps().holderKey(); + const run = (async (): Promise => { try { // Connecting must not PROVISION. `ensureAccount` would create the user on @@ -64,15 +82,22 @@ export async function connectedUser(): Promise { // background side effect, at a moment nothing controls. An account that does // not exist has nothing to restore and no inbox to drain. if ((await resolveAccount(holder)) === null) return; + if (!stillConnected()) return; // 1. Durable first: what this user has already applied. - for (const cap of await readLinks()) getCaps().learn(cap); + const links = await readLinks(); + if (!stillConnected()) return; + for (const cap of links) getCaps().learnFor(holderKey, cap); // 2. Then the queues: ALL of them — the user's own inbox, plus one per // document it opened an inbox on. Both levels, as the PO specified, and // both are answered by the same User-branch record (`AddInboxCap`). // Sequential rather than parallel: each `processInbox` writes what it // applies to the SAME private store, and interleaving those writes buys // nothing on a queue that is nearly always empty. - for (const inbox of await myInboxes()) await processInbox(inbox); + const inboxes = await myInboxes(); + for (const inbox of inboxes) { + if (!stillConnected()) return; + await processInbox(inbox); + } } catch { // Not configured yet, or offline. Nothing to restore, and connecting must // not fail because a queue could not be reached — the next connection, or diff --git a/packages/sdk/src/shared-wallet/account-registry.ts b/packages/sdk/src/shared-wallet/account-registry.ts index 3b9156e..298d0a7 100644 --- a/packages/sdk/src/shared-wallet/account-registry.ts +++ b/packages/sdk/src/shared-wallet/account-registry.ts @@ -1113,7 +1113,11 @@ export async function listMyEntityDocs(id: string, scope: Scope): Promise { export async function read(targetInboxLike: NuriLike): Promise { const targetInbox = toNuri(targetInboxLike, "inbox.read"); await assertOwnInbox(targetInbox, "read"); + // WHO this read belongs to, captured with the guard that authorised it — see the note + // beside the filing below, and `caps.holderKey`. + const owner = getCurrentUser(); + const ownerKey = getCaps().holderKey(); const sid = await sessionId(); // NOTE: cold-start repo opening is done by the COLD DIRECT readers that need it // (a cold reader that opens the repo before reading), NOT here — `inbox.watch` @@ -444,10 +448,19 @@ export async function read(targetInboxLike: NuriLike): Promise { // view that was empty for want of that cap re-read instead of staying stale. const delivered: Deposit[] = []; const links: ReadCap[] = []; + // The ownership guard ran at entry; the filing happens several awaits later, and filing + // resolves WHO is holding at that moment. So an application switching identity in the + // gap could have this inbox's caps land in the NEW holder's ring. A hazard read off the + // code, not a leak anyone reproduced — see `caps.holderKey`. + // + // Abandoning is the faithful answer: upstream an inbox is processed by ITS owner's + // verifier, and switching user is another session. Nothing is lost — an inbox is not + // consumed by reading, so the next connection under the right identity files them. + const stillOwner = getCurrentUser() === owner; for (const d of deposits) { const cap = capOfPayload(d.payload); if (cap) { - getCaps().learn(cap); + if (stillOwner) getCaps().learnFor(ownerKey, cap); links.push(cap); continue; } diff --git a/packages/sdk/test/anti-fork.test.ts b/packages/sdk/test/anti-fork.test.ts index cfa0a73..430175c 100644 --- a/packages/sdk/test/anti-fork.test.ts +++ b/packages/sdk/test/anti-fork.test.ts @@ -209,8 +209,12 @@ describe("deterministic resolution over a doc-shim corrupted by fork residue", ( // Seed the pointer (store-root → doc-shim) and the corrupted record IN the doc-shim. fakeNg._quads.push({ g: ROOT, s: "urn:ng-eventually:shim:root", p: "urn:ng-eventually:shim:shimDoc", o: docShim }); const subj = "urn:ng-eventually:shim:account:dupuser"; + // The minimum must sit NEITHER first NOR last, or the test cannot tell a canonical + // pick from a positional one. It used to end on `pub-a`, so `rows[rows.length - 1]` + // — an order-dependent pick, precisely the fault this test exists to catch — passed + // it. Only `rows[0]` failed. Found by mutation, 2026-08-10. const dupPublics = [ - "did:ng:o:pub-m", "did:ng:o:pub-a", "did:ng:o:pub-z", "did:ng:o:pub-c", "did:ng:o:pub-a", + "did:ng:o:pub-m", "did:ng:o:pub-a", "did:ng:o:pub-z", "did:ng:o:pub-c", "did:ng:o:pub-z", ]; fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:id", o: "dupuser" }); for (const p of dupPublics) diff --git a/packages/sdk/test/public-store.test.ts b/packages/sdk/test/public-store.test.ts index 25cf24d..cb93f34 100644 --- a/packages/sdk/test/public-store.test.ts +++ b/packages/sdk/test/public-store.test.ts @@ -55,6 +55,21 @@ afterEach(() => { setCurrentUser(null); }); +/** + * Alice creates her note and exposes its cap — the two halves of what `createEntityDoc` + * does for a `public` scope, in that order. + * + * The `mint` is not decoration: exposing writes to the document, and writing needs to + * reach it. Without it this file only passed while the emulation happened to be + * DISARMED, which made its results depend on which test file ran first — it went red in + * `bun test test/public-store.test.ts`. A fixture that exposes a cap for a + * document nobody holds describes a state the library never produces. + */ +async function aliceExposesHerNote(): Promise { + getCaps().mint(PUB); + await exposeReadCap(PUB, mintCap(PUB)); +} + /** Arm the emulation without giving the current holder anything: some OTHER document. */ function armEmulation(): void { setCurrentUser("someone-else"); @@ -66,7 +81,7 @@ const PUB = "did:ng:o:pub" as Nuri; test("a cap exposed on a document is downloaded by a holder that has nothing", async () => { inject(); setCurrentUser("alice"); - await exposeReadCap(PUB, mintCap(PUB)); + await aliceExposesHerNote(); setCurrentUser("bob"); armEmulation(); @@ -91,7 +106,7 @@ test("a document that exposes nothing yields nothing — that is the normal case test("a cap naming ANOTHER document is refused, not filed", async () => { const { quads } = inject(); setCurrentUser("alice"); - await exposeReadCap(PUB, mintCap(PUB)); + await aliceExposesHerNote(); // Forge the exposed value so it names a different document. quads[0]!.o = mintCap("did:ng:o:someone-elses" as Nuri); @@ -116,7 +131,7 @@ test("inert while no cap has been issued at all — nothing to obtain, nothing a test("a public store serves every asker, not only the first", async () => { inject(); setCurrentUser("alice"); - await exposeReadCap(PUB, mintCap(PUB)); + await aliceExposesHerNote(); armEmulation(); setCurrentUser("bob"); @@ -131,7 +146,7 @@ test("a public store serves every asker, not only the first", async () => { test("asked once per document: the outcome is memoised, in both directions", async () => { const { sparql_query } = inject(); setCurrentUser("alice"); - await exposeReadCap(PUB, mintCap(PUB)); + await aliceExposesHerNote(); armEmulation(); setCurrentUser("bob"); @@ -150,7 +165,7 @@ test("asked once per document: the outcome is memoised, in both directions", asy test("resetting the caps forgets the memo — a stale yes would hand back what is no longer held", async () => { const { sparql_query } = inject(); setCurrentUser("alice"); - await exposeReadCap(PUB, mintCap(PUB)); + await aliceExposesHerNote(); armEmulation(); setCurrentUser("bob"); await fetchReadCap(PUB);