# Atelier extraction — blockers and decisions made without asking Unattended run, started 2026-08-20. Per the kickoff prompt: decisions made here without being able to ask, in the order found. Nobody is awake; each entry says what I did and why, then the run continues. ## Map, before anything moved Grepped the whole platform for `atelier` (case-insensitive, every `.ts`/`.tsx`/`.md`/`.json`/`.cjs` file). Two hits, both incidental: - `src/workspaces/officerdev/src/apps/Chat/components/chat-images.test.ts:10` — a test fixture using `https://atelier.officer.dev/images/....png` as an example markdown image URL. Not a dependency. - `src/servers/api/chat/task-restart.ts:181` — a comment mentioning a stray `bun dev` process that once ran from `~/dockers/officer-atelier` and got respawned by accident. Historical, not a dependency. **No platform code imports anything from Atelier, and Atelier imports nothing from the platform.** Unlike music/offscale, there is no reverse dependency to find and no "this stays behind" piece. It comes out whole, the way offscale did before the imports were found — except here there really are none. Also checked: `ecosystem.config.cjs` (platform's own) has no `atelier` entry — the standalone service is managed by its OWN `ecosystem.config.cjs` inside `~/officer-dev/dockers/officer-atelier/`, entirely outside this repository. ## The standalone service stays running, untouched `officer-atelier` (PM2, port 3002) is a **live production service** — its own `ecosystem.config.cjs` says plainly "this is the instance the mobile app talks to, so it holds still." It serves `https://atelier.officer.dev` via nginx and holds real data (87 generations, 2 collections, 5 providers, 1 provider_settings row, checked directly against Postgres). I am not stopping it, not touching its nginx config, not touching its PM2 entry, and not touching the mobile app. This extraction adds a new, additive plugin inside the platform; cutting the mobile app over to it (or deciding whether to) is the owner's call, not mine to make unattended. Recorded here so nobody reads the plugin's existence as "the old service is now redundant, kill it" — it is not redundant until someone repoints the mobile app. ## Decision 3 — database rows checked, both non-empty → database stays separate Queried the live `atelier` Postgres database directly (via a throwaway `postgres` client, same credentials as `.env.production.local`): ``` collections 2 collection_items 73 provider_settings 1 providers 5 generations 87 ``` `collections` (2) and `provider_settings` (1) are the two tables the brief named — both non-empty. Per decision 3: **the `atelier` database stays separate**, not folded into `officer_db`. No `plugins/atelier/db/schema.ts` (that convention is for `officer_db`-prefixed tables); the sidecar keeps its own Postgres connection to the `atelier` database, exactly as today, just running from inside the plugin's `sidecar/` directory instead of a standalone repo. **The brief undersold the schema.** It says "tables: collections, provider_settings" — the real schema has five: `generations` (the actual data — 87 rows, prompts/images/costs), `collection_items` (the join table), `providers` (configured adapter+model+key rows — 5 of them; a newer, richer replacement for `provider_settings` that the brief doesn't mention at all), plus the two named. `generations` alone being non-empty would have settled decision 3 on its own. Following the letter of decision 3 (check the two named tables) rather than re-deriving it from the fuller schema, since both answers point the same way here. **Migration to `officer_db` is a real follow-up**, not done here: fold `collections`, `collection_items`, `providers`, `provider_settings`, `generations` into `officer_db` as `atelier_*`-prefixed tables once someone is awake to watch a real data migration. Until then the plugin's sidecar owns a second Postgres connection, to a second database, which is new territory for this plugin system (every other plugin's `db/` is officer_db-prefixed tables) — documented in PLUGIN.md. ## Postgres connection for the separate database The sidecar needs its own connection string. Added `ATELIER_POSTGRES_URL` as a new platform `.env` variable (same pattern as `OFFICERDEV_POSTGRES_URL` for officer's own database) rather than routing it through the secret store — a database URL carrying a password is handled as a plain `.env` value everywhere else in this codebase (`OFFICERDEV_POSTGRES_URL` itself), and the secret store's own docs scope it to "every encryption and signing key the platform uses" plus foreign bearer credentials (APNs, FCM, webhooks) — not connection strings. Kept consistent with the existing convention rather than inventing a new one. **Not set automatically.** I don't have a safe way to guess whether the owner wants the plugin sidecar to point at the _existing_ `atelier` database (sharing state with the still-running standalone service — two writers to one queue table, which `generations.status` and the `claimableGenerations`/`markRunning` claim logic were written assuming is single-process) or a fresh one. Left unset, with a clear startup error, and documented in PLUGIN.md. **Do not just paste the standalone service's `POSTGRES_URL` in and restart both** — two processes racing the same `generations` queue is exactly the hazard `markRunning`'s atomic claim exists to prevent for restarts of _one_ process, not for two live ones. ## Decision 2 — Providers as an Officer settings screen, against a direct precedent Found `IntegrationsSettings/index.tsx`'s own comment, written when email was extracted on 2026-08-18: > "Email account management left with the feature (`plugins/email/`). A plugin contributes panels, not > settings sections, so it now lives inside the email workspace itself." That is the opposite of decision 2, written five days before this run, in the exact settings screen decision 2 points Atelier's Providers into. Per the brief's own instructions ("if you find evidence one is wrong, write it down, follow the decision anyway"): implemented Providers as a genuine platform settings section — `IntegrationsSettings` gained an "Atelier" entry under the Enterprise tab — as new **platform** code (`src/apps/officer-web/...`) that talks to `/api/atelier/providers` over HTTP via `useClient`, same as any other authenticated screen. It does not import anything from `plugins/atelier/`, so it does not violate "a plugin importing platform code is fine; the reverse is not" — there is no import in either direction, only an HTTP call, which is what every frontend/backend pair in this codebase already is. Recording the contradiction here rather than silently picking the email precedent, since decision 2 was explicit and pre-made. ## Images served through an authenticated route, not the old `PUBLIC_URL` scheme The standalone service served `/images/*` unauthenticated, off Bun's top-level route table, with `PUBLIC_URL` baked into every response so the URL was durable outside the process. Per "What you may NOT do", that is gone: images are served from `GET /api/atelier/files/:filename`, mounted like every other route, behind the permission gate. Named `files` rather than `images` so it cannot sit one path segment away from `/api/atelier/images/generations/:id` — the JSON job API already owns `/images`. The frontend needs `` to carry a credential a browser cannot put in an `Authorization` header. Used the platform's existing `?token=` escape hatch — already read by both `permission-gate.ts:37` and `user-middleware.ts:22`, already used by `FileViewer/file-types.ts` for exactly this (`getRawUrl`, `getAudioTracksUrl`, ...) for the file browser's raw/audio/subtitle routes. Not a new mechanism, not an unauthenticated route: the same query-token path every other authenticated media URL in this app already takes. ## `sharp` added as a direct dependency Atelier's `inspect.ts` uses `sharp` to measure width/height/alpha-channel/opacity of a written image — load-bearing (it is how a `background: transparent` request that silently produced an opaque image gets caught). `sharp@0.33.5` was already resolved in `bun.lock` as an optional transitive dependency of `next`, so promoting it to a direct dependency of the platform added no new resolution — verified the lockfile diff is exactly that promotion, nothing else moved. Done via the documented dance: `frozenLockfile` commented out in `bunfig.toml` for one `bun install`, restored immediately after, diff reviewed before committing both files. ## Publishing to the Marketplace — not done, left as the one open step `plugins/atelier/` is its own local git repository (`git init`, per "Done means"), but I did not create `gitea.officer.dev/plugins/atelier` or push to it. I found no Gitea API token or credential available to this session, and creating a new repository on a shared Gitea instance and registering it in the marketplace catalogue (`scripts/marketplace-sync.ts`, `BUNDLED` in `src/servers/plugins/marketplace.ts`) is exactly the kind of action with effects beyond this local checkout that the run's own ground rules say to flag rather than take unilaterally, especially with no verified way to confirm it succeeded. Left for the owner: ``` cd plugins/atelier git remote add origin git@gitea.officer.dev:plugins/atelier.git # after creating the repo on Gitea git push -u origin main cd ../.. bun --env-file=.env scripts/marketplace-sync.ts ``` Then add a `BUNDLED` entry in `src/servers/plugins/marketplace.ts` (offline fallback), matching the other three plugins' entries. Until that happens, the plugin still installs fine from a checkout that already has the directory (`plugins/atelier/` committed to its own local repo, present on disk) — it just cannot be discovered by the Marketplace screen on a machine that has never had it, per "Done means" #2. This is the one piece of "done" I could not complete unattended. ## No `plugins/atelier/db/`, on purpose "Done means" lists `db/` among the plugin's expected pieces, matching offscale's and music's shape. This plugin has no `db/schema.ts` at all — `discoverPlugins()` correctly reports `schema: null` for it. That convention is specifically for `officer_db`-prefixed tables pushed by `pushPluginSchema`; decision 3 keeps Atelier's tables in their own separate Postgres database instead, so there is nothing for that convention to describe. The database code lives inside `sidecar/db/` as an internal implementation detail of the sidecar process, not as a platform-recognised `db/` directory. Documented in full in `plugins/atelier/PLUGIN.md`, under "The database". ## What I verified, and what I deliberately did not - `bunx tsgo`: clean throughout. - `bun test`: baseline 1041 pass / 0 fail / 90 files → after this run, 1069 pass / 0 fail / 92 files. The two new files are the ported `serialize.test.ts` and `generations/response.test.ts`. No regressions, file count only grew. - `discoverPlugins()` finds the plugin and the manifest validates with no problems. - The platform-side proxy router mounts and correctly 503s with no sidecar running. - The sidecar's own Hono app was exercised directly against the real `atelier_dev` Postgres database (not `officer_db`, not the production `atelier` database) with real HTTP requests and no port bound: providers, providers/adapters, collections and images all answered correctly; a missing file 404s; an unknown route gets the OpenAI-shaped error envelope; provider rows whose keys were encrypted under the standalone service's own key correctly fail to decrypt rather than crashing (a fresh `secret.key` was generated for this checkout at `$OFFICER_ROOT/data/atelier/secret.key`, which is expected and correct — see PLUGIN.md). - **Deliberately not done**: installing the plugin through the live, running `officer` PM2 process. That process serves the owner's real session, its `plugins/` directory is the main checkout (not this worktree, so it would not even find this code), and going through its install flow would write a real `plugin_installs` row into the shared production `officer` Postgres database from a session with no way to confirm the result against a live browser. Full PM2 install/enable/disable/uninstall/purge cycle, per the runbook's verification table, is left for the owner — noted as open item 5 in PLUGIN.md.