Skip to content

EthenEthenEthen

Keeping Code History Scoped to the Signed-In User

Ethen Code keeps local run history behind a per-account key, discards the previous scope on identity change, and tells other tabs to do the same.

Ethen Code keeps local run history behind a per-account key, discards the previous scope on identity change, and tells other tabs to do the same.

Two people share one browser profile on a demo machine. The first signs into Ethen Code, runs a few agent sessions, and signs out. The second signs in and opens the same workspace. What should the second person see in run history? Nothing from the first person — not merged, not carried over, not recoverable from a global cache. This article explains the local mechanism Ethen Code uses to get that behavior: a single owner binding per tab, per-account storage keys, discard-on-transition, a named sign-out sweep, and a cross-tab broadcast so follower tabs invalidate too.

The scope here is deliberately narrow. Everything below describes client-side persistence in persist.ts and the focused behavioral checks in ie-m9a-code-voice-scope.test.ts. That is a local storage mechanism with focused tests, not proof of complete multitenant security. Server authorization, transport security, and any cloud copy of workspace data are outside what these two sources establish.

Why a global key is the wrong default

The persistence module defines two key shapes. The legacy constant is a single global:

text
ethen.coding.runs.v1

The scoped shape prefixes the same run collection with the current owner:

text
ethen/scope/<ownerId>/coding.runs.v1

A helper called runsKey() picks between them. When the tab has no bound owner, reads and writes use the legacy global. When an owner is bound, reads and writes use only that owner's scoped key. There is no fallback that checks both keys, and no merge step that combines them. The current owner is held in a module-level variable, currentRunsOwner, exposed read-only through getCodingRunsOwner().

That design answers the shared-machine question at the storage layer. Anonymous use keeps the historical global key, so existing unsigned behavior does not break. Signed use never touches the global key for run data. Each account's runs live under its own prefix, and switching accounts changes which key the tab reads without ever copying data between prefixes.

The test file pins this down through the real saveRuns and loadRuns callers rather than through mocks of the persistence layer. It writes runs while anonymous and asserts the legacy key is populated, then binds actor-a and asserts that loadRuns() returns empty and that the only stored key afterward is ethen/scope/actor-a/coding.runs.v1. The point is not just that the new key exists — it is that binding a new owner does not adopt the old data.

Binding the tab to an account

setCodingRunsOwner() is the single function that binds a tab to an actor. It takes the new owner id, a storage handle defaulting to window.localStorage, and an options object with an optional broadcast flag. It returns what it removed: { removedLegacy, removedScoped }.

Its control flow has three branches worth understanding separately.

First, same-owner re-entry is a no-op. If the incoming owner equals the current owner, the function updates nothing and removes nothing. The comment in the source is explicit: remounts must not wipe the current actor's own runs. This matters in frameworks where components mount, unmount, and remount without any identity change. Without this guard, a re-render could look like a sign-in and discard the very history it was trying to display.

Second, a null storage handle resets the binding only. The owner variable is updated, but no keys are touched and nothing is broadcast as removed. The source comment describes this as the path for follower tabs re-resolving from the server: the tab updates who it thinks it is without performing a local sweep it cannot safely perform.

Third, a genuine owner transition performs a discard. The function removes the legacy global if present, then enumerates every key under the previous owner's prefix — ethen/scope/<previous>/ — takes a sorted snapshot of those keys, and removes each one. Enumeration is snapshot-first so that removing keys during iteration cannot skip entries. Every storage access is wrapped in best-effort handling: a throwing storage layer does not crash the caller. The function records which scoped keys it removed and returns them alongside the legacy flag.

The transition also derives a reason for the broadcast:

  • previous === null means sign-in
  • ownerId === null means sign-out
  • otherwise account-switch

Unless the caller passes { broadcast: false }, the function broadcasts that reason to other tabs. The return value plus the broadcast together give the caller both local confirmation and cross-tab propagation from one call.

What sign-in, switch, and sign-out each discard

It helps to walk the three transitions concretely, because each discards something different.

On sign-in from anonymous, the previous owner is null, so only the legacy global is eligible for removal alongside the binding change. The test seeds nothing or seeds a legacy payload, binds actor-a, and then asserts that loadRuns() is empty and the legacy key is gone. The new actor starts with a clean scoped key. Stale anonymous runs are never adopted into the signed scope.

