phantasia (φαντασία) — Greek: imagination, the faculty that forms images. The standalone Studio every Appolis app taps into.
This is a living document. Update it with every change that lands — same rule as every project.
phantasia-engineF:\Claude Code\phantasia-engine · deploy: node build.js && npx wrangler deployphantasia (818ccab1-392c-48d1-a110-33e69a3bba9d) · R2 flipper-studio (shared with flip-cms — bytes never moved in the extraction)node server/api.js → http://localhost:3999 (FLIPPER local proxies to it via STUDIO_URL, default this port)node --test --test-force-exit test/api.test.js — 26/26finally and no catch. So the raw abort text went straight past the message, which is precisely what Tyler saw. Both phases are handled now, and the message names which limit was hit, how long it ran, and that nothing was saved. Found by a test, not by reading the code a second time. ⚠️ The idle floor is 5s rather than 20s specifically so it can be exercised in a test — a limit nobody can reach in a test is a limit nobody checks. The default a real caller gets is 90s of silence. ⚠️ Writing that test also caught an unrealistic mock: a reader whose read() never settles and never listens for the signal simply hangs, so the first version timed out instead of asserting anything. Real fetch errors the body stream on abort; the mock models that now, or it proves nothing about production either way. 30 tests, 0 failing.useModel falls back to the default for anything off that list, a request naming it would have run on Opus 4.8 without a word. A whitelist that SUBSTITUTES is worse than one that refuses: the bill and the output both change and nothing says so. ⚠️ A MODEL IS ONLY ADDED WHEN IT IS ADDED IN THREE PLACES, and the two easy ones to forget are the two that fail silently: EFFORT_MODELS (sending output_config to a model that rejects it is a 400 the USER discovers, not the developer) and PRICING.copy_brief.model_mult (a missing multiplier does not throw — it prices a premium model at the base rate, so the usage board under-reports until the invoice does the noticing). Opus 5 is now in all three, and a test asserts every selectable model carries a price, that the prices ORDER correctly (Haiku < Sonnet < Opus 5 < Fable), and that Haiku 4.5 is never sent an effort setting. That last one matters twice over: Haiku is the cheapest entry in the picker, so an unconditional send would break exactly the option someone chooses to save money. 29 tests, 0 failing.maxTokens was declared in anthropicCopy's signature and server/api.js — the ONE caller in five repos — never passed it, so every request in the product still ran on 8000, and the error text told the reader to "Raise maxTokens for this call": advice pointing at a wire nobody had run. That is what sent the second diagnosis down the wrong road too. ⚠️ AND THE PREMISE OF THE FIRST DIAGNOSIS WAS WRONG. The failing response carried a thinking block with no thinking parameter sent, which was read as "thinking is on by default". The docs say otherwise for the seeded model: Opus 4.8 defaults thinking OFF, while Fable 5 is always on and cannot be disabled at all (an explicit {type:"disabled"} returns 400) and Sonnet 5 is on by default. So the run was NOT on the default model — the tool's slug had drifted to a model that reasons by default and costs $50/MTok against Haiku's $5, a 100× spread on the same click, while the estimator records a flat 3¢ whatever happens. WHAT SHIPPED. ① The budget is genuinely reachable: api.js forwards max_tokens and effort from the request body, and the ceiling is 64000. ② The call STREAMS. A non-streaming request at this size is where the docs stop recommending a synchronous call and start pointing at Batch, because some networks drop an idle connection — and the SDK protections against that do not apply here, since this calls the raw endpoint with fetch. Streaming keeps the connection producing events for its whole life, which removes that failure rather than betting against it. ⚠️ Only text_delta is concatenated: thinking_delta arrives on the SAME channel, and appending it would splice the model's reasoning into the middle of a JSON document the caller is about to parse. ③ effort, gated by model. Raising the ceiling alone leaves effort at its default of high, which the docs name as the regime most likely to exhaust the budget — so a bigger number can be eaten the same way, just more expensively. ⚠️ Haiku 4.5 is absent from the documented supported-models list (which spells another model's full dated id out, so the omission is a decision) and rejects output_config with a 400 — and Haiku is the CHEAPEST entry in the dropdown, so an unconditional send would break exactly the option someone picks to save money. ④ A timeout. There was no timeout, no abort and no cancel anywhere in this chain: a hung request hung until something else gave up, while already being billed. ⑤ Usage comes back — output tokens and thinking tokens — so the next budget is a measurement rather than a floor, and the empty-answer error now names the budget it actually spent instead of advising an unreachable knob. ⑥ 💸 AND A BILLED FAILURE NOW LEAVES A ROW. The ai_jobs INSERT sat AFTER the await, so any throw meant Anthropic had billed the work and the ledger recorded nothing — the usage board showed a quiet day, the owner saw a failure, and the natural response is to press the button again and pay twice. There is also no spend cap on the browser path (the cap is gated on via === 'connector'), which makes the silence worse. ⚠️ THE LEDGER FIX WAS SILENTLY DOING NOTHING WHEN FIRST WRITTEN: chosen was declared inside the try, so the catch referenced a variable that did not exist there and threw a ReferenceError into its own swallow-and-continue. The row never appeared. A test caught it; reading the code twice had not. TESTS: the suite mock now answers with real SSE — including a thinking delta that must never surface — because a mock replying with one whole JSON body tests a path the product no longer takes and would stay green while the shipped reader was broken. 28 tests, 0 failing. Seven mutants verified red: streaming off, budget wire cut, effort sent to Haiku, thinking spliced into the answer, ceiling back to 16000, failure recorded as done, and chosen moved back inside the try. ⚠️ STILL TRUE FROM v0.7.3: wrangler.jsonc holds four live plaintext secrets, so there is still no git here and every edit is backed up by hand. Sequence remains: move them to wrangler secret put, THEN init. ⚠️ NOT MEASURED: no real Anthropic call has been made through this path — the streaming reader is proven against a faithful mock, not against the live API, and the wall-clock cost of a 32000-token lane run through the Kosmos→Phantasia→Anthropic chain is unknown.anthropicCopy sent max_tokens: 2000, hardcoded. That was ample while every caller wanted a headline or a paragraph — but Kosmos's lander intake asks Claude for an ENTIRE landing page as one JSON document, every section and every prop, which does not fit. The budget was spent before a single text block closed, so the response came back carrying no text at all. ⚠️ AND THE ERROR MISDIAGNOSED ITSELF. It threw a bare Claude returned no text, which points a reader at the API key, the model, or the account — everything except the cause. The response knew: it carried stop_reason, and the line discarded it. An empty answer is a MEASUREMENT; it now reports stop_reason and the block types actually returned, and says what to do when the cause is the cap. max_tokens is now a caller-overridable ceiling defaulting to 8000 — a ceiling is not a reservation, so raising it costs nothing unless the model genuinely writes more. ⛔ Do not lower it back to fit the smallest caller. Verified with a stubbed API: default cap 8000, a caller can still pin it lower, normal text still returns, and a max_tokens/thinking-only response now produces Claude returned no text (stop_reason=max_tokens; blocks: thinking) — the token budget was spent before any text block closed. ⚠️ NO GIT HERE, AND IT COST SOMETHING TODAY. Initialising a repo before this edit was the obvious safety move and was DELIBERATELY NOT DONE: wrangler.jsonc carries four live secrets in plaintext (INTERNAL_KEY, SIGN_KEY, APP_KEY, and ID_SECRET), so a fresh history would have permanently recorded them — the exact mistake Kosmos todo_1699 exists for. The ID_SECRET value here is byte-identical to appolis's, making this a FOURTH repo holding a secret its own rotation runbook already calls burned. Sequence is: move those four to wrangler secret put, THEN init git. Until then every edit here is backed up by hand and has no revert. ⚠️ Also worth recording: the documented deploy is node build.js && npx wrangler deploy and only the second half was run. It was harmless this time — build.js precompiles web/studio.html's JSX and nothing else, and this change is server-side, reached by wrangler through worker.js → server/api.js → integrations/ai-providers — but the same shortcut on a UI change would have shipped a stale front end while reporting success.The AI Studio extracted out of FLIPPER (batch 39, 2026-07-14) into its own Cloudflare Worker so every business/app on the account — FLIPPER today, Kosmos next, anything after — taps the same generation engine while keeping its own brain: tools, learnings (prefs), keys, billing, assets, files. Grown inside FLIPPER across batches 27–38; everything below moved here verbatim.
The honest performance story: same-account service bindings are zero-latency RPC — extraction does not speed up single requests. What it buys: Studio D1 traffic no longer shares a database with 274k orders and CS queue caches; a Studio deploy no longer redeploys the live CS app; new apps get the whole Studio by adding one binding; per-business brains come free because every table was already tenant-scoped.
Flip CMS (Hermes) ──service binding──▶ phantasia-engine ──▶ D1 "phantasia"
kosmos ──service binding──▶ │ ──▶ R2 "flipper-studio"
appolis hub (🔌) ──service binding──▶ │ (/internal/mcp — the AI room)
public ── phantasia.appolis.app ───┤ (standalone UI + /f/ + /onset)
appolis (🪪 ID) ◀──service binding────────── ┘ (/id/check · /id/resolve)
◀──service binding────────── ┘ hermes (/internal/overview — company brains)
Which door a request came through decides what it may do — that is now load-bearing, not incidental (§7). ctx.via is one of binding (a host app's embedded Studio: a human), session / appolis (the standalone UI: a human), connector (the AI room: a machine — may stage a generation, never spend, never approve), or dev. Identity headers are only honoured on a non-public hostname.
Four front doors:
x-studio-internal (must equal INTERNAL_KEY) + x-studio-tenant/-user/-user-name/-user-email/-role. The profiles table mirrors identities from these headers on every request, so job/asset labels never need the host's user table.phuser cookie). One-time first-admin bootstrap is APP_KEY-gated (?key=…), same pattern as FLIPPER./id/check over the APPOLIS_ID service binding; an account entitled to phantasia (or the master wildcard *) signs in right here and gets the shared appolis_id cookie (Domain=.appolis.app, 7d) so every other suite app opens too. SSO fallback: no binding headers + no phuser → the shared cookie verifies locally (HMAC vs ID_SECRET, cheap reject) then resolves over /id/resolve; entitlement checked on EVERY resolve, so a revoked grant locks the door immediately. Lazy provisioning: an Appolis identity maps onto a profile by profiles.appolis_id, else adopts an existing password-bearing login by email (Tyler's master email → his Spartan Studios admin; mirror-only connector rows never match), else mints a fresh isolated t_ws_… workspace with the newcomer as its admin — the v0.3.0 isolation rule holds. Sign-out clears BOTH cookies (the suite cookie IS an SSO user's session — leaving it would sign them straight back in). The any-door rule stands: this login screen keeps working forever; the portal is never the gatekeeper.POST /internal/mcp) — the suite hub forwards raw JSON-RPC here over its binding under ID_SECRET, naming the person by email. Every tool is a thin wrapper over the same REST routes the UI calls (the door re-enters handleFetch with binding-trust headers), so key resolution, billing, tenant isolation and 🏛 house-pipeline sealing apply unchanged — no duplicated permission logic, ever. Two rules this room enforces that the rest of the app cannot: it cannot spend (generation is staged for a human — todo_1061) and it cannot outlive access (brains are re-resolved from Hermes on every call — todo_1062). list_brains shows which brains are reachable right now; brain on any tool picks one, defaulting to the person's own workspace.Public surface: exactly GET /f/:id?e=<epoch>&t=<hmac> — signed share links (SIGN_KEY HMAC over fileId.expiry) — plus the 🎬 On-Set slate at /onset and its /api/onset/* session + shared-clock endpoints (crew phones, no login by design; every field length-capped). Everything else 401s without an identity.
Tenancy: tenants (id, name, brand JSON). FLIPPER = t_verdant (kept from before the extraction so every migrated row works unchanged). Each tenant's tools/prefs/keys/billing/assets/files are fully separate — the per-business brain.
ai_tools) — curated launch set seeded per tenant, fully editable; hf_equiv pegs to the Higgsfield snapshot for structured runner params.sources JSON = exact-twin alternates w/ price_mult; generate ranks sources-with-keys by variable price, tries cheapest, fails over on submit errors; job records what actually ran; overrides.source pins. Verified-twins-only rule: never route to an older model version.house_keys pays) or 🔑 Own (member's ai_user_keys ONLY, no silent house fallback). Cards are never stored — keys ARE the payment.17 3 *)./report/shotlist/:id serves from here now; FLIPPER proxies old links). Generate-to-shot auto-attach.ai_edits, batches 32–35) — trims, audio lane, uploads with Premiere-style format metadata; timeline.zip = FCP7 XML + fetch scripts; user imports relink by filename.putStream via FixedLengthStream — request body goes straight into R2, zero Worker buffering; honest cap 100MB (the platform request limit; was 50).GET /api/studio/ai/assets/:id/meta — asset row + generation prompt (FLIPPER's landers read the creative's r2_key, then hit shared R2 directly — one meta hop, zero byte proxying).POST /api/studio/ai/messages — Anthropic passthrough (vision content, up to 32K tokens) with Phantasia-resolved keys; recorded as a job. FLIPPER's lander writer rides this.GET/POST /api/studio/ai/housekeys — admin house-key store; FLIPPER's /connections AI cards mirror into it on save.Per-profile private storage (user_files): streaming upload (POST /api/myfiles/upload?name=&folder=), folder labels, rename, authed download, hard delete (no trash for personal files), and POST /:id/share → HMAC link (default 7 days, max 30). The share link is how a file rides into a Gorgias reply (FLIPPER's 📎 composer button) or gets handed to the Flexport claims portal. Strictly own-files-only — admins included.
web/studio.html is generated by scripts/build-studio-html.js; build.js runs it first, then precompiles JSX → studio.<hash>.js + studio-compiled.html (served at /), self-hosted React, immutable caching, keeping the last 2 old bundles as a deploy grace window (v0.6.1). Edit the generator, never the generated shell.
Four tabs: ✨ AI Tools · 🪄 Pipeline · 🎬 Editor · 🎥 Video Projects. Two halves with different owners (see §7): StudioAI / PipelineView / StudioEditor are sliced verbatim out of Hermes's page — change them there — while the App shell, the login/bootstrap screens and VideoProjects (including the 🤝 shared_from badge) live inline in the generator and are edited here.
🎬 On-Set mode is not part of the React shell at all: web/onset.html is served as a real 200 at /onset by worker.js (bypassing both the assets .html→clean redirect and the SPA shell). It's the crew's slate + shot logger — digital slate, rolling timecode, a sync clap, take logging with circle/keep/NG — on a shared free-run clock (GET /api/onset/time + client offset sync, verified live at ±9ms) so every device that joins a session code reads the same timecode. Sessions are per-shoot (/onset?s=vp-<projectId>), so concurrent shoots never collide. Public by design (crew phones have no login); every session field is length-capped because of it.
Note: the ad-lander panel needs product context that lives in Hermes — build landers from Hermes's embedded tab; everything else is fully functional standalone.
INTERNAL_KEY — service-binding trust; must equal the host's STUDIO_KEY var (flip-cms carries it since v0.64.0).SIGN_KEY — /f/ share-link HMAC.APP_KEY — standalone bootstrap gate (https://phantasia.appolis.app/?key=<APP_KEY> for the one-time admin creation).ID_SECRET — 🪪 Appolis ID machine trust: must equal the appolis worker's ID_SECRET; verifies the shared appolis_id cookie locally and authenticates /id/check + /id/resolve calls over the APPOLIS_ID service binding.HOUSE_TENANT — 🏛 the house workspace (t_spartan, Tyler's Spartan Studios): the only tenant whose admin can publish shared house pipelines; its pinned pipeline refs ride cross-tenant as sealed generation inputs.PUBLIC_HOSTS — 🔑 our own client-facing hostnames, comma-separated (default phantasia.appolis.app; .workers.dev is always treated as public). Identity headers are ignored and /internal/ 404s on these — see the door test in §7. Bind a new custom domain to this Worker and you must add it here, or it silently becomes an identity-injection door.CONNECTOR_CAP_CENTS — 💸 the AI door's rolling 30-day spend ceiling in cents (default 2000). Counts ai_jobs.via='connector'; a human running the same approved request is never capped.⚠️ These are all vars, i.e. plaintext in this repo. Moving them to wrangler secret put + rotating is tracked as todo_1059 and has to be coordinated across the five workers that share them (this one, appolis, agora, Flip CMS/Hermes, AWS Motor Club) — INTERNAL_KEY must keep matching each host's STUDIO_KEY, and ID_SECRET must match appolis. Don't rotate one repo alone.
Generation approval (nothing auto-fires), exact-twins-only sourcing, House/Own two-state billing, never store cards, coral→gold = negative actions only.
UI drift rule: StudioAI / PipelineView / StudioEditor are shared with Hermes's page BY COPY — scripts/build-studio-html.js slices them out of it on every build, so an edit made here is silently overwritten. Change those in Hermes. The App shell, login/bootstrap and VideoProjects are inline in the generator and are edited here. (§5.)
🔑 The door test (todo_1063): identity headers (x-studio-) and /internal/ are binding-only. Cloudflare routes by Host, and nothing reaches this Worker under another app's hostname except a service binding — Kosmos and Hermes forward their own hostname, the suite hub uses https://internal, dev is localhost. So arriving at one of our client-facing hostnames (PUBLIC_HOSTS / *.workers.dev) means the public internet: identity is ignored there even with a valid key. A leaked key is no longer an identity.
💸 The approval rule has a server-side home (todo_1061): the rule used to live only in the browser, so it didn't bind the AI. Now the connector door cannot spend — it can only stage an ai_stages row for a person to approve, and an approval is single-use. Any NEW door that can reach /api/studio/ai/generate must be classified: a human click may spend, a machine may only stage.
🏢 Access is resolved live, never cached into a row (todo_1062): both doors compute reachable brains from Hermes per request, so revocation lands on the next call and the company role is always the live one. Never re-introduce "read the tenant/role off the local profile row" — that is precisely what let an ex-employee keep a company brain on their assistant.
ai_tools 13 · ai_prefs 6 · ai_catalog 1 · everything else 0 rows (all generation history was local-dev only — production had never generated). Parity verified per table. R2 bytes untouched. House keys: none existed in prod /connections yet — when Tyler saves them there, the passthrough mirrors them here. Chardizy's ai_* tables still exist (empty of readers) as the rollback path; drop them in a later cleanup batch after soak.
todo_1707). ① git init + a baseline commit (77e2841, 34 files). Until now Phantasia had no git history at all — no diff, no revert, no blame for the entire Studio backend, while every sibling app had one. It also meant the suite's house rule that a dirty tree signals another session is mid-flight was silently inoperable here: the collision check returned "clean" for a repo it could not see.
⚠️ AND THE FIRST COMMIT IS EXACTLY WHERE IT WOULD HAVE BITTEN. The .gitignore rule protecting the credential file was broken from the day it was written (2026-07-26): it read wrangler.jsonc # INTERNAL_KEY / SIGN_KEY / … in vars. Git honours # as a comment only at the START of a line, so the trailing note became part of the pattern, the rule matched nothing, and wrangler.jsonc was never ignored. Harmless only because there was no repo — a git add . would have committed ID_SECRET. Fixed, and proven with git check-ignore -v rather than eyeballed (wrangler.jsonc and data/ both confirmed ignored before staging). Staged content was scanned too, not just filenames: no staged file contains the live secret, none carries an sk-/aid_/amt_/apg_ literal. Excluded by design: wrangler.jsonc, data/ (phantasia.db stores provider API keys in plaintext in house_keys), .wrangler/, node_modules.
② The build reported success while doing half its job — for three weeks. scripts/build-studio-html.js slices the Studio components out of Hermes and writes web/studio.html, so the standalone shell cannot drift from the copy Hermes embeds. It read …/chardizy/web/flip-my-life-retention.html — which stopped existing when FLIPPER was renamed to Hermes and the file became hermes.html. That rename enumerated its own consumers and missed this one, because it lives in a different repo. So the script threw ENOENT, build.js caught it, printed studio.html regen skipped and exited 0. web/studio.html last regenerated 2026-07-26; its source last changed 2026-08-18. Regenerated now: 155,636 → 171,043 bytes.
THE FIX HAS TWO HALVES, and the second is the one that matters. SRC repointed to web/hermes.html — and the two failures that used to look identical are now separated: if the Hermes repo is not checked out at all (a CI box, someone else's machine) the script says so and exits 0, which is the intended fallback; if the repo is present but the source is missing — a move, a rename, a typo — it exits non-zero with instructions for finding the new file. build.js no longer wraps it in a catch that downgrades everything to a warning, because a build step that cannot do its job must fail the build rather than report success. Deployed; phantasia 200, gated Studio paths 307 to auth as expected, whole suite 200.
INTERNAL_KEY, SIGN_KEY and APP_KEY move from vars to Wrangler secrets (no version bump — only where the credentials live changed; nothing observable). Move, not change — identical values, so no /f/ share link was invalidated. That is the whole reason Step 0 exists as a separate step: rotating SIGN_KEY would 403 every outstanding signed share link, and those get pasted into CUSTOMER emails with up to a 30-day life. Moving it costs nothing; changing it is a customer-visible event and is still NOT done.
⚠️ THE RUNBOOK'S STATED ORDER IS IMPOSSIBLE. It says put the secret first, then delete the vars line. The Cloudflare API refuses: "Binding name already in use" [code: 10053] — a secret cannot take the name of an existing plaintext binding. The var must be stripped and deployed first, which opens a brief window where the binding is undefined. It fails CLOSED, so it is unavailability and never exposure, but for this app that window includes /f/ signing — so it was done as ONE window for all three keys, off-hours, with the values copied out-of-repo first (stripping the config destroys the only copy).
⚠️ Worth knowing for this repo specifically: wrangler.jsonc is gitignored here, so these three were on disk only and never entered git history — unlike Kosmos, whose config is tracked. That does not make disk-plaintext safe, but it does mean there is nothing to purge.
ID_SECRET stays in vars on purpose — it is the only credential the weekly Appolis tripwires can read, and no session can read a secret. Verified after: phantasia 200, whole suite 200.
server/api.js; documented in full in appolis/APP_BREAKDOWN.md (v0.9.0, v0.9.1) and Kosmos note_1689. ① The shared SSO cookie is no longer pre-verified here. The Appolis-ID branch used to gate its /id/resolve call on auth.verifyIdSession(m[1], deps.idSecret) and skip everything if that failed. That made Phantasia a second, silent authority on a signature only Appolis may judge — and it pinned the suite to one key: the instant Appolis signed sessions with its own ID_SESSION_KEY, every valid cookie would have failed here and Studio SSO would have died without the request ever reaching /id/resolve. Because /api/hub-apps swallows failures into {apps:[]}, it would have died silently. Removed and deployed before Appolis flipped its key. Behaviour is unchanged: a bad token used to be rejected locally, now /id/resolve rejects it.
⚠️ The near-miss worth remembering: the helper here is named verifyIdSession, while its twins in Kosmos and Agora are verifySession. A grep for the sibling name returns nothing in this repo. The cutover was enumerated app-by-app rather than pattern-matched, which is the only reason this file was included at all.
② /api/my-connector now forwards the person's own signed cookie (x-id-token, from the headers.cookie regex already used elsewhere in this file). That route returns a credential, not information: the amt_ token acts fully as the person across every licensed app, needs no header, and survives every key rotation. Appolis prefers the token and echoes proven: true; the ?email= stays until its phase C, because a local (non-SSO) Studio session has no such cookie.
🔧 Two repo-health problems found while doing this — see the phantasia board (todo_1707): this repo is not a git repository at all (no history, no revert, and the suite's "dirty tree means someone is mid-flight" check silently cannot work here), and node build.js half-fails while reporting success — scripts/build-studio-html.js reads a Hermes-repo file that has moved, so studio.html has not regenerated in some time while every deploy looked clean. Also worth knowing: worker.js is a ~5 KB shim that imports server/api.js and wrangler bundles it at deploy, so editing the server file IS sufficient — grepping the built worker for your change finds nothing and looks like a failed edit.
💸 The AI room could spend money on its first tool call (todo_1061). generate mapped straight onto the paid route: no staging, no pending state, no confirmation, no ceiling. The generation-approval rule — "nothing fires until a person approves" — lived only in the browser, in React state, so a prompt injected into a note the assistant merely READ could bill a provider, on House billing that's the company's key, in a loop. Fixed by giving the rule a server-side home: the connector door stamps x-studio-via: connector → ctx.via==='connector', and that door cannot spend at all. generate/stage_generation write an ai_stages row (pending, with the resolved prompt and a real cost estimate from the same estimateCents maths the runner bills) and return 202 {needs_approval, stage_id, est_cost_cents} having contacted no provider. Only a human door can POST /stages/:id/approve (the AI gets a 403 — it can ask, never approve), and only an approved row can run — single-use, so an approval can't be replayed into repeat spend. A 30-day per-connector cent ceiling (CONNECTOR_CAP_CENTS, default 2000¢) backstops even approved runs, and ai_jobs.via now records which door spent, so machine spend is auditable beside human spend. The Studio's own click path is untouched: a human click still generates immediately — that click is the approval.
🏢 The connector pinned the wrong brain and never heard about revocation (todo_1062). One Appolis ID legitimately owns a profile row per company brain it has opened, and with no index and no ORDER BY the lookup returned the lowest-rowid row — whichever tenant the person was provisioned into first, often a COMPANY brain, with no argument to choose otherwise. Worse, the AI door made no Hermes, entitlement or membership call at all, so an ex-employee kept a company's gallery, other members' job prompts, pipelines and house-key spend on their assistant indefinitely while the website had already cut them off — and the local role, written once at first contact, let a stale admin outrank Hermes's live answer (re-stamped on every call, self-reinforcing). Now the connector resolves brains through the same live accessibleBrains() the cookie door uses, on every call: revocation lands on the next tool use, the default is the person's own workspace, a validated brain argument + a list_brains tool make the choice explicit, and the live company role is what gets stamped. accessibleBrains also stopped keying "your own brain" off t_ws_ alone — that missed a standalone workspace with a real login (Tyler's t_spartan), leaving the master account with no own brain and defaulting it into a company.
🔑 x-studio-internal was honoured on the public internet (todo_1063). Identity came purely from headers — tenant, user and role all taken from the request with no origin check — so anyone holding the key could read any tenant's gallery, download any member's My Files, or spend the house keys straight from the internet. Not reachable through the connector tools, but it was the floor the whole tenancy model stood on. Fixed with the door test: Cloudflare routes by Host, and nothing reaches this Worker under another app's hostname except a service binding — Kosmos and Hermes each forward their own hostname, the suite hub uses https://internal, dev is localhost. So a request that arrived at one of our client-facing hostnames (PUBLIC_HOSTS, default phantasia.appolis.app, plus any .workers.dev — which covers preview URLs) is by definition the public door: x-studio- is ignored there even with a valid key, and /internal/ 404s, closing the public /internal/mcp door too. ⚠️ Bind a new custom domain and you must add it to PUBLIC_HOSTS. Found in passing: the dev server never routed /internal/ to the handler at all, so the connector room could not be exercised or tested locally — which is why it had no coverage; the node path now mirrors the Worker.
Then the fixes were adversarially reviewed, and three of them had holes — all reproduced, all fixed here:
· Revocation PROMOTED (critical, introduced by this batch). Widening "your own brain" to any row not currently in the company list inverts on the exact event the system exists to handle: when Hermes drops the business — or is merely unreachable — the company list empties, and an ordinary employee's only profile row is a mirror inside that company. It was relabelled as their own brain at admin, letting a just-removed member read and overwrite the company's house keys. A home brain is now personal by construction (a real login or a workspace we minted), and its role comes from the row instead of being hardcoded. An outage must never promote.
· A trailing-dot Host walked through the whole door test (critical). phantasia.appolis.app. is the same host to DNS and to Cloudflare's router but a different string to includes(), and WHATWG URL keeps the dot — so identity headers were honoured and /internal/* re-opened on the public domain. Hostnames are normalized before comparison now.
· One approval bought N runs (high). /stages/:id/run read the row, awaited a provider round-trip, then wrote — so parallel run_staged calls on a single approval each passed the check and each billed (measured: 8 parallel → 8 charges, audited as one run), and the same read-then-act shape walked through the ceiling. The stage is now claimed with a compare-and-swap before any provider I/O, the loser gets a 409, and a claimed-but-unbilled stage reserves its estimate so a burst of separate approvals can see each other. The double-clicked approve-and-run path got the same treatment.
Still owed (not this batch): the shared machine-trust secrets still sit in vars in five workers and want wrangler secret put + a coordinated rotation (todo_1059); the hub still forwards identity as a bare email header rather than a signed envelope (todo_1060); the ceiling is per (tenant, user), so someone with several reachable brains has one ceiling per brain; and the ⏳ AI-requests approval panel is not in the shipped shell yet — the API is live and a human can approve with POST /api/studio/ai/stages/:id/approve (optionally {"run":true}), but the in-app surface is still to build.
26/26 tests — they assert the todos' own verify criteria (50 staging calls → zero provider requests, one approval → exactly one, a revoked Hermes membership vanishing from the connector without touching the web, the header set landing 401 on the public domain while both binding doors keep working) plus a regression apiece for the three review findings.
shared_from pill on the Video Projects tiles and on the open project's dashboard header, naming the workspace a shoot reached you from — so a collaborator's project is visibly not natively yours. The value is a pure passthrough of Kosmos's GET /internal/video-projects (Phantasia computes nothing and owns no sharing model); it is therefore invisible without KOSMOS_HUB + ID_SECRET + an identity email. This shipped in bundle studio.c2cc3626a2.js on 2026-07-23 with no version bump and no changelog line — shipped behaviour with no record anywhere, caught by the 2026-07-26 suite doc audit (note_1082). Recorded here, and the deck + version now cover it. The lesson is the standing one: the bump and the changelog line are part of shipping, not paperwork after it.GET /internal/video-projects, ID_SECRET-gated: stage label/icon/color from the video pipeline, next shoot date, shot + open-todo counts, live/hub links). Each tile opens a per-project dashboard: 🎬 Start the live shoot (the /onset clapper on a per-project shared session vp-<projectId> — multiple shoots run concurrently, each its own instance), ✨ AI tools, 🪄 pipeline (finds-or-starts by name), 🎞 editor, ▶ lander / 🛖 hub, 🪐 back to the Kosmos tile. Deep link /?vp=<id> lands straight in a project's dashboard — Kosmos video projects' single 🎬 Phantasia button opens it (the ONE-door rule, Tyler 2026-07-19). On shoot day (stage=shoot, or the next event is today) the TILE grows a 🎬 LIVE quick door straight into that project's clapper session. Kosmos and Agora stay the watchers; Phantasia runs the shoot.POST /pipelines/:id/use → your own clone opens, toast explains takes/winners land in YOUR workspace); microcopy states the house examples ride along and stay the studio's. (2) 🏛 Publish toggle — the house workspace's admin gets a Publish-to-everyone button in the open-pipeline header ({shared} PATCH; server still 403s everyone else — the new is_house flag on GET /pipelines is presentation-only). (3) 🏛 badges — list rows mark house clone (source_ref house:…) and published (shared=1); an open clone carries a sealed-refs hint pill. Engine change this build: is_house on the pipelines list response. 19/19 tests.build.js no longer deletes EVERY old hashed bundle: right after a deploy, a minutes-stale edge-cached shell (ours, or a host app's /studio proxy — Kosmos/Hermes serve this same shell over the binding) could still name the previous studio.<hash>.js; with that file deleted the script 404'd → blank Studio until revalidation, every deploy reopening the window. Now the build keeps the newest 2 old bundles as a grace window (mtime-desc, delete slice(2)) so a stale shell loads the prior working version and the in-app refresh watcher walks it forward. The other half of the Hermes fix was already in place here: the worker stamps Cache-Control: no-store on every HTML response (worker.js shell path), while hashed bundles + vendor keep their immutable year cache via _headers. Verified live post-deploy: the pre-deploy bundle (studio.488eee6437.js) still returns 200 after the new version shipped; HTML carries no-store.HOUSE_TENANT = t_spartan) can publish any pipeline (shared flag, house-admin-only — other tenants' admins get a 403) and it appears on a 🏛 house rail in every tenant's GET /pipelines. Running one is clone-on-use (POST /pipelines/:id/use): the runner gets their own copy (title + concept + settings, source_ref="house:<id>") so takes, winners, state, and billing all live in THEIR tenant on THEIR keys. The magic stays sealed: a cloned pipeline's ref resolution anchors to the house tenant's pinned refs, and the generate path may load those bytes cross-tenant only if the asset is a pinned house ref (ai_pipeline_refs JOIN permit) — house files are never in any other tenant's gallery, meta, or file download (all remain strictly tenant-scoped; verified by four sealing negatives), and an unpinned house asset is refused as an input. Unpublishing closes the clone door instantly. Schema: ai_pipelines.shared + ai_pipelines.source_ref (migrated live). Engine half of Hermes note_530#8; the Studio UI pass (house rail + Use button + 🏛 ref badge) rides the next FLIPPER-components export. 19/19 tests.APPOLIS_ID service binding + ID_SECRET var, dual-auth on /api/auth/login (local profiles first, then /id/check — an entitled Appolis account signs in at Phantasia's own form and gets the shared .appolis.app cookie), and an SSO-cookie fallback in identity resolution (local HMAC verify → /id/resolve → entitlement phantasia or master * → lazy provisioning, via:'appolis'). Provisioning respects the multi-tenant brain model: map by new profiles.appolis_id column → adopt an existing password-bearing login by email (Tyler's master email lands on his Spartan Studios admin; the passwordless t_kosmos mirror row is correctly ignored) → else mint a fresh isolated t_ws_… workspace owned by the newcomer (v0.3.0 rule holds — no shared tenants, no connector tenants). Sign-out clears BOTH cookies; the shell treats via:'appolis' as a real standalone session (true "Sign out", not "← Exit Studio"). Entitlements re-check on every login/resolve — revoking a grant locks the door instantly (verified live). Also fixed in passing: the local dev server dropped the second Set-Cookie header (Object.fromEntries collapse in makeServer). Any-door rule intact: the standalone login/bootstrap flow is untouched and E2E-verified alongside both SSO doors on prod, all scratch accounts/tenants cleaned after. 18/18 tests (2 new: dual-auth provisioning + SSO-cookie/adoption/logout, against a mock appolis binding with real token crypto).phuser session or a host app's service-binding proxy (via:'binding') — instead of demanding a real login. That's the "every app gets the Studio for one binding" payoff: [[Kosmos]] now has the full Studio (AI tools · generator pipeline · editor · My Files) as its own tenant t_kosmos — separate tools, pipelines, house refs; nothing shared with FLIPPER (t_verdant) or the standalone (t_spartan), verified isolated (empty pipelines/files, own seeded tools). Kosmos's Worker pure-proxies the compiled shell + /studio.<hash>.js + /vendor/ + /api/studio + /api/myfiles + /api/auth + /f/ + /report/ to env.STUDIO with x-studio- identity stamped in — no local copy of the React app, so it can't drift — gated by Kosmos's own password (single sign-on). Embedded niceties: "Sign out" → "← Exit Studio" (back to the host, no session to drop); the update-watcher polls the current path; the header pill shows the tenant name (/api/auth/me now returns tenant_name). Build hygiene: the shell export script moved into the repo (scripts/build-studio-html.js) and build.js runs it automatically first, so the shell can never silently drift from FLIPPER's components. 16/16 tests (node --test test/*.test.js — the bare-dir form breaks on Node 24).t_verdant (FLIPPER's own tenant), so the standalone shared FLIPPER's tool catalog and would have shared its pipelines/assets. Fixed: bootstrap always mints a fresh tenant (t_ws_…), never a connector's; bootstrap-status now checks globally (a standalone login exists anywhere) instead of keying off t_verdant; duplicate-email signups are refused. Tyler's admin was migrated out of t_verdant into its own Spartan Studios tenant — his standalone now shows zero FLIPPER data, its own fresh tool catalog, and his house-reference uploads live only there. Each future signup = its own isolated tenant/brain.pipeline-refs, then auto-rides every user's generation of that step. Also fixed two setup blockers: the keyed root URL was 307-redirecting and dropping ?key= (worker now serves the SPA shell as a 200, query preserved), and the login page wasn't forwarding the key to bootstrap-status (so setup stayed locked out even with the key) — both fixed and verified.GET /api/auth/bootstrap-status reports {needs_bootstrap, key_ok, key_required}; the Login page reads it on mount and: with the access key present it drops straight into "✓ Access key accepted — set up your admin account"; without it, it shows a clear "open this page with ?key=YOUR-APP-KEY" instruction (never printing the key). Once an admin exists, it's a plain sign-in. First admin: https://phantasia.appolis.app/?key=<APP_KEY>.object_sheet is now its own step surface alongside main_sheet — one pipeline can lock a person AND their car/weapon/product, each with its own uploads, takes (1–4 versions per staging), and winners; both sheets carry independently into frames, storyboard, and video via the reference graph. The pipeline-level subject toggle is gone.ai_pipeline_refs) anchor every user's generation to the proven examples. New plumbing: ai_pipelines + ai_pipeline_refs tables, ai_jobs.pipeline_ref lineage, and the OpenAI images-EDIT path — ref_assets on generate sends reference images (sheets/schemes) alongside the prompt so identity survives wardrobe changes. 15/15 tests (2 new pipeline E2Es: full flow + house-ref anchoring w/ member-permission gate).GET/POST /api/studio/ai/settings (max_upload_mb, 1–100 — 100MB stays the platform ceiling, admins can only lower it), stored on tenants.brand, enforced in BOTH upload paths (Studio media + My Files) with the cap named in the error message. Settings UI knob lands with the next Studio UI pass (API-first for now).