Lever A of the gray-cloud switch fix: get_workspace was the ONLY switch-time
input still proxied to the live container. For worktree-mode (container)
workspaces its result is 100% DB-derived, so mirror it and answer from the
control plane — switching never depends on the container being awake.
- D1: new workspace_details table (opaque payload, write-path self-heal
CREATE — the git_snapshots precedent, no migration step needed).
- PUT /team/sync: new workspaceDetails rows (exact WorkspaceDetail
serialization); GET /team/workspace-detail serves them member-readable,
D1-only. Local-dev registry mirrors the semantics in-memory.
- Container (team_sync.rs): push one workspace's detail on WorkspaceChanged /
SessionListChanged / WorkspaceChangeRequestChanged, all workspaces on
WorkspaceListChanged, plus a full push in the startup backfill. Detail-
mirrored events defer their realtime broadcast until the mirror lands
(same P1-6b contract as the session mirror).
- Desktop: loadWorkspaceDetail branches to the mirror in team mode (the
loadWorkspaceSessions pattern); a null mirror falls through to /rpc.
Cold start previously minted EVERY member's ForgeIdentity DO and wrote
the whole team's gh/glab tokens into the shared container — one induced
'cat' away from a full-team credential leak. Per the user product
ruling the creator-identity model stays (everyone's pushes reuse the
creator's forge identity); only the storage narrows:
- teams.forge_identity_member_id: the forge 'creator', bound
FIRST-authorizer-wins on PUT /team/forge-identity (mirrors
cloud_identity_member_id's single-row upsert, but COALESCE keeps an
existing binding; a non-creator racing to authorize first is the same
accepted TOFU edge as cloud identity). Added via the P1-4c idempotent
D1 ALTER runner (0002) + schema.sql for fresh DBs.
- collectMemberForgeCreds injects tokens ONLY for the creator entry
(flagged creator:true); every other member is login-only (kept for
per-member commit authorship). Local-docker launcher mirrors the same
narrowing.
- member_creds.rs (CONTAINER RUST — needs the next team-image bump):
token lookups fall back to the creator's tokens when the acting
member has none, and when no acting member is bound at all (the
detached auto-push thread). Old injected files without the creator
flag still parse (no fallback). Authorship lookups (member_for) do
NOT fall back.
NOTE: P1-8a (scrubbing the token out of .git/config) depends on this
creator fallback for push auth — rolling back this commit requires
rolling back the P1-8a commit with it.
schema.sql claimed "the orchestrator runs the ALTER once at deploy" — no such
orchestrator existed. Any D1 predating a column (long-lived dev DBs;
re-provision onto an already-set-up account, where d1 create and the schema
apply both no-op) kept missing cloud_identity_member_id and team.ts's
INSERT/SELECT hit "no such column" → 500, and the next additive column would
have hit the same wall with no runner.
- d1-migrations.ts: additive-migration list + classifyMigrationError —
SQLite's "duplicate column name" (fresh DB / re-run) is already-applied,
everything else is fatal. Comment documents the upgrade path: if migrations
ever stop being additive/duplicate-classifiable, move to a versioned
schema_migrations table instead of stretching this list.
- provision-team.ts executes the list right after the schema apply on every
provision / re-provision.
- schema.sql's lying migration comment rewritten to the real mechanism.
Validated against a real D1 (scratch DB, deleted after): old-shape teams
table → migration exit 0 and the column SELECTs; re-run → wrangler-wrapped
"duplicate column name … SQLITE_ERROR [code: 7500]" (captured verbatim into
the test) → classified already-applied.
The composer's model list (list_agent_model_sections) previously proxied
through ensureServe, so opening the app against a sleeping container burned a
cold start (up to 180s) just to render the model picker — and the WP1
readiness probe (/v1/health through the same proxy) woke the container too.
Cache the catalog (metadata only, never credentials) in a new D1
`model_catalog` row and answer the RPC BEFORE ensureServe:
- Worker intercept: authed (member/admin, mirrors /admin/*) cache hit returns
the verbatim payload with `x-helmor-model-catalog: cached` and ZERO
container interaction; a miss falls through to the normal ensureServe+proxy
path (the one legitimate non-@agent wake: brand-new/reset teams —
provision's WP6 verify normally seeds the cache at setup).
- Invalidation, no TTL: write-through on every LIVE pass of the RPC, plus
ensureServe now reports `coldStarted` and every wake (including
/admin/warm-up) refreshes the cache in the background — an image update is
picked up on the first real wake after it.
- Self-healing schema: the write path batches the CREATE TABLE, so a pre-WP5
D1 that never re-ran schema.sql works without a manual migration; a missing
table reads as a plain cache miss.
- local-dev proxy mirrors the intercept with an in-memory registry cache so
the no-wake behavior is e2e-verifiable on local-docker.
No container/Rust changes — no image publish needed for prod rollout.
Data-plane foundation (additive; dead until the desktop is wired in B.3):
- schema.sql: new `sessions` + `messages` mirror tables (browse-essential session
columns; append-only message rows w/ verbatim content JSON). Mirrors the
sandbox's SQLite so history is readable with the container asleep.
- team-gateway/core.ts: TeamSyncInput types + 3 routes — PUT /team/sync (container
write-through, companion-gated), GET /team/sessions, GET /team/messages
(member-or-admin reads).
- team.ts: D1-backed store impls — idempotent id-keyed upserts, append-only
message inserts (ON CONFLICT DO NOTHING), cascade deletes, batched.
- local-dev registry + proxy: matching in-memory mirror so `bun run dev:team`
serves /team/sessions|messages the same way.
- contract test: round-trip + auth (member can't write the mirror; unauth can't read).
Worker typecheck + 46 cloud tests green. Schema is applied to the live D1 and the
Worker redeployed in B.3, when the desktop starts reading these routes.
D1 control-plane registry (members/invites/teams/workspaces) behind /team/*
JSON routes, plus the proxy-path member derivation: the invite token is the
member's bearer; the Worker maps it to member_id, strips any client-supplied
X-Helmor-Member-Id, injects the derived id, and swaps in the shared companion
token for the container hop (never client-asserted). Verified on real CF:
invite round-trip (bootstrap/invite/accept/members) + gating (garbage->401,
shared->200, accepted-invite->200). Additive — the Phase 0 proxy is untouched.