On account switch from actor-a to actor-b, both the legacy global (if it somehow reappeared) and the entire previous prefix are removed. The test writes runs as actor-a, switches to actor-b, and asserts that removedScoped equals exactly ["ethen/scope/actor-a/coding.runs.v1"] and that loadRuns() is empty. Actor B cannot read actor A's runs through the normal load path because the key they lived under no longer exists in that tab's storage, and the load path only reads actor B's key.

On sign-out, the named helper handleCodingRunsSignOut() delegates to setCodingRunsOwner(null, storage) with broadcasting enabled. Its doc comment says to call it before any redirect, so the sweep runs while the tab still has storage access. The test binds actor-a, saves runs, calls the sign-out helper, and then asserts the full sweep: the scoped key is listed in removedScoped, the owner reads back null, loadRuns() is empty, and the storage key list is empty. Sign-out leaves no run key behind in that tab — neither scoped nor legacy.

One detail deserves emphasis: the switch path removes by prefix (ethen/scope/<previous>/), not by naming one exact key. The current scoped runs key is the only key the module itself writes under that prefix, but prefix removal is robust to future keys added under the same owner scope. Sorting the snapshot before removal keeps the behavior deterministic and makes the returned list stable for tests and logging.

Why remounts and re-renders are safe

The same-owner no-op guard looks like a small optimization, but the test treats it as a correctness property. After switching to actor-b and saving runs, the test calls setCodingRunsOwner("actor-b", storage) a second time and asserts that nothing was removed and that loadRuns() still returns one run.

Without this guard, every component remount that re-asserted the current user would behave like an account switch to the same account. Depending on implementation, that could discard the legacy key on every mount or, worse, treat the current scope as "previous" and delete it. The guard makes binding idempotent: asserting the already-current owner is always free. Only a genuinely different owner triggers the destructive path.

The null-storage branch serves a complementary purpose. Some tabs cannot safely sweep — the source names follower tabs re-resolving identity from the server. Passing null storage lets those tabs update their in-memory binding without touching keys they may not own or may not be able to enumerate. The function returns empty removal lists in that case, which correctly reports that no local discard ran.

Telling other tabs: the identity broadcast

Discarding keys in one tab is not enough when the same origin is open in several tabs. Each tab holds its own in-memory currentRunsOwner and shares the same localStorage area. If the user signs out in tab one while tab two still believes actor-a is current, tab two could keep displaying stale runs or write new ones back under the old scope.

The module addresses this with a shared BroadcastChannel named ethen-identity. The channel name is exported as CODING_RUNS_IDENTITY_CHANNEL, and the source notes it matches the chat helper's channel, so same-origin tabs already share the transport. broadcastCodingRunsChange() opens the channel, posts a single message, and closes it:

ts
{
  kind: "identity-change",
  app: "code",
  reason: "sign-in" | "sign-out" | "account-switch",
  previousOwner: string | null
}

Broadcasting is best-effort by design. If BroadcastChannel is undefined, the function returns silently. If posting throws, the error is swallowed, because the local discard in the originating tab already ran — cross-tab propagation must never undo or block the local cleanup. The channel is closed immediately after posting; there is no long-lived sender to leak.

Receiving is handled by subscribeCodingRunsChange(), which opens its own channel, listens for message events, and invokes the callback only when kind === "identity-change" and app === "code". The returned unsubscribe function removes the listener and closes the channel. Filtering on app is what keeps sibling features from reacting to each other's identity traffic: the same ethen-identity channel carries messages for more than one consumer, and each subscriber ignores messages tagged for a different app.

The test file verifies the cross-app filtering directly. It subscribes one listener for voice identity changes and one for coding-run changes on the same fake channel, triggers a voice identity change and a coding owner change, and asserts that each listener received exactly one message with the expected app and reason fields. A voice sign-in does not invalidate code history, and a code account switch does not disturb voice selection. Each app's follower tabs react only to their own app's broadcasts.

