diff --git a/docs/build-db-schema-owner-prd.html b/docs/build-db-schema-owner-prd.html
new file mode 100644
index 0000000..b22032e
--- /dev/null
+++ b/docs/build-db-schema-owner-prd.html
@@ -0,0 +1,211 @@
+
+
+
+
+
+One schema owner for build-db
+Task slug build-db-schema-owner · branch enable-managed-auth · 2026-07-30 · follow-up to finding F1 of the managed-auth smoke test
+
+Definition
+In this project, "schema owner" means exactly one module declares
+build-db's version number and creates every object store in a single
+onupgradeneeded handler; every other module obtains its handle from that owner and
+never calls indexedDB.open itself.
+src/projects.ts already claims this role in its own comment
+(projects.ts:30): "This module owns the schema: build-db must be opened
+at ONE version, and two modules opening it at different versions throws on whichever is second."
+src/workspace-store.ts:40 already complies, via export const openWorkspaceDb = openBuildDb.
+This task makes the one non-complying module comply.
+
+The defect
+
+ | Module | Opens | At version | Creates |
+ src/projects.ts:33 | build-db | 2 | projects, meta, workspace |
+ src/workspace-store.ts:40 | — (delegates) | — | — |
+ src/env-store.ts:9 | build-db | 1 | env-store |
+
+Measured in a real browser during the smoke test: once projects.ts has upgraded the
+database to 2, env-store's open at 1 fails with
+VersionError: The requested version (1) is less than the existing version (2).
+
+
+
Scope correction, recorded honestly. The smoke-test writeup described this as
+breaking the env-vars feature. It does not, today: env-store.ts has no
+consumers — a repo-wide search finds the string env-store only in the module
+itself and its own test. It is a latent defect in an unused module. That lowers the urgency but not
+the correctness argument: the module ships, and it is wrong the moment anyone wires it up.
+
+
+Why the obvious fix is wrong
+Changing env-store.ts's DB_VERSION from 1 to 2 looks like a one-character
+fix and is a trap. Databases already at version 2 would open without firing
+onupgradeneeded at all, so the env-store object store would never be
+created — trading a loud VersionError for a quieter
+NotFoundError on first use. The store has to be created by the owner's upgrade handler,
+which means the version must actually advance to 3.
+
+Goals
+
+ - One module opens
build-db; everyone else delegates.
+ - Every existing database — v1 with
{meta, projects}, v2 with
+ {meta, projects, workspace}, or an env-store-first v1 — converges on the same v3 shape
+ with no data loss.
+ - A test that fails against today's code, in real IndexedDB semantics rather than a stub.
+ - Make the version bump safe to deploy while a tab running the old code is still open.
+
+
+Non-goals
+
+ - Deleting
env-store.ts. Defensible — the migration plan's Phase 0
+ deleted dead trees on exactly this reasoning — but the user asked to fix, not to remove. Flagged as
+ an open decision below rather than taken unilaterally.
+ - Building any env-vars UI or wiring the module to a consumer.
+ - Touching the F3 provisioning-idempotency finding, which needs a product decision.
+ - Changing what any store holds, or migrating record shapes.
+
+
+Constraints
+
+ | Constraint | Consequence |
+ | Real user data | build-db holds the user's actual projects. A migration that drops a store destroys work. Every createObjectStore stays guarded by a contains() check, and the upgrade is verified against the user's live database, not just a synthetic one. |
+ | Duplicate module instances | The Gleam build mirrors src/ into build/dev/javascript/build/, and main-gleam.ts imports from both paths. Two copies of projects.ts can each hold a memoized connection. Same version in both, so they coexist — but it means an upgrade can find another connection already open. |
+ | Version bumps can block | An open connection at v2 blocks a v3 upgrade indefinitely. Today openBuildDb has neither an onblocked nor an onversionchange handler, so a second tab on the old code would hang the new one silently — with no error, forever. |
+ | No subagents | Session policy disallows spawning them; planner and judge roles are performed inline against the same rubric. |
+
+
+Open decision (not a blocker)
+Since env-store.ts is unused, the alternative to fixing it is deleting it (~78 lines
+plus its test). Recommendation: fix now, decide deletion separately. The fix is small
+and makes the schema rule uniformly true, which is worth having regardless; deletion is a product call
+about whether per-project env vars are still wanted. Proceeding with the fix under that assumption.
+
+Plan
+
+
+
Step 1 — projects.ts becomes the sole schema owner at v3
+
Bump DB_VERSION to 3, create the env-store object store in the same
+guarded upgrade handler, and add the missing onblocked / onversionchange
+handling that a version bump makes necessary.
+
Acceptance criteria
+
+ - AC1.1
DB_VERSION === 3 and the upgrade handler creates all four stores, each guarded by objectStoreNames.contains().
+ - AC1.2
onblocked rejects with a message naming the cause, instead of hanging forever.
+ - AC1.3
onversionchange closes the connection so another tab's upgrade is never blocked by this one.
+ - AC1.4 The store-name constant is exported so
env-store.ts cannot drift from the owner's spelling.
+
+
Evidence: diff; unit tests in step 3.
+
+
+
+
Step 2 — env-store.ts delegates
+
Delete its DB_NAME/DB_VERSION/openDb and import
+openBuildDb, mirroring workspace-store.ts exactly — same shape, same
+explanatory comment style.
+
Acceptance criteria
+
+ - AC2.1
env-store.ts contains no indexedDB.open call and declares no version.
+ - AC2.2 A repo-wide search shows exactly one
indexedDB.open for build-db in src/.
+ - AC2.3 The module's public API is unchanged —
getEnvVars, setEnvVars, upsertEnvVar, deleteEnvVar, envToDotEnv keep their signatures.
+
+
Evidence: diff; grep -rn "indexedDB.open" src/.
+
+
+
+
Step 3 — a test that would have caught it
+
The existing env-store.test.ts replaces indexedDB with a hand-rolled stub
+whose open() ignores the version argument entirely — which is precisely why this bug
+survived. Move it to real fake-indexeddb semantics, the pattern
+projects.test.ts already uses.
+
Acceptance criteria
+
+ - AC3.1 A test opens the DB through
projects.ts first, then reads/writes through env-store.ts, and passes.
+ - AC3.2 The reverse order also passes (env-store first, then projects).
+ - AC3.3 The new test fails against the pre-fix code — verified by stashing the fix, not assumed.
+ - AC3.4 A test proves an existing v2 database upgrades to v3 with its records intact.
+ - AC3.5 The existing env-store behavioral tests (
envToDotEnv escaping etc.) still pass.
+
+
Evidence: npx vitest run src/env-store.test.ts src/projects.test.ts; a recorded pre-fix failure.
+
+
+
+
Step 4 — validate, including against the user's real database
+
Acceptance criteria
+
+ - AC4.1
npm test (gleam + vitest) and server vitest all green, with no test count regression (baseline 799).
+ - AC4.2 In the live browser,
build-db reports version: 3 with all four stores present.
+ - AC4.3 The user's existing projects survive — project count and current-project id unchanged across the upgrade, compared before and after.
+ - AC4.4 An
env-store read/write round-trips in the real browser without a VersionError.
+ - AC4.5 No new console errors on load.
+
+
Evidence: suite output; before/after DB snapshot from the live page.
+
+
+Validation commands
+
+
npx vitest run src/env-store.test.ts src/projects.test.ts
+npm test # 197 gleam + 293 vitest
+cd server && npm test # 309
+grep -rn "indexedDB.open" src/ # expect exactly one hit
+
+
+Definition of done
+One indexedDB.open for build-db in the codebase; both module orders work
+under real IndexedDB semantics; the new test demonstrably fails without the fix; the user's live
+database is at v3 with all four stores and every project still present; all suites
+green.
+
+Judge rubric
+
+ | Area | Judgment |
+ | Correctness | Do all three starting states (v1 two-store, v1 env-first, v2) converge on v3 without loss? |
+ | Data safety | Is every createObjectStore guarded? Was the real database verified, not just a synthetic one? |
+ | Test quality | Does the test fail on the old code? Does it use real IDB semantics rather than a stub that ignores versions? |
+ | Simplicity | Does env-store.ts now read like workspace-store.ts, or has a second pattern been invented? |
+ | Taste | Do the comments explain why the rule exists rather than restating the code? Is the delegation as quiet as workspace-store.ts's? |
+ | Maintainability | Would the next contributor adding a store know where to put it? |
+
+Taste threshold: below 4 triggers a repair loop. Originality is not scored — the correct answer
+here is deliberately to copy the pattern the repo already established, not to invent one.
+
+Risks
+
+ | Risk | Handling |
+ | Upgrade drops a store and destroys projects | Guarded creates only; no deletes. Verified against the live DB with a before/after project count (AC4.3). |
+ | Old tab blocks the upgrade forever | Explicitly addressed in Step 1 (AC1.2, AC1.3) rather than left latent. |
+ | Version bump ships to users mid-session | onversionchange closes stale connections so the next load upgrades cleanly. |
+ | Fixing dead code is wasted effort | Acknowledged in the open decision above; the fix is small and the schema rule becomes uniformly true. |
+
+
+
+
+
diff --git a/docs/build-db-schema-owner-progress.html b/docs/build-db-schema-owner-progress.html
new file mode 100644
index 0000000..6cebc20
--- /dev/null
+++ b/docs/build-db-schema-owner-progress.html
@@ -0,0 +1,173 @@
+
+
+
+
+
+Progress — one schema owner for build-db
+Task slug build-db-schema-owner · branch enable-managed-auth · PRD: build-db-schema-owner-prd.html
+
+Status: complete — all four steps
+pass. build-db now has exactly one indexedDB.open; every historical database
+shape converges on v3 with data intact, verified both in tests and against the user's real database.
+
+Step checklist
+
+ | Step | Status | Criteria |
+ 1 — projects.ts sole owner at v3 | pass | 4/4 |
+ 2 — env-store.ts delegates | pass | 3/3 |
+ | 3 — test that would have caught it | pass | 5/5 |
+ | 4 — validate, incl. the real database | pass | 5/5 |
+
+
+Step 1 — evidence
+
+ | AC | Verdict | Evidence |
+ | 1.1 v3, four guarded creates | pass | DB_VERSION = 3; all four createObjectStore calls guarded by contains(); no deletes anywhere in the handler |
+ 1.2 onblocked | pass | Rejects with "build-db upgrade is blocked by another open tab — close it and reload" instead of hanging |
+ 1.3 onversionchange | pass | Connection closes itself when another tab upgrades |
+ | 1.4 exported store name | pass | export const ENV_STORE = 'env-store', consumed by env-store.ts |
+
+1.2 and 1.3 were not in the original brief — they became necessary because of the version
+bump. Without them, a user with a second tab on the old code gets a page that silently never boots.
+The pre-fix test run made this concrete rather than theoretical: the "env-store touches the database
+first" case failed by timing out at 5001 ms, not by assertion — a real deadlock.
+
+Step 2 — evidence
+
+ | AC | Verdict | Evidence |
+ | 2.1 no open, no version | pass | env-store.ts is now const openDb = openBuildDb with the same explanatory comment style as workspace-store.ts |
+ | 2.2 exactly one owner | pass | grep -rn "indexedDB.open" src/ → a single hit, src/projects.ts:52 |
+ | 2.3 API unchanged | pass | All five exports keep their signatures; tsc -b exits 0 |
+
+
+Step 3 — evidence
+
+ | AC | Verdict | Evidence |
+ | 3.1 projects-first ordering | pass | "works when projects.ts opens the database first" |
+ | 3.2 env-store-first ordering | pass | "works when env-store touches the database first" |
+ | 3.3 fails pre-fix | pass | 5 of 12 failed against stashed pre-fix sources; restored and re-verified afterwards |
+ | 3.4 v2 upgrade keeps data | pass | Seeded v2 DB with a project + current-project-id; both survive to v3 |
+ | 3.5 existing behaviour intact | pass | The three envToDotEnv tests carried over unchanged |
+
+The old test file is worth recording as its own finding: it carried ~50 lines of fake-DB scaffolding
+(fakeDB, testStore, openTestDb) that no test used,
+and its stubbed indexedDB.open() ignored the version argument entirely. The four storage
+functions had zero coverage while appearing to be tested. That is why a version conflict could
+ship. The replacement runs on fake-indexeddb — the pattern projects.test.ts
+already used — so version semantics are real.
+Coverage went from 3 tests (one pure function) to 14, adding storage round-trips,
+per-project isolation, both module orderings, and three migration paths.
+
+Step 4 — evidence
+
+ | AC | Verdict | Evidence |
+ | 4.1 suites green | pass | 197 gleam + 304 vitest + 309 server = 810 (baseline was 799; +11 net). tsc -b exits 0. |
+ | 4.2 real DB at v3 | pass | Live browser: version: 3, stores ['env-store','meta','projects','workspace'] |
+ | 4.3 no data loss | pass | See the before/after table below |
+ | 4.4 env-store works live | pass | Round-tripped a probe record on the upgraded database, then deleted it — env-store left with 0 rows, exactly as found |
+ | 4.5 no console errors | pass | 102 messages reviewed: Vite connects, Clerk's expected dev-keys warning, React DevTools notice, our own debug lines. No VersionError, no blocked, no errors. |
+
+
+The user's real database, before and after
+
+
+ | Before (v2) | After (v3) |
+ | version | 2 | 3 |
+ | stores | meta, projects, workspace | env-store, meta, projects, workspace |
+ | projects | 3 | 3 same ids |
+ | files per project | — | 6 / 13 / 6 intact |
+ | messages per project | — | 0 / 2 / 4 intact |
+ current-project-id | 303e3d3e… | 303e3d3e… unchanged |
+ | workspace records | 1 | 1 unchanged |
+
+
+Verified by importing the app's own modules (/src/projects.ts,
+/src/env-store.ts) into the live page through Vite's dev transform, so the upgrade ran
+through production code paths against real data — not a re-implementation of the migration.
+
+Migration paths covered
+
+ | Starting shape | Where proven |
+ v1 {meta, projects} — pre-workspace, the oldest shape in the wild | test: "upgrades a v1 {meta, projects} database straight to v3" |
+ v1 {env-store} — what the old env-store-first path would have created | test: "upgrades a v1 database that only has env-store" |
+ v2 {meta, projects, workspace} | test + the user's real database |
+ | no database at all | test: "opens every store at one version" |
+
+
+Inline judge review
+No subagents this session (policy), so the LLM-as-judge pass was performed inline against the PRD rubric.
+
+ | Area | Verdict | Note |
+ | Correctness | pass | All four starting shapes converge on v3. Initially only two were covered; the gap was found during this review and closed with two more tests rather than claimed. |
+ | Data safety | pass | Guarded creates only, no deletes. Verified against real data with files and messages counted, not just row counts. |
+ | Test quality | pass | Fails on the old code (5/12). Real IDB semantics. The deadlock surfaced as a timeout, which is what motivated onblocked. |
+ | Simplicity | pass | env-store.ts lost 14 lines and gained a one-line delegation identical in shape to workspace-store.ts. No new pattern invented. |
+ | Security/privacy | pass | No credentials touched. The probe record written during live verification was removed. |
+ | Taste | 4/5 | The fix is deliberately unoriginal — it makes the third module obey a rule the codebase had already written down and enforced once. Comments explain why the version had to move to 3 rather than restating the code. Not a 5 only because the underlying module is still unused. |
+ | Maintainability | pass | One upgrade handler, one version constant, exported store names. Where a fifth store goes is now unambiguous. |
+
+Originality deliberately not scored — per the PRD, copying the established pattern is the
+right answer here.
+
+Files changed
+
+ src/projects.ts — v3, env-store creation, exported ENV_STORE, onblocked + onversionchange
+ src/env-store.ts — delegates to openBuildDb; own open/version removed
+ src/env-store.test.ts — rewritten on fake-indexeddb; 3 tests → 14
+ docs/build-db-schema-owner-{prd,progress}.html — these artifacts
+
+
+Residual risks and follow-ups
+
+ env-store.ts is still unused. It is now correct, but nothing imports it. Deleting it remains a live option, and is a product call about whether per-project env vars are still wanted.
+ - The version bump reaches existing users on next deploy. Safe by construction (guarded creates) and now safe under a stale tab (
onblocked/onversionchange), but it is a schema change and worth a deliberate deploy rather than a drive-by.
+ - Only one real database was exercised — the user's, at v2. The v1 shapes are covered by tests, not by a real profile.
+ - F3 (non-idempotent provisioning) is untouched and still needs a product decision. See the smoke-test progress doc.
+
+
+Handoff log
+
+
2026-07-30 begin build-db-schema-owner step 0 F1 follow-up; baseline 799 tests green
+2026-07-30 scope-fix build-db-schema-owner step 0 env-store.ts has NO consumers — latent, not
+ live; smoke-test writeup overstated impact
+2026-07-30 plan-approved build-db-schema-owner step 0 inline plan review; v3 (not v2) required, or
+ the store is never created
+2026-07-30 step-1-done build-db-schema-owner step 1 v3 + guarded creates + blocked/versionchange
+2026-07-30 step-2-done build-db-schema-owner step 2 one indexedDB.open in src/
+2026-07-30 step-3-done build-db-schema-owner step 3 14 tests; 5 fail pre-fix (one by deadlock)
+2026-07-30 step-4-done build-db-schema-owner step 4 real DB v2->v3, 3 projects intact, 810 green
+2026-07-30 complete build-db-schema-owner -- all 4 steps pass; env-store deletion still open
+
+
+
+
+
diff --git a/docs/managed-auth-local-smoke-prd.html b/docs/managed-auth-local-smoke-prd.html
new file mode 100644
index 0000000..cae0cd0
--- /dev/null
+++ b/docs/managed-auth-local-smoke-prd.html
@@ -0,0 +1,215 @@
+
+
+
+
+
+Managed auth — local smoke test
+Task slug managed-auth-local-smoke · branch enable-managed-auth · 2026-07-30
+
+Production was flipped to managed auth in f3f7f85, but the paths that flip
+turns on have never run against real credentials. This is a bounded, evidence-gathering
+run: stand up both processes locally with a real Clerk dev instance and a real OpenRouter
+provisioning key, then drive the browser through the whole funnel and record what actually
+happens.
+
+Definition: what "smoke test" means here
+In this project, a managed-auth smoke test is a single human-path traversal —
+signed-out browser → signed-in → provisioned key → generated app rendering in the preview —
+performed against real Clerk and real OpenRouter, with evidence captured at each boundary
+the flag controls.
+It explicitly is not:
+
+ - a load, security, or penetration test;
+ - a substitute for the 796 unit tests (already green) — those cover logic in isolation, this covers the seams between processes and third parties;
+ - a production verification — localhost with a Clerk dev instance differs from the deployed pairing (see Risks);
+ - a code-change task. Code changes are only in scope as repairs for defects this run surfaces.
+
+
+Why this run exists
+docs/managed-openrouter-migration-plan.md marks these as unvalidated, needing real
+credentials. This run targets exactly them:
+
+ - Phase 2 spike: the OAuth redirect round-trip — it leaves the origin and comes back, which a placeholder key cannot exercise.
+ - Phase 2 spike: the managed agent loop —
/api/agent/step, web_search, web_fetch, web_post have only ever seen fixtures.
+ - Phase 1: lazy provisioning,
402 budget_exhausted, /api/me budget accuracy, or_key_enc not stored in plaintext.
+ - Phase 3: the friction test — landing → signed up → first app rendering, never seeing a key field, provider dropdown, or model name.
+
+
+Constraints
+
+ | Constraint | Consequence for this run |
+ | Both flag halves required | isManagedAuthEnabled() (src/managed-auth.ts:16) needs VITE_MANAGED_AUTH=true and a non-empty publishable key. A blank key silently falls back to BYOK — the run would pass while testing the wrong path. Step 1 must prove managed mode is actually on before anything else counts. |
+ COOP same-origin | Severs window.opener, so popup OAuth can never report back. The gate forces oauthFlow: 'redirect' (src/managed-auth.ts:98). Any popup-based sign-in attempt is a defect, not a workaround. |
+ COEP require-corp | Required for WebContainers. Vite dev sets the same headers (vite.config.ts), so local is a faithful stand-in. Zero external-origin requests is the bar the bundled no-rhc approach claims to meet. |
+ | Real money | The provisioning key mints real budgeted keys and agent calls bill real spend. Keep prompts small; record spend observed. |
+ | No webhook reachability | Clerk cannot deliver a webhook to localhost. CLERK_WEBHOOK_SIGNING_SECRET is deliberately unset, so the run exercises the lazy-provisioning fallback instead. Webhook-delivered provisioning stays unvalidated by this run — a stated gap, not a pass. |
+ | Secrets | Credentials live only in .env.local and server/.env (both gitignored — verified via git check-ignore). No key, token, or JWT is copied into these artifacts, commits, or memory. Evidence records shape and prefix only (e.g. "starts with sk-or-v1-", "does not start with sk-or-"). |
+
+
+Non-goals
+
+ - Deleting the BYOK path (migration plan defers this to post-Phase-5 cleanup).
+ - Clerk Billing / tier upgrades (Phase 4) — the run uses whatever tier a fresh user gets.
+ - Validating the Render deploy or the
/api/* rewrite in production.
+ - Fixing pre-existing defects unrelated to the managed path.
+
+
+Plan
+
+
+
Step 1 — Both processes up, managed mode provably on gate
+
Fill the two env files, start server (:3000) and vite (:5173).
+
Acceptance criteria
+
+ - AC1.1
GET localhost:3000/api/health → 200 {"ok":true}.
+ - AC1.2
GET localhost:5173/api/health → 200 — proves the Vite proxy stands in for the Render rewrite.
+ - AC1.3 Server boots without throwing on
requireEnv; log shows the PGlite data dir.
+ - AC1.4 Server logs the expected
CLERK_WEBHOOK_SIGNING_SECRET not set warning (confirms lazy-provisioning mode, not a misconfiguration).
+ - AC1.5 Managed mode is on: in the browser console,
import.meta.env equivalents resolve such that the sign-in gate — not the app — renders. A rendered app at this point means BYOK fallback and is a hard fail.
+
+
Evidence: curl status lines; server stdout excerpt; screenshot of the first paint.
+
+
+
+
Step 2 — Boot gate and isolation spike
+
Load localhost:5173 in Chrome, signed out.
+
Acceptance criteria
+
+ - AC2.1 Clerk sign-in component renders inside the branded landing shell (not the Account Portal on another origin).
+ - AC2.2
crossOriginIsolated === true in the console.
+ - AC2.3 Zero CORP/COEP errors in the console during load and sign-in.
+ - AC2.4 Network log shows no requests to external origins other than Clerk's own API — specifically none to a CDN for clerk-js/ui chunks.
+ - AC2.5 The Gleam app, WebContainer boot, and IndexedDB access have not started (no WebContainer logs, no IndexedDB databases created).
+
+
Evidence: console snapshot, filtered network request list, screenshot.
+
+
+
+
Step 3 — Sign in with the managed Clerk account never validated
+
Complete sign-up/sign-in. If Google is enabled on the dev instance, prefer the OAuth path since the redirect round-trip is the specific unknown.
+
Acceptance criteria
+
+ - AC3.1 Sign-in completes and the gate's listener resolves (
[managed-auth] gate event with a non-null user).
+ - AC3.2 The redirect round-trip returns to
localhost:5173 in the same tab (no popup, no orphaned window) and the session survives it.
+ - AC3.3
window.Clerk.session.getToken() returns a JWT (recorded as "present, three-segment", never pasted).
+ - AC3.4 The sign-in component unmounts, the shell is removed, and the Gleam app mounts.
+
+
Evidence: console debug lines, screenshots before/after, token shape assertion.
+
+
+
+
Step 4 — Lazy provisioning and budget display never validated
+
First authenticated call triggers inline provisioning (no webhook in dev).
+
Acceptance criteria
+
+ - AC4.1
GET /api/me → 200 with plan, model, limit, limitRemaining.
+ - AC4.2 A
users row exists in PGlite for the Clerk user id.
+ - AC4.3
or_key_enc does not start with sk-or- (encrypted at rest).
+ - AC4.4 The OpenRouter dashboard / management API shows a newly minted key with the tier
limit and limit_reset: "monthly".
+ - AC4.5 The account panel in the UI shows plan and remaining budget matching
/api/me — and shows no key field, provider dropdown, or raw model id.
+
+
Evidence: response body with values redacted to shape; PGlite query output; management API listing; screenshot of the account panel.
+
+
+
+
Step 5 — Full agent round-trip never validated
+
Submit a small prompt and let the agent loop run to a rendered app.
+
Acceptance criteria
+
+ - AC5.1
/api/agent and/or /api/agent/step return 200 with the expected shape; the loop terminates rather than spinning.
+ - AC5.2 A generated app renders in the preview iframe.
+ - AC5.3 A WebContainer boots (install/serve output visible in the terminal pane) — i.e. COEP isolation survived sign-in.
+ - AC5.4 No browser request goes to
openrouter.ai — only /api/* and Clerk.
+ - AC5.5 Spend is attributed to that user's provisioned key, not the provisioning key (compare usage before/after).
+ - AC5.6 At no point in the funnel did a key field, provider dropdown, or model name appear (the friction test).
+
+
Evidence: network log, preview screenshot, terminal excerpt, OpenRouter usage delta.
+
+
+
+
Step 6 — Session lifecycle stretch
+
Only if Steps 1–5 pass; these are the migration plan's remaining Phase 2 criteria.
+
Acceptance criteria
+
+ - AC6.1 A request issued after the ~60s token lifetime still succeeds (fresh
getToken() per request).
+ - AC6.2 Sign-out returns to the sign-in screen (the gate's listener reloads the page).
+ - AC6.3 A replayed post-sign-out token against
/api/me → 401.
+
+
Evidence: timestamped request pair, screenshot, curl status.
+
+
+Validation commands
+
+
curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/api/health
+curl -s -o /dev/null -w '%{http_code}\n' localhost:5173/api/health
+npm test # 197 gleam + 290 vitest (baseline: green)
+cd server && npm test # 309 server (baseline: green)
+
+
+Definition of done
+Steps 1–5 each have every acceptance criterion marked pass with recorded evidence,
+or a criterion is marked fail with a diagnosed root cause and either a repair
+landed or an explicit escalation. Step 6 is best-effort. The final report states plainly which of
+the migration plan's unvalidated criteria this run closed and which remain open — with webhook-
+delivered provisioning and production-instance OAuth named as still-unvalidated regardless of
+outcome.
+
+Judge rubric
+
+ | Area | Judgment |
+ | Correctness | Does each criterion's evidence actually support the verdict, or was a pass inferred from an adjacent observation? |
+ | Path authenticity | Was managed mode provably on throughout? Any BYOK-fallback contamination invalidates the run. |
+ | Security/privacy | No secret, JWT, or raw key in artifacts, commits, or memory. Evidence records shape only. |
+ | Honesty of reporting | Are gaps (webhook, production instance, tier/billing) stated as gaps rather than quietly folded into "passed"? |
+ | Repair quality | If defects were found and fixed, is each fix minimal, idiomatic, and covered by a test that would have caught it? |
+ | Validation | Were the unit suites re-run after any repair? |
+
+Taste/originality applies only to UI or copy changes made as repairs
+(a broken sign-in surface, a bad error state). Pure verification work is not scored on taste.
+Threshold if it applies: below 4 triggers a repair loop.
+
+Risks
+
+ | Risk | Handling |
+ | Blank publishable key → silent BYOK fallback → false pass | AC1.5 makes this a gate before any other step counts. |
+ | Dev instance ≠ production instance | Google OAuth on dev uses Clerk's shared credentials; production needs your own. A dev pass does not clear production — stated in the report. |
+ | Real spend on a real key | Small prompts; spend delta recorded; the provisioned key's own limit caps blast radius by design. |
+ | Runaway agent loop | AC5.1 requires termination; abort and diagnose rather than letting it spin. |
+ | Local PGlite state leaking between attempts | server/.data is gitignored; note when it is cleared, since a stale row changes whether provisioning is lazy or a no-op. |
+
+
+
+
+
diff --git a/docs/managed-auth-local-smoke-progress.html b/docs/managed-auth-local-smoke-progress.html
new file mode 100644
index 0000000..b756f86
--- /dev/null
+++ b/docs/managed-auth-local-smoke-progress.html
@@ -0,0 +1,320 @@
+
+
+
+
+
+Progress — managed auth local smoke test
+Task slug managed-auth-local-smoke · branch enable-managed-auth · PRD: managed-auth-local-smoke-prd.html
+
+Status: complete — all six steps pass,
+end to end, against real Clerk and real OpenRouter. One security-relevant defect was found and
+fixed (sign-out never revoked the session); one unrelated pre-existing defect is documented
+and left for the user to schedule.
+
+Headline results
+
+ - The Phase 2 spike is fully closed.
crossOriginIsolated === true with
+ Clerk loaded, zero COEP/CORP errors, and zero CDN requests (250 resources: 248 local,
+ 2 to Clerk's API). The OAuth redirect round-trip — explicitly listed as unvalidatable with a
+ placeholder key — completes in the same tab and the session survives it.
+ - The managed agent loop works against a real model. One prompt → 9 tool steps →
+ 2 files written → the agent verified its own build → the app rendered in a live WebContainer.
+ Zero browser requests to
openrouter.ai, and the $0.0122 of spend landed
+ on that user's provisioned key.
+ - Sign-out was silently broken, and is now fixed. Found only because the run
+ checked Clerk's own API rather than trusting the UI. See F4.
+
+
+Blocker
+Three real credentials are needed; the run cannot start without them, and starting without them
+would produce a false pass (blank publishable key ⇒ silent BYOK fallback ⇒ the smoke test
+exercises the pre-managed path while appearing to succeed).
+
+ | File | Key | Source | State |
+ .env.local | VITE_MANAGED_AUTH | — | set true |
+ .env.local | VITE_CLERK_PUBLISHABLE_KEY | Clerk dev instance → API keys → Publishable key (pk_test_…) | empty |
+ server/.env | CLERK_SECRET_KEY | Clerk dev instance → API keys → Secret key (sk_test_…) | empty |
+ server/.env | OPENROUTER_PROVISIONING_KEY | OpenRouter → Settings → Provisioning keys (not a normal API key) | empty |
+ server/.env | KEY_ENCRYPTION_SECRET | generated locally | set |
+ server/.env | PGLITE_DATA_DIR | — | set .data |
+
+Both files are gitignored — confirmed with git check-ignore -v:
+.gitignore:7:.env → server/.env and .gitignore:9:.env*.local → .env.local.
+
+Pre-flight findings
+
+ | Check | Result |
+ | Toolchain | pass gleam 1.16.0 (matches GLEAM_VERSION in render.yaml), node v22.22.0 (matches engines.node >=22). Ports 3000 and 5173 free. |
+ | Local ≈ production topology | pass vite.config.ts proxies /api and /webhooks to localhost:3000 and sets the same COOP: same-origin + COEP: require-corp that render.yaml sets. Local is a faithful stand-in for the Render rewrite. |
+ Existing OPENROUTER_API_KEY usable for provisioning? | no GET openrouter.ai/api/v1/keys → 401 {"error":{"message":"Invalid management key"}}. It is a normal inference key; a separate provisioning key is required. |
+ | Clerk credentials present anywhere locally | no Not in env, not in shell profiles, no .env.local, no server/.env. Render has them as sync: false dashboard values. |
+ Baseline: gleam test | pass 197 passed, no failures |
+ Baseline: vitest run (frontend) | pass 290 passed / 19 files |
+ Baseline: vitest run (server) | pass 309 passed / 13 files |
+
+Baseline total: 796 tests green before any change. Any red after a repair is
+attributable to this run.
+
+Step checklist
+
+ | Step | Status | Evidence |
+ | 1 — Processes up, managed mode provably on | pass | 5/5 criteria — see below |
+ | 2 — Boot gate and isolation | pass | 5/5 criteria — see below |
+ | 3 — Sign in (OAuth redirect round-trip) | pass | 4/4 — Google OAuth, user user_3ExWw… |
+ | 4 — Lazy provisioning and budget display | pass | 5/5 — key minted, encrypted at rest, budget shown |
+ | 5 — Full agent round-trip | pass | 6/6 — app rendered, spend correctly attributed |
+ | 6 — Session lifecycle | pass | 3/3 — after the F4 repair |
+
+
+Step 3 — evidence
+
+ | AC | Verdict | Evidence |
+ | 3.1 gate resolves | pass | User user_3ExWwDyvFfzrokOxgnth2cZk4W5 (tom@hyper.io), session sess_3HEdwx…, external account provider google |
+ | 3.2 redirect round-trip | pass | Returned to localhost:5173 in the same tab; session intact. This is the item the migration plan said "a placeholder key cannot exercise". |
+ | 3.3 token issued | pass | JWT present, 3 segments, 820 chars, 60s lifetime (exp-iat). Value never recorded. |
+ | 3.4 app mounts | pass | Shell removed, Gleam app mounted, __buildManagedAuth registered, WebContainer preview live |
+
+
+Step 4 — evidence
+
+ | AC | Verdict | Evidence |
+ 4.1 /api/me | pass | 200 in 1220 ms — {plan:"free", model:"qwen/qwen3.6-35b-a3b", limit:5, usage:0, limitRemaining:5, toolCapable:true, maxToolSteps:8}. Lazy provisioning fired with no webhook, as designed. |
+ 4.2 users row | pass | 1 row: clerk_user_id=user_3ExWw…, tier=free, disabled=false |
+ | 4.3 encrypted at rest | pass | or_key_enc is a 101-byte Uint8Array; does not start with sk-or- and does not contain that substring anywhere |
+ | 4.4 key at OpenRouter | pass | build-user-user_3ExWw… created 2026-07-30T18:51:06Z, limit: 5, limit_reset: "monthly" |
+ | 4.5 account panel | pass | "Plan: free", "Budget: $4.99 of $5.00 remaining this month" — matches /api/me after spend. No provider dropdown, no model name. (See F5 on the ScoutOS publishing field.) |
+
+
+Step 5 — evidence
+
+ | AC | Verdict | Evidence |
+ | 5.1 loop terminates | pass | "9 steps · 2 files · checked it builds" — the harness ran tools, self-verified, and stopped |
+ | 5.2 app renders | pass | Heading "Smoke Test OK" on a pale-green background, exactly as prompted; src/style.css + src/main.tsx patched |
+ | 5.3 WebContainer boots | pass | Preview served from …local-corp.webcontainer-api.io — COEP isolation survived sign-in |
+ | 5.4 no direct provider traffic | pass | Resource origins after a full session: localhost:5173 ×211, Clerk ×7, stackblitz.com ×1, webcontainer preview ×2. Requests matching openrouter.ai: 0. |
+ | 5.5 spend attribution | pass | New key usage 0 → 0.012206708. The user's older June key stayed at 0.121544874. Provisioning key itself not charged. |
+ | 5.6 friction test | pass | Sign-in → prompt → rendered app with no key field, provider dropdown, or model name anywhere in the path |
+
+
+Step 6 — evidence
+
+ | AC | Verdict | Evidence |
+ | 6.1 token refresh | pass | Paired control past expiry: fresh getToken() → 200; the token captured 60s earlier → 401 unauthorized. Proves both per-request refresh and real server-side expiry. |
+ | 6.2 sign-out returns to gate | fail → pass | Initially failed (F4). After the repair: gate rendered, and Clerk reports the session removed. |
+ | 6.3 old token rejected | pass | 401 on replay. Caveat recorded honestly: the replayed token was already past its 60s expiry, so this demonstrates expiry enforcement, not instant revocation. Clerk verification is networkless, so a still-valid token remains accepted for up to 60s after sign-out by design. |
+
+
+Step 1 — evidence
+
+ | AC | Verdict | Evidence |
+ | 1.1 api health direct | pass | GET :3000/api/health → 200 {"ok":true} |
+ | 1.2 health via proxy | pass | GET :5173/api/health → 200 {"ok":true} — the Render rewrite stand-in works |
+ | 1.3 clean boot | pass | [build-api] pglite data dir: .data / listening on :3000; no requireEnv throw |
+ | 1.4 lazy-provisioning mode | pass | CLERK_WEBHOOK_SIGNING_SECRET not set — /webhooks/clerk disabled, relying on lazy provisioning — expected, not a misconfiguration |
+ | 1.5 managed mode provably on | pass | First paint is the Clerk gate, not the app. gleamAppMounted: false. No BYOK contamination. |
+
+Bonus, unplanned: unauthenticated GET /api/me → 401 unauthorized, and a
+garbage bearer token → 401 as well. Server-side JWT rejection is wired correctly through
+the proxy.
+
+Step 2 — evidence
+
+ | AC | Verdict | Evidence |
+ | 2.1 gate renders inline | pass | Clerk component renders inside the branded landing shell alongside the "What do you want to build?" panel — not bounced to the Account Portal. "Development mode" badge confirms the dev instance. |
+ | 2.2 isolation | pass | crossOriginIsolated === true with Clerk.status === "ready" |
+ | 2.3 no COEP/CORP errors | pass | Console contains only the two expected [managed-auth] debug lines. Zero errors. |
+ | 2.4 no external origins | pass | 250 resources: 248 localhost:5173, 2 fond-moose-70.clerk.accounts.dev. Zero CDN requests — the whole point of the no-rhc bundling. |
+ | 2.5 app not started | pass | webContainerBooted: false, managedAuthBridge: false, gleamAppMounted: false, Clerk.user === null. See the IndexedDB note below. |
+
+
+Findings
+
+F1 — env-store.ts opens build-db at the wrong version pre-existing defect fixed 2026-07-30
+Fixed in the follow-up task build-db-schema-owner:
+projects.ts is now the sole schema owner at version 3 and creates the env-store
+store; env-store.ts delegates to openBuildDb. Verified against the user's real
+database — v2 → v3 with all 3 projects, their files, messages, and
+current-project-id intact.
+Correction to what this section originally claimed. It described the defect as
+breaking the env-vars feature. It did not: env-store.ts has no consumers
+anywhere in the repo — the string appears only in the module and its own test. This was a
+latent defect in an unused module, which would have bitten whoever wired it up. The
+VersionError reproduction below is real; the user-facing impact was overstated.
+Not caused by managed auth — recorded because it was found in the course of
+clearing AC2.5.
+src/projects.ts:33 opens build-db at DB_VERSION = 2. Its own
+comment (projects.ts:30) states the rule: "build-db must be opened at ONE
+version, and two modules opening it at different versions throws on whichever is second."
+src/env-store.ts:2 opens the same database at DB_VERSION = 1 — breaking
+exactly that rule.
+Measured in the live profile: build-db is currently at version 1 with
+stores ['meta','projects'] — no workspace, no env-store. So the
+ordering is currently benign only because projects.ts has not yet upgraded it this session.
+Once projects.ts opens at v2, a subsequent env-store open at v1 fails with a
+VersionError (requested version is less than existing). Both open lazily and memoize, so
+which one loses depends purely on call order.
+Also worth noting: both modules are memoized per module instance, and the Gleam build
+mirrors src/ into build/dev/javascript/build/ — the same duplicate-module
+hazard managed-auth.ts:65 already works around via globalThis.
+
+F2 — IndexedDB pre-existence is not a gate leak resolved
+indexedDB.databases() reports build-db while signed out, which naively
+reads as an AC2.5 violation. It is not: both openBuildDb() (projects.ts:43)
+and openDb() (env-store.ts:7) open lazily inside memoized functions, with no
+module-level side effect. The database is a leftover from earlier dev sessions on this origin.
+Deliberately not deleted to confirm this — it holds the user's projects.
+
+Decisions taken
+
+ - Clerk dev instance, not production. Dev keys work on localhost and Google OAuth uses Clerk's shared dev credentials. Consequence: a pass here does not clear the production instance, which needs its own Google OAuth credentials and localhost as an allowed origin.
+ - Webhook secret deliberately unset. Clerk cannot deliver to localhost, so
/webhooks/clerk stays disabled and the run exercises the lazy-provisioning fallback instead. Webhook-delivered provisioning remains unvalidated by this run.
+ - Full round-trip scope including real model spend, per user direction. The provisioned key's own limit caps blast radius.
+ - Subagents not used. Session policy disallows spawning them; the LFG planner and judge roles are performed inline against the same rubric. Noted so a re-entering session does not look for agent output that never existed.
+
+
+F3 — provisioning is not idempotent across a lost users table design risk, now demonstrated
+Lazy provisioning minted a second OpenRouter key for a user who already had one. Both are
+live right now:
+
+
build-user-user_3ExWwDyvFfzrokOxgnth2cZk4W5 created 2026-06-10 limit 1 usage 0.1215
+build-user-user_3ExWwDyvFfzrokOxgnth2cZk4W5 created 2026-07-30 limit 5 usage 0.0122 <- minted by this run
+
+Cause: the server decides whether to provision from its own users table, and this
+local PGlite was empty. It never asks OpenRouter whether a key of that name already exists. The old key
+is now orphaned — still enabled, still carrying budget, and no longer referenced by any row.
+render.yaml already names this hazard ("a lost users table orphans every provisioned
+OpenRouter key") and mitigates it with the persistent disk. This run confirms the failure mode is real
+rather than theoretical, and shows it also triggers whenever a second environment points at the
+same provisioning key — which is exactly what a local dev setup does. There are 7 build-user-*
+keys on this provisioning key today.
+Not fixed here — the remedy is a product decision (reuse-by-name lookup on provision,
+or a cleanup pass for unreferenced build-user-* keys), not a smoke-test repair.
+
+F4 — sign-out never revoked the session security-relevant fixed
+The most valuable thing this run found. Clicking "Sign out" in the account panel
+appeared to work — the panel closed and the page reloaded — but the user stayed signed in, and Clerk's
+own API still reported the session active, with an expire_at roughly a week out.
+Root cause (a race between two correct-looking pieces of code):
+
+ managedSignOut() (src/gleam-externals/managed.mjs:16) fires
+ void __buildManagedAuth.signOut() — deliberately unawaited.
+ - Clerk's
signOut() clears user locally and synchronously, then
+ sends the revoke request.
+ - The boot gate's listener (
src/managed-auth.ts) saw !user and called
+ window.location.reload() immediately.
+ - The navigation aborted the revoke request in flight. After reload, Clerk restored the session from
+ the still-valid cookie.
+
+Every individual piece looked right, which is why unit tests missed it and why only an out-of-band
+check against Clerk's API could catch it. A user signing out on a shared machine stayed signed in.
+Fix: signOut() now sets a signingOut guard, awaits
+clerk.signOut() to completion, and only then reloads. The gate's listener ignores the
+user → null event while that guard is set, so a session ending elsewhere (expiry,
+another tab) still returns the visitor to the gate.
+Verified end to end: re-running the exact unawaited call the account panel makes
+now drives the session from active to removed at Clerk, and the gate renders.
+
+F5 — one key field does remain in the account panel by design
+The friction test passes for the build path, but the account panel does contain a
+ScoutOS API key field (for publishing). This is not a provider/LLM credential and is
+never required to build. Phase 3's own criterion scopes it out ("no hits outside the account panel").
+Recorded so nobody later reads "no key fields anywhere" into this result.
+
+Repair log
+
+ | # | Step | Diagnosis | Action | Result |
+
+ | 1 | 6 (AC6.2) |
+ F4 — reload raced the in-flight revoke request. Diagnosed by querying Clerk's session API directly rather than trusting the UI, which is what turned "looks fine" into "provably still active". |
+ Guarded + awaited signOut() in src/managed-auth.ts; added src/managed-auth.test.ts (3 tests). |
+ pass — session now removed; gate renders; suites green |
+
+
+The new test was checked against the pre-fix code and fails there
+("does not reload until the revoke request has actually settled"), so it genuinely locks the bug out
+rather than merely passing alongside the fix.
+
+Validation after repair
+
+
gleam test 197 passed, no failures
+vitest run 293 passed / 20 files (+3 new, was 290/19)
+server vitest 309 passed / 13 files
+ ---
+ 799 green
+
+
+Files changed
+
+ src/managed-auth.ts — awaited, guarded sign-out (the F4 fix)
+ src/managed-auth.test.ts — new, 3 regression tests
+ docs/managed-auth-local-smoke-prd.html, docs/managed-auth-local-smoke-progress.html — these artifacts
+ .env.local, server/.env — local credentials, gitignored, not committed
+
+
+What this run did NOT validate
+
+ - Webhook-delivered provisioning. Clerk cannot reach localhost;
/webhooks/clerk was disabled throughout. Only the lazy fallback is proven. user.deleted cleanup is entirely unexercised.
+ - The production Clerk instance. This was a dev instance; its Google connection uses Clerk's shared credentials. Production needs its own Google OAuth credentials — the caveat
render.yaml already carries.
+ - The Render deploy path — the
/api/* rewrite was stood in for by the Vite proxy, which is equivalent in shape but not the same infrastructure.
+ - Budget exhaustion (402). Not exercised — it would have required burning $5 or provisioning a throwaway low-limit key.
budget_exhausted remains fixture-tested only.
+ - Tiers/billing (Phase 4) — the run only ever saw the free tier.
+ - Instant revocation. By design, a valid token is accepted for up to 60s after sign-out (networkless verification).
+
+
+Handoff log
+
+
2026-07-30 begin managed-auth-local-smoke step 0 pre-flight complete, 796 tests green
+2026-07-30 blocked managed-auth-local-smoke step 1 awaiting VITE_CLERK_PUBLISHABLE_KEY,
+ CLERK_SECRET_KEY, OPENROUTER_PROVISIONING_KEY
+2026-07-30 unblocked managed-auth-local-smoke step 1 Clerk keys supplied; pk+sk verified same
+ dev instance fond-moose-70; secret key
+ relocated .env.local -> server/.env
+2026-07-30 step-1-done managed-auth-local-smoke step 1 5/5 AC pass; both processes up
+2026-07-30 step-2-done managed-auth-local-smoke step 2 5/5 AC pass; crossOriginIsolated true,
+ 0 CDN requests, 0 COEP errors
+2026-07-30 needs-human managed-auth-local-smoke step 3 sign-in must be performed by the user
+ (no password entry / account creation by
+ the agent); provisioning key still needed
+ for steps 4-5
+2026-07-30 step-3-done managed-auth-local-smoke step 3 Google OAuth redirect round-trip works
+2026-07-30 step-4-done managed-auth-local-smoke step 4 lazy provisioning + encryption at rest
+2026-07-30 step-5-done managed-auth-local-smoke step 5 agent loop rendered an app; 0 openrouter.ai
+ hits; $0.0122 on the user's own key
+2026-07-30 repairing managed-auth-local-smoke step 6 F4: sign-out left session active at Clerk
+2026-07-30 step-6-done managed-auth-local-smoke step 6 F4 fixed + regression test; session now
+ 'removed'; 799 tests green
+2026-07-30 complete managed-auth-local-smoke -- all 6 steps pass; F1/F3/F5 documented,
+ not fixed (out of scope, user's call)
+
+
+
+
+
diff --git a/docs/managed-openrouter-migration-plan.md b/docs/managed-openrouter-migration-plan.md
index 1d86840..280a24b 100644
--- a/docs/managed-openrouter-migration-plan.md
+++ b/docs/managed-openrouter-migration-plan.md
@@ -91,6 +91,20 @@ create table users (
3. `gleam-externals/agent.mjs`: fetch a fresh token via `clerk.session.getToken()` per request (Clerk tokens are short-lived) and send `Authorization: Bearer