The sign-out test also verifies the follower-tab notification path end to end at the unit level. It subscribes a listener before binding actor-a, saves runs, calls handleCodingRunsSignOut(), and asserts that the listener received exactly one message with reason === "sign-out". Combined with the storage assertions — owner null, loads empty, key list empty — this covers the documented contract: the originating tab sweeps everything locally and notifies followers so they can fail closed rather than keep serving the previous owner's history.

How runs are actually read and written

Binding and broadcast decide which key is current. loadRuns(), saveRuns(), and clearPersistedRuns() do the actual reading and writing, with a desktop fallback the browser path does not need.

In the browser, loadRuns() reads the current runsKey() from window.localStorage, parses the JSON object, and normalizes each entry through normalizeRun(). If anything fails — missing key, unparseable JSON, unavailable storage — the browser path yields an empty map and the function falls through to the file path or returns empty. saveRuns() serializes the run map to JSON and writes it to the current runsKey(), warning on failure rather than throwing. clearPersistedRuns() removes both the current scoped key and the legacy key.

normalizeRun() exists because persisted runs outlive the code that wrote them. Older builds may have stored run objects without fields added later, such as queuedMessages. Spreading or pushing onto a missing array field would crash downstream code, so the normalizer backfills every array and object field with its empty or default value: empty arrays for events, queued messages, validation results, changed files, patch checkpoints, and approval records; null or default sentinels for review, gate, repair, sandbox, handoff, and security fields; fresh usage and limit objects when absent. Stale localStorage data from before a schema change loads as a valid run rather than crashing the workspace.

Outside the browser, where window is undefined, the module falls back to a file-backed JSON store. getFilePath() prefers Electron's userData directory (ethen-coding-runs.json under it) when available and otherwise uses the operating-system temporary directory. Writes are crash-safe via atomic rename: the JSON is written to <file>.tmp and then renamed over the target, so a crash mid-write cannot leave a half-written runs file. The file path is injectable for tests through __setFilePersistencePathForTests(). Directory creation is recursive, and failures warn rather than throw, matching the browser path's posture.

Two scope notes apply to the file fallback. First, the file path selection — Electron userData or temp directory — is a local durability mechanism for desktop and Node contexts, labeled COD-P0-02 in the source comments. It is not itself an account-isolation boundary; the per-account keying described above is the browser storage mechanism. Second, clearPersistedRuns() also unlinks the file when present, so explicit clears reach both backends. Readers should not infer server-side retention or deletion behavior from any of this: the module only governs what the local client keeps.

A worked example across two accounts

Putting the pieces together, here is the sequence the tests encode, expressed as tab behavior rather than assertions.

The tab starts anonymous. getCodingRunsOwner() returns null, and saveRuns() writes to ethen.coding.runs.v1. This is the legacy mirror: unsigned work persists locally under one global key.

The user signs in as actor-a. The app calls setCodingRunsOwner("actor-a", storage). Because the previous owner was null, the reason is sign-in. The legacy key is removed, no scoped prefix existed to sweep, and a broadcast goes out with { app: "code", reason: "sign-in", previousOwner: null }. loadRuns() now reads ethen/scope/actor-a/coding.runs.v1, which starts empty. Anything the anonymous session stored is gone from this tab, not migrated.

The user works, and saveRuns() populates the actor-a key. Then a second user signs in on the same machine without a full browser restart, so the app calls setCodingRunsOwner("actor-b", storage). The reason is account-switch. The module removes the legacy key if present and sweeps ethen/scope/actor-a/, returning the removed key for confirmation. A broadcast carries { app: "code", reason: "account-switch", previousOwner: "actor-a" }. Actor B's first load is empty, and actor A's runs are no longer in this tab's storage.

The component tree remounts — navigation, hot reload, or a re-rendered provider calls the binding function again with actor-b. The same-owner guard returns immediately. Nothing is removed. Actor B's runs survive the remount.

Finally the user signs out. The app calls handleCodingRunsSignOut(storage) before redirecting. The reason is sign-out, the actor-b prefix is swept, the legacy key is removed if present, and follower tabs receive the sign-out broadcast. The originating tab ends with no run keys at all. A follower tab that was still showing actor B's runs gets the signal it needs to invalidate rather than continue displaying — or rewriting — the signed-out owner's history.

What the tests establish, precisely

The behavioral file is explicit about its own scope in its header comment: anonymous callers keep the exact legacy global keys, signed callers read and write only ethen/scope/<user>/…, owner switches discard legacy globals plus the previous scope and never adopt, sign-out sweeps everything and broadcasts so followers fail closed, and code and voice channels ignore each other's broadcasts. Each clause maps to at least one test through the real store callers.

It is worth noting what the harness is. Storage is an in-memory MemoryStorage implementing the same getItem/setItem/removeItem/key/length surface the module expects, installed as window.localStorage via vi.stubGlobal. Cross-tab delivery is a FakeBroadcastChannel with a static registry that forwards posted messages to same-name peers and records everything sent. This is a faithful model of the key-selection, discard, and filtering logic, but it is still a model: it exercises the module's branching, key construction, and message filtering without a real browser, real tabs, or real disk.

Within that harness, the coverage is specific. The code suite checks the anonymous-to-signed transition, the never-adopt property, actor-to-actor isolation with exact removed-key assertions, the remount no-op, and the sign-out sweep including broadcast receipt and a final clearPersistedRuns() leaving storage empty. The voice portion of the same file checks the analogous project-selector scoping and the cross-app broadcast filtering already described. The voice assertions corroborate the channel design from a second consumer; they do not substitute for reading the voice implementation, which is outside this article's assigned sources.

Limitations: what this does not prove

The claim limit on this article exists for a reason, so this section is not boilerplate. Treat each of these as a boundary on what to conclude.

Local discard is not server-side isolation. Removing keys from one browser's localStorage says nothing about what the server retains, what another device holds, or what a cloud workspace copy contains. If run data is synced or backed up anywhere outside this module, that system's own access controls — not this sweep — govern who can read it. The sources inspected here contain no server authorization logic.

Best-effort cleanup can fail silently. Every removal is wrapped so that storage exceptions do not crash the app. That is the right tradeoff for a UI persistence layer, but it means a hostile or broken storage implementation could report success while retaining data, and the module would not detect it. The return values report what the module attempted through the calls it made, not an independent audit of the storage backend.

The broadcast is notification, not enforcement. BroadcastChannel delivery is same-origin and best-effort; tabs that are closed, suspended, or running code that never subscribed will not invalidate. The doc comment on the sign-out helper says follower tabs "fail closed," which in context means subscribers treat the broadcast as a signal to drop the previous owner's state — it does not mean the originating tab can force another tab's memory or storage to clear. A follower that ignores the message keeps whatever it holds until its own binding changes.

The focused tests do not constitute a security evaluation. Three code tests plus the shared broadcast-filtering test cover the intended transitions through fake storage and a fake channel. They do not test concurrent writes from two tabs racing a switch, quota-exceeded behavior, corrupted JSON beyond the normalization paths, filesystem permissions on the desktop fallback, or any adversarial scenario. Passing them is evidence the mechanism behaves as designed under the modeled conditions — not a certification of multitenant security.

Finally, Code spans more than the browser. The batch architecture notes record Code as spanning lightweight Chat, cloud Platform, and local Desktop surfaces. This article's sources cover the local persistence slice only. Do not read per-account localStorage keys as a statement about how any cloud or desktop-execution counterpart isolates tenants; that would merge a verified local mechanism with an unverified wider claim.

The shape to remember

Account isolation in Ethen Code's local run history comes down to four habits the module never breaks: bind each tab to exactly one owner, key signed reads and writes under that owner's prefix, discard rather than migrate on every genuine transition, and broadcast the transition so sibling tabs can invalidate. Anonymous use keeps the legacy global; signed use never reads it. Remounts are free; switches are destructive; sign-out is the most destructive transition of all, and it runs before the redirect.

If you build a similar workspace, the transferable lesson is the ordering. Resolve identity first, derive the storage key from the resolved identity, and make the destructive step — removing the previous scope — part of the binding function itself rather than a separate cleanup call sites can forget. Ethen Code's version of that is small enough to read in one sitting and specific enough to test transition by transition, which is exactly why its limits can be stated plainly: a disciplined local boundary, verified by focused tests, inside a larger isolation story these sources do not attempt to tell.