diff --git a/AGENTS.md b/AGENTS.md
index f898a3b98..c32de348d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -116,10 +116,28 @@ Copilot SDK sandboxes.
- Tests in tests/unit/ and tests/integration/ -- pytest with asyncio
- CI/CD via GitHub Actions: deploy.yml (push-to-deploy on main), pr-check.yml (lint+test on PRs)
- Deploy target: Hostinger KVM 4 VPS (Ubuntu 24.04 + Docker)
-- **Control Plane UI**: All frontend work MUST follow `workbench/control_plane/DESIGN_SYSTEM.md`.
- Use shared components (`Tabs`, `FilterPills`, etc.) from `src/components/` — never inline
- ad-hoc tab bars, filter pills, or page headers. Use semantic Tailwind color tokens
- (`bg-primary`, `text-foreground`, `border-border`) — never arbitrary hex values.
+- **Control Plane UI is THEMED. Read `workbench/control_plane/DESIGN_SYSTEM.md`
+ before writing any of it.** Settings → Appearance switches the whole org between
+ RapidTool, Fluent, Material and Graphite, which disagree about palette, corner
+ radius, icon pack, glass/glow and control behaviour (Material buttons are pills,
+ Graphite's labels are uppercase). Three rules, all machine-checked by
+ `src/lib/theme/conformance.test.ts`:
+ 1. **Never write a colour.** Use `bg-primary`, `text-foreground`,
+ `border-border`, `var(--success)` — not `#0ea5e9`, `hsl(…)` or `bg-[#1a1b1e]`.
+ Text on a coloured fill takes the `-foreground` partner, never `text-white`.
+ 2. **Never import `lucide-react`.** Use ``; Lucide names are
+ the vocabulary, the theme picks the pack.
+ 3. **Never hand-roll a control.** Use `Button`/`Input`/`Badge` from
+ `src/components/ui/` — a theme's state layer, focus ring and label transform
+ are not expressible in a class string, which is why the primitives exist.
+ Also use the shared `Tabs`, `FilterPills` and page-header patterns from
+ `src/components/` rather than inlining ad-hoc versions.
+- **Apps that run in the sandbox** (Custom Apps, generative UI, React artifacts)
+ inherit nothing from the shell — they get the `--cc-*` contract instead
+ (`src/lib/theme/app-tokens.ts`, documented in
+ `apps/agents/agent-app-builder/instructions.md`). Style with those tokens and
+ the app follows the org's theme for life; write one hex value and that part of
+ it leaves the design system permanently.
- Agent-generated artefacts (images, reports, PDFs) MUST be written to
`inputs/`, `outputs/`, or `agent-data/` within the agent workspace so the
Control Plane file browser and inline chat cards can discover them. These
diff --git a/FOUNDATION_BUILDOUT_CHECKLIST.md b/FOUNDATION_BUILDOUT_CHECKLIST.md
index 5428aab8c..9988a74d9 100644
--- a/FOUNDATION_BUILDOUT_CHECKLIST.md
+++ b/FOUNDATION_BUILDOUT_CHECKLIST.md
@@ -34,11 +34,12 @@ added**:
|---|---|---|---|
| 1 | ~~**`main` has no branch protection**~~ **CLOSED 2026‑08‑03** | §BO‑17 / `work_plan.md` WS‑5 | Was 404 / rulesets `[]` for months. **Now enabled:** PRs required (`required_approving_review_count: 0` — a sole maintainer must still be able to land work), `enforce_admins: true` (without it the protection is decorative for the only person who pushes), force‑pushes and deletions **blocked**. ⚠️ **`required_status_checks` is deliberately `null`** — `pr-check.yml` carries `paths-ignore: ["**.md", "ai-company-brain/**"]`, so a docs‑only PR runs **no** checks at all; requiring those contexts would leave every docs PR permanently unmergeable. To require them, first give `pr-check` an always‑runs sentinel job, then add that job as the only required context. |
| 2 | **No *scheduled* backup; no restore ever exercised** | **§BO‑23** (below) | ◐ 2026‑08‑03: `backup_db.sh` + `restore_db.sh` + runbook shipped, and `apply_migrations.sh` now fails closed without a pre‑migration dump. **But nothing schedules it** (the systemd units need a `deploy/` write) and **no restore has been run**. Measured recovery position meanwhile: Hostinger VM images only, weekly, **2 retained, newest 5 days old**, ~58 min restore, whole‑machine granularity. **OWNER‑GATE** — install the timer, then run one restore. |
-| 3 | **DB engine sprawl** | §BO‑10 | **12 `create_async_engine` call sites across 10 modules** (+ one sync engine), 8 of them undisposed process‑lifetime singletons. One arrived per app. **This is the only item whose cost compounds per app** — fix the seam before the next app, not after. |
+| 3 | ~~**DB engine sprawl**~~ **CLOSED 2026‑08‑06** | §BO‑10 | Was **12 `create_async_engine` call sites across 10 modules** (+ one sync engine), 8 of them undisposed process‑lifetime singletons, one arriving per app — the only item whose cost compounded per app. **Now one engine and one pool for every async caller**, in `acb_common/db.py` (not the gateway: `acb_auth.access` runs in the gateway process and cannot import it). `acb_audit.record()` is non-blocking on the loop, drained at shutdown. A new engine now fails `tests/unit/test_db_engine_seam.py`. Remaining by design: `acb_graph`'s **sync** engine and `email_ingestion`'s per‑run engines. |
Nothing else on this list needs to be closed first. Items 1 and 2 are risk
-containment the owner must action; item 3 is the one an agent should fix before
-the next app opens engine number 13.
+containment the owner must action; item 3 was the one an agent should fix before
+the next app opened engine number 13 — **closed 2026‑08‑06, and the seam is now
+guarded by a test rather than by a note in this file.**
---
@@ -177,8 +178,17 @@ was stale — corrected 2026-08-02 to match §BO‑20) were anonymous‑reachabl
- **Dependencies:** Alembic; a one‑time baseline of the current schema (`schema.generated.sql` exists as a start).
- **Approach:** Adopt Alembic (autogenerate baselined against `schema.generated.sql`), run it in `lifespan`/entrypoint, keep the raw files as historical. Add a CI check for unique numeric prefixes until then.
-### BO‑10 — Consolidate DB access to one engine/pool *(P2)* ◑
-- **Done (Session 2, 2026‑07‑13):** **every** engine now bounds the CONNECT phase so a slow/unreachable DB can't hang callers — `settings.db_connect_timeout` (default 10s) on `acb_graph.get_engine()` (`ccccdc8`, live in prod), the two gateway asyncpg engines (`1684e1a`), and the four `email_ingestion` async engines (`1ff6c0d`, local, unpushed) via `connect_args={"timeout": …}`. This makes `acb_audit.record()`'s "never block the caller" guarantee real against a hung connect. Test: `tests/unit/test_db_connect_timeout.py`.
+### BO‑10 — Consolidate DB access to one engine/pool *(P2)* ✅ **CLOSED 2026‑08‑06**
+- **Closed (2026‑08‑06).** Every async caller now resolves to ONE engine and ONE pool, and `acb_audit.record()` no longer blocks the loop. What actually shipped, and the two places it departs from the "Approach" line written below in July:
+ - **The seam lives in `packages/acb_common/acb_common/db.py`, not in `acb_graph`.** `acb_graph` was the wrong home twice over: the gateway does not depend on it (nominating it would drag pgvector/AGE into a process that needs neither), and its own engine is **sync**. `acb_common` is the one package every service and every `acb_*` library already imports. `gateway/db.py` remains as a re-export because that is the import path the route packages use.
+ - **`acb_auth.access` is why the seam had to leave the gateway.** It resolves a member's permissions from Postgres *on the request path*, runs inside the gateway process, and cannot import `gateway`. While the seam lived in `gateway/db.py` the gateway had two pools no matter how many route packages were converted — so "one engine" was unreachable by converting routes alone. Its engine had also never carried the connect-phase or `idle in transaction` bounds added after the 2026‑08‑06 outage; it inherits both now.
+ - **Converted:** the six remaining route packages (`admin`, `apps`, `email`, `notes`, `whatsapp`, `workflows`) joining `tasks` and `crm`, plus `acb_auth.access`. Each kept its historical `get_db` / `_get_db` / `_get_session_factory` name as a re-export, so ~50 call sites and every `monkeypatch.setattr(, "_get_db", …)` in the test suite are untouched. Verified live: nine consumers, one engine object, real queries.
+ - **Pool ceiling: 30** (`settings.db_pool_size` 10 + `db_max_overflow` 20, both now tunable), unchanged from the pre-consolidation seam. Deliberately *not* raised to the old sum: the twelve pools summed to ~165 connections from one process against a stock `max_connections` of 100 that Langfuse, LiteLLM and the ingestion services also draw from — a budget that could not be spent, only exceeded.
+ - **`dispose()` in the lifespan was considered and rejected**, contradicting the July approach line. The pool's lifetime *is* the process's; a dispose seam is a way to close connections other in-flight handlers are still using. BO‑9's "nothing disposes anything" observation is correct about the fact and wrong about the remedy for this engine.
+ - **`acb_audit.record()`** keeps its sync signature (25-odd call sites, most of them sync) and dispatches the write to `asyncio.to_thread` **only when called from a running event loop**; sync callers still write inline, which is what they expect. `acb_audit.drain()` is awaited last in the gateway lifespan, so shutdown cannot cancel an in-flight row — without it, non-blocking would have been a regression against the old behaviour, where the write completed before the handler returned.
+ - **Ratchet:** `tests/unit/test_db_engine_seam.py` fails the build on a new `create_async_engine` call site (AST-parsed, not grepped — every one of these modules mentions the name in prose saying it does *not* call it) and separately fails when an allowlist entry stops creating an engine, so the allowlist cannot rot into blanket permission. Plus `tests/unit/test_audit_non_blocking.py` (loop not blocked, write genuinely off-thread, sync callers still inline, drain waits, drain is bounded).
+- **Still open, deliberately:** `packages/acb_graph/acb_graph/db.py:32`'s **sync** `create_engine`. It serves a different (sync) caller set — including `acb_audit`'s own write — and folding it in is an `acb_graph` rewrite, not this ticket. The four `email_ingestion` engines also stay: separate process, per-run lifetime, disposed when the run ends. Both are recorded in the test's allowlist with those reasons.
+- **Done earlier (Session 2, 2026‑07‑13):** **every** engine now bounds the CONNECT phase so a slow/unreachable DB can't hang callers — `settings.db_connect_timeout` (default 10s) on `acb_graph.get_engine()` (`ccccdc8`, live in prod), the two gateway asyncpg engines (`1684e1a`), and the four `email_ingestion` async engines (`1ff6c0d`, local, unpushed) via `connect_args={"timeout": …}`. This makes `acb_audit.record()`'s "never block the caller" guarantee real against a hung connect. Test: `tests/unit/test_db_connect_timeout.py`.
- **Missing — the "three+" above was written in July and is now materially wrong; re‑measured 2026‑08‑03.** It is **12 `create_async_engine(...)` call sites across 10 modules**, plus a 13th **sync** `create_engine` in `acb_graph/db.py:32`:
| Module | Sites | Shape |
|---|---|---|
@@ -194,8 +204,8 @@ was stale — corrected 2026-08-02 to match §BO‑20) were anonymous‑reachabl
| `email_ingestion/scheduler.py:142, 527, 560` | 3 | per‑call, disposed at `:424`/`:540`/`:590` |
| `packages/acb_graph/acb_graph/db.py:32` | (1 sync) | `create_engine`, a different flavour again |
**The eight cached singletons are never disposed** — repo‑wide, the only `engine.dispose()` calls are the four `email_ingestion` per‑call engines cleaning up after themselves, and nothing in the gateway lifespan disposes anything (BO‑9). Also still open: sync `acb_audit.record()` blocks the async loop (H11) — connect_timeout bounds the hang but the call is still synchronous.
+ *(The table above is the 2026‑08‑03 measurement, kept as the record of what was found. Every async row in it is closed as of 2026‑08‑06 — see the top of this section for what replaced them and why the July approach line was not followed literally.)*
- **Why it moved up the list:** the count grew by *one engine per app* — `notes`, `whatsapp`, `workflows` and `apps` all arrived with their own. This is the only foundation item whose cost **compounds per app**, so it is the one to fix before the next app rather than after (see `work_plan.md` §2's "Can we go app by app?" block, exception 3).
-- **Approach:** Provide a single configured async engine in `acb_graph` (sized pool), funnel all callers through it, dispose it in the gateway lifespan, and make `acb_audit.record()` async (or always call via `to_thread`).
### BO‑11 — Decide `acb_schemas`: wire in or delete *(P2)* ✅
- **Done:** deleted the package (0 production importers, drifted from the ORM — H10). Removed its 7 `pyproject` dependency declarations + `tool.uv.sources` entry, the smoke‑test import, and the stale "wire/API surface" comment in `acb_graph/models.py`; re‑locked. Bonus: this exposed a latent under‑declared dependency — `orchestrator/triage/schema.py` uses pydantic `EmailStr` (needs `email‑validator`) but only got it transitively via `acb_schemas`; now declared explicitly as `pydantic[email]` on the orchestrator.
@@ -1674,7 +1684,7 @@ non‑blocking style backlog.
1. **P0 hardening sprint (do first):** **BO‑23 (backup/restore — scripts + runbook are AGENT‑SAFE; it is P0 because it is the only unrecoverable failure mode here)**, BO‑8 (rotate+purge secrets), BO‑2 (auth enforcement — ✅ since), BO‑1 (Action Broker), BO‑3 (mutation governance). These close the Critical trust‑boundary and governance gaps that everything else sits on.
2. **P1 sprint:** BO‑7 (sandbox), BO‑5 (observability+cost), BO‑6 (migrations), BO‑12/BO‑14 (runtime + permission model), BO‑15 (LLM config SoT — **tier→model half only**), BO‑17/BO‑18 (gates), **BO‑20 (event‑bus consumer + job queue)**. *(BO‑19 closed 2026‑08‑03.)*
-3. **P2/P3:** BO‑9, **BO‑10 (promoted in practice — it is the one item that compounds per app; see the verdict block at the top)**, BO‑11, BO‑13, BO‑16, **BO‑21 (memory activation)**.
+3. **P2/P3:** BO‑9, ~~BO‑10~~ **(✅ closed 2026‑08‑06 — one engine/pool + non-blocking audit; it was the one item that compounded per app)**, BO‑11, BO‑13, BO‑16, **BO‑21 (memory activation)**.
**Competitive‑informed items** (proven reference implementations from Hermes Agent / OpenClaw — full mapping in `ai-company-brain/specs/competitive_hardening_2026-07.md`): CH‑1→BO‑7/BO‑14, CH‑2→BO‑1, CH‑3→BO‑20, CH‑4→WBS 3.3, CH‑5→BO‑12, CH‑6→BO‑21, CH‑7→Phase‑5 Annealer, CH‑8→BO‑5. These do not change the sequencing above — they attach a "what good looks like" reference to items we already have, plus the two new items (BO‑20/BO‑21) the comparison surfaced.
diff --git a/FOUNDATION_CONTINUATION.md b/FOUNDATION_CONTINUATION.md
index a9cd31d60..489774f35 100644
--- a/FOUNDATION_CONTINUATION.md
+++ b/FOUNDATION_CONTINUATION.md
@@ -51,7 +51,7 @@ Non-negotiable #4 went from *false* → *enforceable on demand*:
### 4. The larger P1/P2 (buildable autonomously, no owner gate — good next-session work)
- **BO-7** sandbox dynamic agent execution (security-critical, big — needs a substrate call: reuse the mutation container vs nsjail).
- **BO-6** Alembic + auto-apply (start with the `schema_migrations` ledger, the smallest safe win).
-- **BO-10 rest:** consolidate to ONE shared async engine + make `acb_audit.record()` non-blocking (`to_thread`).
+- ~~**BO-10 rest:** consolidate to ONE shared async engine + make `acb_audit.record()` non-blocking (`to_thread`).~~ **DONE 2026-08-06** — see the BO-10 row in the table below.
- **BO-13 finish:** decompose `run_agent_stream` (~1,600 lines) behind a `Runtime` interface — extend the harness to the **Copilot tier + idle-timeout** branches FIRST (can only be exercised on Linux/CI, not the Windows dev box — see the caveat below).
- **BO-15** tier→model single source of truth (then **BO-16** can retire the now-load-bearing `config.yaml`), **BO-5** OTLP export, **BO-9** lifecycle, **BO-17** graduate CI gates + broaden eval paths, **BO-14** destructive-tool registry.
@@ -117,7 +117,7 @@ uv run python -m pytest -m integration -q # NEW: needs the docker s
**DB connection facts:**
- Settings key: `database_url` (`packages/acb_common/acb_common/settings.py`), default `postgresql+psycopg://acb:acb_dev_change_me@localhost:5432/acb`.
-- Access layer: `acb_graph.get_session()` (sync SQLAlchemy). Async engines also exist in `routes/tasks/core.py` and `routes/email/core.py` (see BO-10).
+- Access layer: `acb_graph.get_session()` (sync SQLAlchemy) for sync callers. **Every async caller shares ONE engine and pool: `acb_common.db` (`get_db`/`get_session_factory`), re-exported as `gateway.db`** — BO-10, closed 2026-08-06. Adding a second `create_async_engine` fails `tests/unit/test_db_engine_seam.py`.
- Migrations dir: `infra/postgres/NN_*.sql` (idempotent by hand); runner: `scripts/apply_migrations.sh`; applied on deploy by `deploy/hostinger/deploy.sh:71` and `.github/workflows/deploy.yml:163`.
---
@@ -223,7 +223,7 @@ Two runtimes coexist (native MAF + GitHub Copilot SDK) while `AGENTS.md` claims
| **BO-4** event bus | Redis Streams producer (`apps/services/ingestion/ingestion/queue.py`) has **no consumer** — no `xreadgroup`/`xack` anywhere in the repo, so `ingestion:*` and `ingestion:dlq` are write-only and trimmed unread. **Correction (2026-08-02): "webhook→agent flow not wired" is stale** — ClickUp fans out through `ingestion.event_hooks` to `workflows.triggers.dispatch_event` → `start_run` today (commit `e20ea830`); Gmail/Zoho receivers are still TODO stubs. | `apps/services/ingestion/` | **Owner: `FOUNDATION_BUILDOUT_CHECKLIST.md` §BO-20 (= BO-4). This row is a pointer, not a second plan** — the earlier "ship it OR drop the claim" fork is withdrawn; §BO-20 owns the scope, the tickets (BO-20a–f), the acceptance criteria and the one open owner decision (§BO-20.0, the process model). Redis is already provisioned. |
| **BO-5** observability | OTel disabled + exporter not installed + no collector | `acb_common` deps, `infra/docker-compose.yml`, `executor` kill-switch | Either add `opentelemetry-exporter-otlp` + a collector (Langfuse half-present under `obs` profile) and re-enable MAF/LiteLLM tracing, or delete the OTel deps + "OTLP-ready" claim. |
| **BO-9** lifecycle | Fire-and-forget `ensure_future` warmups untracked/never cancelled; no engine/Neo4j dispose on shutdown; Redis per-call in ingestion | `apps/gateway/gateway/main.py`, `ingestion/queue.py` | Hold task refs + cancel after `yield`; create/dispose a shared engine + Redis pool in `lifespan`. |
-| **BO-10** DB engines ◑ | **Partial:** `connect_timeout` now on **every** engine — `acb_graph` (ccccdc8), the two gateway engines (1684e1a), and the four `email_ingestion` engines (`1ff6c0d`, local). **Left:** consolidate to ONE shared async engine; **make sync `acb_audit.record()` non-blocking** (still blocks the loop on async paths). | `acb_graph/db.py`, `routes/tasks/core.py`, `routes/email/core.py`, `email_ingestion/*`, `acb_audit/log.py` | Provide one configured async engine in `acb_graph`; funnel all callers; make `record()` async (or always `to_thread`). |
+| **BO-10** DB engines ✅ | **CLOSED 2026-08-06.** `connect_timeout` was already on every engine (`ccccdc8`/`1684e1a`/`1ff6c0d`). Both remaining items are now done. **(a) ONE async engine.** The seam lives in `acb_common/db.py` — not in `gateway/db.py`, because `acb_auth.access` resolves permissions from Postgres on the request path *inside* the gateway process and cannot import `gateway`, so a gateway-owned seam could never get below two pools. `gateway/db.py` is a re-export. All eight gateway route packages plus `acb_auth.access` resolve to it; each kept its historical `get_db`/`_get_db`/`_get_session_factory` name as a re-export so ~50 call sites and every `monkeypatch.setattr(, "_get_db", …)` are untouched. **acb_auth's engine had never carried the 2026-08-06 connect/`idle in transaction` bounds — it does now.** Pool ceiling 30 (`db_pool_size`+`db_max_overflow`, tunable), against ~165 before: stock Postgres allows 100 and Langfuse/LiteLLM/ingestion share it, so the old sum was a budget that could not be spent, only exceeded. **(b) Non-blocking audit.** `record()` keeps its sync signature and dispatches to `asyncio.to_thread` only when called from a running loop; sync callers still write inline. `acb_audit.drain()` is awaited last in the gateway lifespan so a shutdown cannot cancel an in-flight row. Ratchet: `tests/unit/test_db_engine_seam.py` (AST-parsed, allowlisted, fails on a new engine AND on a stale allowlist entry) + `tests/unit/test_audit_non_blocking.py`. **Still open, deliberately:** `acb_graph/db.py`'s **sync** `create_engine` — a different pool for a different (sync) caller set, and converting it is an acb_graph rewrite, not this ticket. | `acb_common/db.py` (new), `gateway/db.py`, `routes/{admin,apps,email,notes,whatsapp,workflows}/*`, `acb_auth/access.py`, `acb_audit/log.py`, `gateway/main.py` | — |
| **BO-15** LLM SoT | tier→model still defined in 4 disagreeing places; `_TIER_CONTEXT_WINDOWS` a stale copy | `acb_llm/client.py`, `infra/litellm/*.yaml`, `settings.py`, DB `model_config` | Make DB `model_config` authoritative; delete `tier_overrides.yaml`/`enabled_models.json`/proxy directives once seeded; have `settings.py` read windows from `context.py`. |
| **BO-16** vestigial proxy | `infra/litellm/config.yaml` is a full proxy config but no proxy runs; `provider_models_cache.json` a rotting committed cache | `infra/litellm/`, `infra/provider_models_cache.json` | Reduce to the tier map (or DB); delete the cache; align `infra/AGENTS.md`. |
| **BO-7** sandbox | Dynamic agent code runs in-process w/ full gateway privileges; deps install into shared venv | `acb_skills/loader.py` | Run agent execution in the mutation-style container / restricted subprocess w/ per-run venv. Big; security-critical. |
@@ -238,7 +238,7 @@ Two runtimes coexist (native MAF + GitHub Copilot SDK) while `AGENTS.md` claims
The foundation is "production-ready" (the audit's bar) when:
1. **Trust boundaries enforced:** default-deny auth (B2); Action Broker gates all outward writes (A2/B1); dynamic agent code sandboxed (BO-7); leaked secret rotated + purged from history (BO-8 residual).
2. **Governance real:** self-mutation counter + human gate + real test gate — **done** (BO-3/F8); mutation wired into the streaming path or explicitly scoped (H5).
-3. **Data layer sound:** Alembic + auto-apply (A1); one DB engine + non-blocking audit (BO-10).
+3. **Data layer sound:** Alembic + auto-apply (A1); one DB engine + non-blocking audit — **done** (BO-10, 2026-08-06).
4. **Observability honest:** either real OTLP export + collector, or the claim removed; cost per-tier populated — **partly done** (BO-5).
5. **Event flow wired:** the webhook→run path is real for ClickUp already (via the event-sink registry, not Redis); the bar here is the durable half — a consumer draining `ingestion:*` with retry/DLQ, and Gmail/Zoho receivers at ClickUp parity. Owned by `FOUNDATION_BUILDOUT_CHECKLIST.md` §BO-20 (= BO-4); the "or remove the claim" alternative is withdrawn.
6. **Executor maintainable:** `run_agent_stream` decomposed behind a `Runtime` interface, xenon ceiling ratcheted (C1).
diff --git a/ai-company-brain/work_plan.md b/ai-company-brain/work_plan.md
index fba68a184..2e3a4b432 100644
--- a/ai-company-brain/work_plan.md
+++ b/ai-company-brain/work_plan.md
@@ -99,7 +99,7 @@ looks. Full statements live in `FOUNDATION_BUILDOUT_CHECKLIST.md`.
|---|---|---|---|
| 1 | ~~**`main` has no branch protection**~~ — **CLOSED 2026-08-03** | WS-5 · checklist §BO-17 | Was `404 Branch not protected` with rulesets `[]` under both mechanisms, so every CI gate in the YAMLs was decorative. **Enabled 2026-08-03** (owner-authorised in-session): PRs required, `required_approving_review_count: 0`, **`enforce_admins: true`**, force-push and deletion blocked. Verified by reading the protection back. ⚠️ **`required_status_checks` is deliberately `null`**: `pr-check.yml` has `paths-ignore: ["**.md", "ai-company-brain/**"]`, so a docs-only PR produces **no** check-runs — requiring those contexts would make every docs PR permanently unmergeable (this row's own PR included). Tightening path: add an always-runs sentinel job to `pr-check`, then require **that** one context. |
| 2 | **No backup / restore path** | **new: checklist §BO-23** | The only DB script that dumps anything is `scripts/dump_schema.sh`, which is `pg_dump --schema-only` (`:52`) — **structure, zero rows**. There is no `pg_restore`, no logical data dump, no WAL archiving (`archive_mode`/`wal_level`/`pgbackrest`/`wal-g` appear nowhere in `infra/` or `deploy/`), and no restore runbook. Meanwhile `scripts/apply_migrations.sh` replays **every** numbered migration ≥ `02_` on **every** deploy under `psql -v ON_ERROR_STOP=1` (`:59-74`) with no ledger and no down-migrations — 140 files today, 142 numbered files on disk. `deploy/hostinger/README.md:115` is honest that the only backup is Hostinger's **weekly whole-VPS** image and that PITR is a "later" item. Largest uncovered risk, and it scales with app count. |
-| 3 | **DB engine sprawl** | checklist §BO-10 | Measured 2026-08-03: **12 `create_async_engine(...)` call sites across 10 modules** (`acb_auth/access.py:69`; gateway `routes/{admin,apps,email,notes,tasks,whatsapp,workflows}/*core*.py`; `email_ingestion/{inbound,scheduler}.py` ×4), plus a 13th **sync** `create_engine` in `acb_graph/db.py:32`. Eight are module-level cached `_ENGINE` singletons and **none of them is disposed on shutdown** — the only `engine.dispose()` calls in the tree are the four `email_ingestion` per-call engines cleaning up after themselves. **This is the one that compounds: one engine per app, added by each app.** The next app should extend a shared seam, not add engine 13. |
+| 3 | ~~**DB engine sprawl**~~ **CLOSED 2026-08-06** | checklist §BO-10 | Measured 2026-08-03: **12 `create_async_engine(...)` call sites across 10 modules** (`acb_auth/access.py:69`; gateway `routes/{admin,apps,email,notes,tasks,whatsapp,workflows}/*core*.py`; `email_ingestion/{inbound,scheduler}.py` ×4), plus a 13th **sync** `create_engine` in `acb_graph/db.py:32`. Eight are module-level cached `_ENGINE` singletons and **none of them is disposed on shutdown** — the only `engine.dispose()` calls in the tree are the four `email_ingestion` per-call engines cleaning up after themselves. **This is the one that compounds: one engine per app, added by each app.** The next app should extend a shared seam, not add engine 13. **CLOSED 2026-08-06:** every async caller now resolves to ONE engine and pool in `packages/acb_common/acb_common/db.py` — not `acb_graph` (the gateway does not depend on it, and its engine is sync) and not `gateway/db.py` (which `acb_auth/access.py` cannot import, so a gateway-owned seam could never get below two pools in the gateway process). The six remaining route packages plus `acb_auth.access` were converted, each keeping its historical `get_db`/`_get_db`/`_get_session_factory` name as a re-export so ~50 call sites and every test monkeypatch are untouched; `gateway/db.py` is a re-export. `acb_auth`'s engine had never carried the 2026-08-06 connect/`idle in transaction` bounds — it does now. Pool ceiling 30 (tunable via `db_pool_size`/`db_max_overflow`), deliberately not the old ~165 sum, which exceeded a stock `max_connections` of 100 shared with Langfuse/LiteLLM/ingestion. `acb_audit.record()` is non-blocking on the loop (`to_thread` only when a loop is running; sync callers still inline) and `acb_audit.drain()` is awaited last in the gateway lifespan. Guarded by `tests/unit/test_db_engine_seam.py` + `tests/unit/test_audit_non_blocking.py`. Still open by design: `acb_graph/db.py`'s **sync** `create_engine` and `email_ingestion`'s per-run engines. |
### Substrate (foundation)
diff --git a/apps/agents/agent-app-builder/instructions.md b/apps/agents/agent-app-builder/instructions.md
index 0602fbd09..1e0271c35 100644
--- a/apps/agents/agent-app-builder/instructions.md
+++ b/apps/agents/agent-app-builder/instructions.md
@@ -116,14 +116,53 @@ single line of CSS. Reach for these instead of hand-rolling equivalent styles;
it's both the fastest path (no CSS to write or debug) and the only way the app
is guaranteed to look native, not "close enough."
-**Never redeclare these** (no `:root { --primary: ... }`, no `.cc-card { ... }`
-override) — use them as-is, the same rule `load_design_system`'s full reference
-states for reports.
-
-- **Tokens** (use directly in your own CSS, never redefine): `--cc-bg`,
- `--cc-card`, `--cc-fg`, `--cc-muted`, `--cc-border`, `--cc-secondary`,
- `--cc-primary`, `--cc-primary-fg`, `--cc-accent`, `--cc-success`,
- `--cc-warning`, `--cc-danger`, `--cc-radius`, `--cc-ease`.
+### The one rule that matters: never write a colour
+
+**CommandCenter is themed, and the theme is an org-wide setting somebody can
+change at any time.** RapidTool, Fluent, Material and Graphite differ in palette
+*and* in personality — corner radius, button shape, icon set, whether labels are
+uppercase, how a control reacts to hover. Settings → Appearance switches all of
+it, for everyone, in one click.
+
+Every `--cc-*` value below is resolved from **whatever theme is active when your
+app is opened**, and updates live if it changes while the app is running. So an
+app styled with tokens follows the company's design system for the rest of its
+life, with no edit. An app with `background: #0ea5e9` in it is stuck looking like
+2026's theme forever, on a surface where everything around it has moved on — and
+because it *renders fine*, nobody will notice until it looks broken.
+
+That is the whole deal: **use the tokens and you get theming for free; write one
+hex value and that part of your app leaves the design system permanently.**
+
+**Never redeclare a token** (no `:root { --cc-primary: ... }`, no
+`.cc-card { ... }` override) — a redeclaration is a hardcoded colour with extra
+steps, and it wins over the theme.
+
+- **Colour** — `--cc-bg` (page), `--cc-card` (panel), `--cc-fg` (text),
+ `--cc-muted` (secondary text), `--cc-border`, `--cc-secondary` (quiet fills),
+ `--cc-primary` + `--cc-primary-fg` (the interactive colour and the ink that
+ goes ON it), `--cc-accent`, and the four states `--cc-success`,
+ `--cc-warning`, `--cc-danger` each with an ink pair `--cc-success-fg`,
+ `--cc-warning-fg`, `--cc-danger-fg`.
+ **Always use the `-fg` partner for text on a coloured fill.** White is not
+ safe: some themes ship a pale warning colour, and white-on-pale is invisible.
+- **Type** — `--cc-font` (UI stack), `--cc-mono` (figures, code, anything that
+ should line up in columns), `--cc-heading-weight`, `--cc-heading-tracking`.
+ *Caveat worth knowing:* the frame can only pass **named and system** families,
+ so you get Segoe UI on Fluent and Roboto on Material, but not a self-hosted
+ webfont — the sandbox blocks font loading by design. Shape, spacing and colour
+ cross intact; that one thing does not.
+- **Shape & motion** — `--cc-radius`, `--cc-border-width`, `--cc-shadow`,
+ `--cc-duration`, `--cc-ease`. Use `--cc-ease` and `--cc-duration` for
+ transitions rather than inventing curves; it is how the app feels the same as
+ the shell around it.
+- **Control personality** — `--cc-button-radius` (Material makes buttons full
+ pills, Fluent nearly square), `--cc-control-filled-border`,
+ `--cc-control-state-layer`, `--cc-control-focus-ring`,
+ `--cc-control-label-tracking`, `--cc-control-label-transform`.
+ You mostly won't touch these: `.cc-btn` and the native controls already apply
+ them. They are here for when you build a control the kit doesn't have, so it
+ can behave like the ones it does.
- **Buttons & panels** — `` /
``, `
…
`.
Native `input`/`select`/`textarea`/`input[type=range]` are already styled
diff --git a/apps/services/gateway/AGENTS.md b/apps/services/gateway/AGENTS.md
index 2555c0d0a..f9a42fb15 100644
--- a/apps/services/gateway/AGENTS.md
+++ b/apps/services/gateway/AGENTS.md
@@ -13,7 +13,7 @@ webhook receivers, OAuth callbacks, and the Control Plane API.
## Local Contracts
-0. db.py -- **The shared async engine seam (BO-10).** `get_engine()` / `get_session_factory()` / `get_db()`, module-level cached, SQLAlchemy's async stack imported INSIDE the functions so importing this module does not drag it into a process that never opens a connection. The engine is never disposed here: the pool's lifetime is the process's, and a `dispose()` seam would be a way to close connections other handlers are still using. **A new app package consumes this rather than adding engine thirteen** — `routes/crm` does, and `routes/tasks/core.py` was converted to it as the proof. See item 12c.
+0. db.py -- **The shared async engine seam (BO-10) — now a re-export of `acb_common.db`, which is where the seam and its reasoning live.** `get_engine()` / `get_session_factory()` / `get_db()`, module-level cached, SQLAlchemy's async stack imported INSIDE the functions so importing it does not drag it into a process that never opens a connection. The engine is never disposed: the pool's lifetime is the process's, and a `dispose()` seam would be a way to close connections other handlers are still using. **The gateway makes ZERO `create_async_engine` calls of its own** — closed 2026-08-06 by converting the last six route packages (`admin`, `apps`, `email`, `notes`, `whatsapp`, `workflows`), each of which kept its historical `get_db`/`_get_db`/`_get_session_factory` names as re-exports so ~50 call sites and every `monkeypatch.setattr(, "_get_db", …)` in the tests are untouched. Why the seam moved OUT of the gateway: `acb_auth.access` resolves permissions from Postgres on the request path and runs *inside* this process but cannot import `gateway`, so while the seam lived here the gateway had two pools no matter how many route packages were converted — and that second engine also never carried the 2026-08-06 connect/`idle in transaction` bounds. `acb_common` is the one package both sides already depend on. Pool sizing is `db_pool_size`/`db_max_overflow` (default 10+20 = 30, unchanged from the pre-consolidation seam) because the arithmetic is deployment-wide: stock Postgres allows 100 and Langfuse/LiteLLM/ingestion share the server — the twelve old pools summed to ~165, a budget that could not be spent, only exceeded. **A new app package consumes this rather than adding an engine**, and `tests/unit/test_db_engine_seam.py` fails the build if one appears (AST-parsed, not grepped, with an allowlist that must not go stale). See item 12c.
1. main.py -- FastAPI app factory, lifespan (key loading, model cache warmup, aiosmtpd inbound SMTP startup, background email sync scheduler, and the seven supervised loops — email sync, WhatsApp enrichment, tasks provider-sync, calendar auto-rollover, workflow schedule scanner, the BO-20a ingestion event-bus consumer `ingestion.consumer.start_ingestion_consumer` (flag-gated OFF on `INGESTION_CONSUMER`), and the CRM⟷Zoho two-way sync `routes.crm.sync_zoho.start_crm_zoho_sync` (flag-gated OFF on `CRM_ZOHO_SYNC`, and the gate lives inside that function, not here); **every one of the seven is stopped unconditionally on shutdown**, including the flag-gated ones whose loop may never have started — for the ingestion consumer that contract is pinned by `tests/unit/test_ingestion_consumer.py::test_gateway_lifespan_starts_the_consumer_and_stops_it_after_yield`, which reads `main.py` as text and asserts start-before-`yield`, stop-after, and no flag guard on the stop), /copilot/chat AG-UI endpoint (relayed through stream_relay.run_detached when thread_id present)
2. routes/agent.py -- /agent/run, /agent/run/stream (detached: agent survives client disconnect), /agent/run/{thread_id}/reconnect (replay + live follow), /agent/webhook, agent CRUD, mutation inbox (approve/reject). Org access control: `GET /agent` filters the registry to agents the caller may run (unless they hold `agents:manage`, who see everything); all THREE run endpoints (`/run`, `/run/stream`, `/run/async`) call `assert_can_run_agent` — the filtered picker is UX, the endpoint check is the boundary of record. Registry writes (register/patch/delete/pull) need `agents:manage`. `/agent/webhook/{source}` is the fourth run path and is authenticated by HMAC-SHA256 over the raw body (`X-CC-Signature`; per-source `AGENT_WEBHOOK_SECRET_` overrides the global one) — it FAILS CLOSED with 503 when no secret is configured, because it starts an agent run and is internet-reachable. The same events also fan out to workflow event triggers (`routes/workflows/triggers.dispatch_event`, best-effort) — an event can route to an agent, to workflows, or both
3. routes/chat.py -- Chat history CRUD (Postgres-backed sessions and messages) + GET /chat/active-sessions (Redis cc:active:* scan for running agents). Authorization is MEMBERSHIP, not ownership: every predicate that used to be `WHERE user_id = :uid` now goes through `gateway/rooms.py` (`SESSION_VISIBLE_SQL` for lists, `resolve_room_access` for a single session), which is the same answer when a session has one participant. `_get_messages` also applies two room rules that are no-ops outside a room — the late-joiner waterline (`chat_session_participant.join_message_ts`) and the clearance filter (`_render_message` redacts a turn produced by a run acting on capabilities the reader does not hold). `_upsert_messages` stamps authorship SET-ONCE: the three racing writers cannot rename an author, and a human turn is always attributed to the authenticated caller, never to a client-supplied identity.
diff --git a/apps/services/gateway/gateway/db.py b/apps/services/gateway/gateway/db.py
index 86be70d50..85f9444a1 100644
--- a/apps/services/gateway/gateway/db.py
+++ b/apps/services/gateway/gateway/db.py
@@ -1,126 +1,36 @@
"""The gateway's shared async database seam (BO-10).
-The gateway had **twelve** module-level ``create_async_engine`` call sites, one
-per app package, each with its own pool that nobody can size, drain or observe
-as a whole. The board's standing instruction is that the next app extends a
-shared seam rather than adding engine thirteen — so this module exists and
-``routes/crm`` consumes only it.
-
-This is the ``routes/tasks/core.py`` block **lifted, not redesigned**: the same
-URL coercion, the same pool sizing, the same bounded connect phase. Converting a
-consumer to it is therefore a no-op at runtime, which is the property that makes
-``routes/tasks/core.py`` usable as the proof that the seam works (D-CRM-4,
-``ai-company-brain/specs/crm_app.md`` §4). Converting the other ten call sites
-is explicitly out of scope there and is its own chore.
-
-Two rules worth keeping:
-
-* ``sqlalchemy.ext.asyncio`` is imported **inside** the functions, not at module
- scope. Every app package does it that way and the gateway's import graph
- depends on it — importing this module must not drag SQLAlchemy's async stack
- into a process that never opens a connection.
-* The engine is created on first use and cached for the process. It is never
- disposed here: the pool's lifetime is the process's, and a ``dispose()`` seam
- would be a way to close connections other request handlers are still using.
+This module is now a **re-export of** :mod:`acb_common.db`, which is where the
+seam and all of its reasoning live. It stays here because it is the import path
+every gateway route package already uses, and because "the gateway's DB seam" is
+the name people look for.
+
+Why the seam moved out of the gateway: ``acb_auth.access`` resolves a member's
+permissions from Postgres on the request path and runs *inside* this process,
+but ``acb_auth`` cannot import ``gateway``. While the seam lived here the
+gateway had two pools no matter how many route packages were converted, so the
+ticket's "one engine/pool" could not actually be reached. ``acb_common`` is the
+one package everything already depends on, so that is where it went.
+
+The gateway makes **zero** ``create_async_engine`` calls of its own — see
+``tests/unit/test_db_engine_seam.py``, which fails the build if a new one
+appears.
"""
from __future__ import annotations
-import os
-from typing import Any
-
-from acb_common import get_settings
-
-#: Process-wide, created on first use. Module-level rather than app-state so a
-#: background loop or a broker handler with no ``Request`` reaches the same pool.
-_ENGINE: Any = None
-_SESSION_FACTORY: Any = None
-
-
-def async_database_url() -> str:
- """The configured database URL, coerced onto the asyncpg driver.
-
- ``DATABASE_URL`` wins over the setting because LiteLLM's Prisma client needs
- the plain ``postgresql://`` form in the environment, so the two disagree by
- design and the environment is the one deploy sets.
- """
- settings = get_settings()
- db_url = os.environ.get("DATABASE_URL", settings.database_url)
- if "postgresql+psycopg" in db_url:
- return db_url.replace("postgresql+psycopg", "postgresql+asyncpg")
- if db_url.startswith("postgresql://"):
- return db_url.replace("postgresql://", "postgresql+asyncpg://")
- return db_url
-
-
-#: Ceiling on how long one of OUR sessions may sit `idle in transaction`, in ms.
-#:
-#: This is a lock-release deadline, not a performance knob. SQLAlchemy's
-#: ``AsyncSession`` opens a transaction on first ``execute()`` and holds it until
-#: commit/rollback/close, so a handler that reads a row and then awaits a slow
-#: network call is `idle in transaction` — holding an ACCESS SHARE lock — for the
-#: whole call. That is normal and fine. What is not fine is an await that never
-#: returns: on 2026-08-06 a hung LLM call pinned one such transaction for 14h44m,
-#: a migration's ``ALTER TABLE`` queued behind its lock, and because Postgres's
-#: lock queue is FIFO every later reader of that table queued behind the *waiting*
-#: ALTER. Sending mail stopped, and the pool drained behind the blocked readers.
-#:
-#: ⚠️ MUST stay comfortably above the LLM wall-clock worst case, because the email
-#: automation package legitimately awaits completions with a session open.
-#: ``acb_llm.client`` bounds one call at 3 attempts x 90s + 6s backoff ≈ 276s. At
-#: 600s a genuine retrying completion can never trip this, while a hang is capped
-#: at ten minutes instead of unbounded. Raise ``LLM_REQUEST_TIMEOUT_SECS`` and you
-#: must raise this too — that coupling is the whole reason both numbers are
-#: written down next to their reasoning.
-_IDLE_IN_TXN_TIMEOUT_MS = "600000" # 10 minutes
-
-
-def engine_connect_args() -> dict[str, Any]:
- """Driver-level connect args every gateway engine should share.
-
- Two bounds, both about failing instead of hanging:
-
- * ``timeout`` — asyncpg's CONNECT-phase ceiling, so a slow or unreachable DB
- fails fast rather than stalling request handlers.
- * ``idle_in_transaction_session_timeout`` — the server-side deadline above.
- Set through asyncpg's ``server_settings`` so it rides the connection's
- startup packet and applies to every session from this pool, with no
- migration and no ``ALTER ROLE``. Scoping it to the app's own connections is
- deliberate: ``pg_dump`` and the migration runner connect as the same role
- and must NOT inherit an app-tuned deadline.
- """
- return {
- "timeout": get_settings().db_connect_timeout,
- "server_settings": {
- "idle_in_transaction_session_timeout": _IDLE_IN_TXN_TIMEOUT_MS,
- },
- }
-
-
-def get_engine() -> Any:
- """The shared pooled async engine, created on first use."""
- global _ENGINE
- if _ENGINE is None:
- from sqlalchemy.ext.asyncio import create_async_engine
-
- _ENGINE = create_async_engine(
- async_database_url(), echo=False, pool_pre_ping=True,
- pool_size=10, max_overflow=20, pool_recycle=1800,
- connect_args=engine_connect_args(),
- )
- return _ENGINE
-
-
-def get_session_factory() -> Any:
- """The shared ``async_sessionmaker`` over :func:`get_engine`."""
- global _SESSION_FACTORY
- if _SESSION_FACTORY is None:
- from sqlalchemy.ext.asyncio import async_sessionmaker
-
- _SESSION_FACTORY = async_sessionmaker(get_engine(), expire_on_commit=False)
- return _SESSION_FACTORY
-
-
-async def get_db() -> Any:
- """Return a new async session from the shared, pooled engine."""
- return get_session_factory()()
+from acb_common.db import (
+ async_database_url,
+ engine_connect_args,
+ get_db,
+ get_engine,
+ get_session_factory,
+)
+
+__all__ = [
+ "async_database_url",
+ "engine_connect_args",
+ "get_db",
+ "get_engine",
+ "get_session_factory",
+]
diff --git a/apps/services/gateway/gateway/main.py b/apps/services/gateway/gateway/main.py
index 483decb29..83060fc45 100644
--- a/apps/services/gateway/gateway/main.py
+++ b/apps/services/gateway/gateway/main.py
@@ -386,6 +386,18 @@ def _clone(n: str, r: str | None, lp: str | None) -> None:
except Exception:
pass
+ # Flush audit writes that are still on worker threads. `acb_audit.record`
+ # is non-blocking on the event loop (BO-10), which means an event recorded
+ # moments before shutdown is in flight rather than committed; exiting here
+ # would cancel it. Last, and after every loop above, so writes those loops
+ # made on their way out are included. Bounded internally — a wedged audit
+ # DB cannot hold the shutdown open.
+ try:
+ from acb_audit import drain as drain_audit
+ await drain_audit()
+ except Exception:
+ pass
+
_log.info("gateway.shutdown")
diff --git a/apps/services/gateway/gateway/routes/admin/_common.py b/apps/services/gateway/gateway/routes/admin/_common.py
index 2f5c6b87f..b589c484a 100644
--- a/apps/services/gateway/gateway/routes/admin/_common.py
+++ b/apps/services/gateway/gateway/routes/admin/_common.py
@@ -34,7 +34,6 @@
from __future__ import annotations
-import os
from datetime import datetime, timezone
from typing import Any
@@ -44,8 +43,13 @@
invalidate_access,
permission_matches,
)
-from acb_common import get_logger, get_settings
+from acb_common import get_logger
from fastapi import APIRouter, Depends, HTTPException
+
+# The shared gateway engine (BO-10) — see the DB section below for why the
+# names are re-exported rather than imported at each call site.
+from gateway.db import get_db # noqa: F401
+from gateway.db import get_session_factory as _get_session_factory # noqa: F401
from sqlalchemy import text
_log = get_logger("gateway.admin")
@@ -61,39 +65,17 @@
NON_ASSIGNABLE_ROLES = frozenset({"agent_service"})
-# ── DB (shared pooled async engine, same recipe as routes/apps/_common.py) ───
-
-_ENGINE = None
-_SESSION_FACTORY = None
-
-
-def _get_session_factory() -> Any:
- global _ENGINE, _SESSION_FACTORY
- if _SESSION_FACTORY is None:
- from sqlalchemy.ext.asyncio import ( # noqa: PLC0415
- async_sessionmaker,
- create_async_engine,
- )
-
- from gateway.db import engine_connect_args
- settings = get_settings()
- db_url = os.environ.get("DATABASE_URL", settings.database_url)
- if "postgresql+psycopg" in db_url:
- db_url = db_url.replace("postgresql+psycopg", "postgresql+asyncpg")
- elif db_url.startswith("postgresql://"):
- db_url = db_url.replace("postgresql://", "postgresql+asyncpg://")
- _ENGINE = create_async_engine(
- db_url, echo=False, pool_pre_ping=True,
- pool_size=5, max_overflow=10, pool_recycle=1800,
- connect_args=engine_connect_args(),
- )
- _SESSION_FACTORY = async_sessionmaker(_ENGINE, expire_on_commit=False)
- return _SESSION_FACTORY
-
-
-async def get_db() -> Any:
- """Return a new async session from the shared, pooled engine."""
- return _get_session_factory()()
+# ── DB (the one shared gateway engine — gateway/db.py, BO-10) ────────────────
+#
+# This package used to build its own engine here, with its own pool of 5+10.
+# It now has none: `get_db` and `_get_session_factory` at the top of this module
+# are re-exports of the shared seam, so every `from ._common import get_db` in
+# this package keeps working with a single pool behind it.
+#
+# The re-export is deliberate rather than pointing each caller at `gateway.db`.
+# Sibling modules import the name from here and the tests monkeypatch it *on the
+# sibling* (`monkeypatch.setattr(groups, "get_db", ...)`), so the name has to
+# stay resolvable through this module for both to keep working.
# ── Auth gate ───────────────────────────────────────────────────────────────
diff --git a/apps/services/gateway/gateway/routes/apps/_common.py b/apps/services/gateway/gateway/routes/apps/_common.py
index 110b5be3d..857e93459 100644
--- a/apps/services/gateway/gateway/routes/apps/_common.py
+++ b/apps/services/gateway/gateway/routes/apps/_common.py
@@ -16,7 +16,6 @@
import hashlib
import json
-import os
import re
from collections.abc import Collection, Sequence
from pathlib import Path
@@ -25,6 +24,10 @@
from acb_auth import UserContext, UserRole, get_current_user
from acb_common import get_logger, get_settings
from fastapi import APIRouter, Depends, HTTPException
+
+# The shared gateway engine (BO-10) — see the DB section below.
+from gateway.db import get_db as _get_db
+from gateway.db import get_session_factory as _get_session_factory # noqa: F401
from sqlalchemy import text
_log = get_logger("gateway.apps")
@@ -67,39 +70,14 @@
DEFAULT_AI_TIER = "tier-fast"
-# ── DB (shared pooled async engine, same recipe as tasks/core.py) ────────────
-
-_ENGINE = None
-_SESSION_FACTORY = None
-
-
-def _get_session_factory() -> Any:
- global _ENGINE, _SESSION_FACTORY
- if _SESSION_FACTORY is None:
- from sqlalchemy.ext.asyncio import (
- async_sessionmaker,
- create_async_engine,
- )
-
- from gateway.db import engine_connect_args
- settings = get_settings()
- db_url = os.environ.get("DATABASE_URL", settings.database_url)
- if "postgresql+psycopg" in db_url:
- db_url = db_url.replace("postgresql+psycopg", "postgresql+asyncpg")
- elif db_url.startswith("postgresql://"):
- db_url = db_url.replace("postgresql://", "postgresql+asyncpg://")
- _ENGINE = create_async_engine(
- db_url, echo=False, pool_pre_ping=True,
- pool_size=10, max_overflow=20, pool_recycle=1800,
- connect_args=engine_connect_args(),
- )
- _SESSION_FACTORY = async_sessionmaker(_ENGINE, expire_on_commit=False)
- return _SESSION_FACTORY
-
-
-async def _get_db() -> Any:
- """Return a new async session from the shared, pooled engine."""
- return _get_session_factory()()
+# ── DB (the one shared gateway engine — gateway/db.py, BO-10) ────────────────
+#
+# This package used to build its own engine here with its own 10+20 pool. It now
+# has none: `_get_db` / `_get_session_factory` at the top of this module are
+# re-exports of the shared seam. The private names are kept so that every
+# `from ._common import _get_db` in this package — and every test that
+# monkeypatches `_get_db` on the sibling module it is imported into — keeps
+# working unchanged.
# ── Auth gate ────────────────────────────────────────────────────────────────
diff --git a/apps/services/gateway/gateway/routes/apps/lifecycle.py b/apps/services/gateway/gateway/routes/apps/lifecycle.py
index 0b2c3cb0d..ad715b862 100644
--- a/apps/services/gateway/gateway/routes/apps/lifecycle.py
+++ b/apps/services/gateway/gateway/routes/apps/lifecycle.py
@@ -107,7 +107,7 @@ def starter_index_html(name: str) -> str:
"""The single-file starter app: on-brand via the platform's PRE-INJECTED
design system, with a commented ``window.cc`` usage example (the bridge
+ the ``--cc-*`` tokens and ``.cc-*`` block-kit classes below are
- injected by the run/preview frame itself — ``SandboxedHtml.tsx``'s
+ injected by the run/preview frame itself — ``lib/theme/sandbox-frame.ts``'s
``buildSrcDoc``, the exact same styling every CommandCenter report and
generative-UI card already uses. Never redeclare these — see RFC §4.1
and ``apps/agents/agent-app-builder/instructions.md``'s Design section).
diff --git a/apps/services/gateway/gateway/routes/email/__init__.py b/apps/services/gateway/gateway/routes/email/__init__.py
index e7fcb455f..084a1ab61 100644
--- a/apps/services/gateway/gateway/routes/email/__init__.py
+++ b/apps/services/gateway/gateway/routes/email/__init__.py
@@ -19,8 +19,10 @@
# Flatten submodule namespaces into the package so the historical public surface
# (incl. private ``_helpers`` the scheduler/tests import by name) is preserved.
-# NOTE: reassigned module globals (e.g. core._ENGINE) live on their submodule;
-# read those via ``email.core.``, not the flattened copy.
+# NOTE: this copies BINDINGS once, at import. A name later reassigned on its
+# submodule (or monkeypatched there by a test) diverges from the flattened copy
+# — read and patch those via ``email..``, never through the
+# package namespace.
for _mod in (core, transport, automation, digest):
for _k, _v in vars(_mod).items():
if not _k.startswith("__"):
diff --git a/apps/services/gateway/gateway/routes/email/core.py b/apps/services/gateway/gateway/routes/email/core.py
index e2150b8f5..a6cd51dbe 100644
--- a/apps/services/gateway/gateway/routes/email/core.py
+++ b/apps/services/gateway/gateway/routes/email/core.py
@@ -17,6 +17,9 @@
from acb_common import get_logger, get_settings
from fastapi import APIRouter, HTTPException
+
+# The shared gateway engine (BO-10) — see the DB section below.
+from gateway.db import get_session_factory as _get_session_factory
from pydantic import BaseModel
from sqlalchemy import text
from acb_auth import require_feature_router
@@ -380,40 +383,20 @@ async def hydrate_message_body(db: Any, message_id: str, user_email: str) -> str
return row.body_text or ""
-_ENGINE = None
-
-
-_SESSION_FACTORY = None
-
-
-def _get_session_factory():
- global _ENGINE, _SESSION_FACTORY
- if _SESSION_FACTORY is None:
- from sqlalchemy.ext.asyncio import (
- async_sessionmaker,
- create_async_engine,
- )
-
- from gateway.db import engine_connect_args
- settings = get_settings()
- db_url = os.environ.get("DATABASE_URL", settings.database_url)
- if "postgresql+psycopg" in db_url:
- db_url = db_url.replace("postgresql+psycopg", "postgresql+asyncpg")
- elif db_url.startswith("postgresql://"):
- db_url = db_url.replace("postgresql://", "postgresql+asyncpg://")
- elif "+asyncpg" not in db_url and "postgresql" in db_url:
- db_url = db_url.replace("postgresql://", "postgresql+asyncpg://")
- _ENGINE = create_async_engine(
- db_url, echo=False, pool_pre_ping=True,
- pool_size=10, max_overflow=20, pool_recycle=1800,
- # Bounds the connect phase AND how long one of our sessions may sit
- # `idle in transaction`. This engine is the one that drained on
- # 2026-08-06 — see gateway/db.py::engine_connect_args for why the
- # second bound exists and why it is 10 minutes and not 5.
- connect_args=engine_connect_args(),
- )
- _SESSION_FACTORY = async_sessionmaker(_ENGINE, expire_on_commit=False)
- return _SESSION_FACTORY
+# ── DB (the one shared gateway engine — gateway/db.py, BO-10) ────────────────
+#
+# This package used to build its own engine here with its own 10+20 pool — the
+# engine that drained on 2026-08-06 behind a transaction left `idle in
+# transaction` for 14h44m. Both bounds that came out of that incident now live
+# on the shared seam (``acb_common.db.engine_connect_args``, re-exported as
+# ``gateway.db``), which is where the reasoning for the 10-minute deadline is
+# written down.
+#
+# `_get_session_factory` at the top of this module is a re-export of that seam.
+# `_get_db` stays a real function here only to keep its signature: callers pass
+# nothing today, but the parameter is part of the historical surface this
+# package flattens into ``gateway.routes.email`` (see __init__.py), and tests
+# monkeypatch `_get_db` on the sibling module they exercise.
async def _get_db(request_id: str | None = None):
diff --git a/apps/services/gateway/gateway/routes/notes/core.py b/apps/services/gateway/gateway/routes/notes/core.py
index 362acbcf4..c8cadc2b6 100644
--- a/apps/services/gateway/gateway/routes/notes/core.py
+++ b/apps/services/gateway/gateway/routes/notes/core.py
@@ -16,8 +16,12 @@
from pathlib import Path
from typing import Any
-from acb_common import get_logger, get_settings
+from acb_common import get_logger
from fastapi import APIRouter, HTTPException
+
+# The shared gateway engine (BO-10) — see the DB section below.
+from gateway.db import get_db as _get_db # noqa: F401
+from gateway.db import get_session_factory as _get_session_factory # noqa: F401
from pydantic import BaseModel
from sqlalchemy import text
from acb_auth import require_feature_router
@@ -139,39 +143,14 @@ class PatchMeetingRequest(BaseModel):
copilot_enabled: bool | None = None
-# ── DB (shared pooled async engine, same recipe as tasks/core.py) ────────────
-
-_ENGINE = None
-_SESSION_FACTORY = None
-
-
-def _get_session_factory():
- global _ENGINE, _SESSION_FACTORY
- if _SESSION_FACTORY is None:
- from sqlalchemy.ext.asyncio import (
- async_sessionmaker,
- create_async_engine,
- )
-
- from gateway.db import engine_connect_args
- settings = get_settings()
- db_url = os.environ.get("DATABASE_URL", settings.database_url)
- if "postgresql+psycopg" in db_url:
- db_url = db_url.replace("postgresql+psycopg", "postgresql+asyncpg")
- elif db_url.startswith("postgresql://"):
- db_url = db_url.replace("postgresql://", "postgresql+asyncpg://")
- _ENGINE = create_async_engine(
- db_url, echo=False, pool_pre_ping=True,
- pool_size=5, max_overflow=10, pool_recycle=1800,
- connect_args=engine_connect_args(),
- )
- _SESSION_FACTORY = async_sessionmaker(_ENGINE, expire_on_commit=False)
- return _SESSION_FACTORY
-
-
-async def _get_db():
- """Return a new async session from the shared, pooled engine."""
- return _get_session_factory()()
+# ── DB (the one shared gateway engine — gateway/db.py, BO-10) ────────────────
+#
+# This package used to build its own engine here with its own 5+10 pool. It now
+# has none: `_get_db` / `_get_session_factory` at the top of this module are
+# re-exports of the shared seam. The private names are kept so that every
+# `from .core import _get_db` in this package — and every test that
+# monkeypatches `_get_db` on the sibling module it is imported into — keeps
+# working unchanged.
# ── Ownership — one predicate, one loader ────────────────────────────────────
diff --git a/apps/services/gateway/gateway/routes/settings.py b/apps/services/gateway/gateway/routes/settings.py
index 12168a474..715032001 100644
--- a/apps/services/gateway/gateway/routes/settings.py
+++ b/apps/services/gateway/gateway/routes/settings.py
@@ -1719,3 +1719,170 @@ async def get_cache_info(
seen.add(c.provider)
deduped.append(c)
return deduped
+
+
+# ---------------------------------------------------------------------------
+# GET /settings/appearance — the organisation's default look
+# PUT /settings/appearance — change it (admin only)
+#
+# The theming engine (workbench/control_plane/src/lib/theme/) lets each member
+# pick a theme, but the ORG DEFAULT is what everyone who has not chosen sees,
+# and it is the only way to move the whole company onto one look at once.
+# That decision has to outlive a browser, so it lives here.
+#
+# WHAT THIS ROUTE DELIBERATELY DOES NOT KNOW
+# ------------------------------------------
+# Which themes exist. That list is `THEMES` in the frontend's themes.ts, and
+# the whole point of the engine is that adding a theme is one manifest entry —
+# if the gateway validated `theme_id` against a copy of that list, every new
+# theme would need a backend deploy too, and a mismatch between the two lists
+# would reject a theme the app can actually render.
+#
+# So `theme_id` is stored as an opaque identifier, constrained only to the
+# shape that is safe to put in a CSS attribute selector (the same rule
+# `buildThemeCss` enforces). The frontend re-validates on read and falls back
+# to its default for an id it does not recognise — which it must do anyway,
+# since a theme can be removed from the app long after this row named it.
+#
+# Personal preferences are NOT stored here. They are per-device, they change
+# often, and round-tripping them would add a request to every page load to
+# render something the pre-paint boot script has already applied from
+# localStorage.
+# ---------------------------------------------------------------------------
+
+_APPEARANCE_KEY = "appearance"
+
+#: Same rule as SAFE_ID in the frontend's css.ts: a theme id ends up inside
+#: `html[data-theme="…"]`, so restrict it to characters needing no escaping.
+_THEME_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
+
+_MODES = ("dark", "light")
+_DENSITIES = ("compact", "default", "comfortable")
+
+_APPEARANCE_DEFAULTS: dict[str, Any] = {
+ "themeId": "rapidtool",
+ "mode": "dark",
+ "density": "default",
+ "allowUserOverride": True,
+}
+
+
+class OrgAppearance(BaseModel):
+ """The organisation-wide default look."""
+
+ themeId: str = _APPEARANCE_DEFAULTS["themeId"]
+ mode: str = _APPEARANCE_DEFAULTS["mode"]
+ density: str = _APPEARANCE_DEFAULTS["density"]
+ allowUserOverride: bool = True
+
+
+class AppearanceResponse(BaseModel):
+ org: OrgAppearance
+ #: Who last changed it and when — an org-wide setting affects everybody,
+ #: so "who did this" is the first question when the company's UI changes.
+ updatedBy: str = ""
+ updatedAt: str = ""
+
+
+def _coerce_appearance(raw: Any) -> tuple[OrgAppearance, str, str]:
+ """Normalise a stored blob into a usable default.
+
+ Every field falls back independently: a row written by an older or newer
+ version of the app should degrade one key at a time rather than reset the
+ whole organisation's look.
+ """
+ blob = raw if isinstance(raw, dict) else {}
+ org = blob.get("org") if isinstance(blob.get("org"), dict) else blob
+
+ theme_id = str(org.get("themeId") or "")
+ if not _THEME_ID_RE.match(theme_id) or len(theme_id) > 64:
+ theme_id = _APPEARANCE_DEFAULTS["themeId"]
+
+ mode = org.get("mode")
+ if mode not in _MODES:
+ mode = _APPEARANCE_DEFAULTS["mode"]
+
+ density = org.get("density")
+ if density not in _DENSITIES:
+ density = _APPEARANCE_DEFAULTS["density"]
+
+ allow = org.get("allowUserOverride")
+ if not isinstance(allow, bool):
+ allow = _APPEARANCE_DEFAULTS["allowUserOverride"]
+
+ return (
+ OrgAppearance(
+ themeId=theme_id, mode=mode, density=density, allowUserOverride=allow
+ ),
+ str(blob.get("updatedBy") or ""),
+ str(blob.get("updatedAt") or ""),
+ )
+
+
+@router.get("/appearance", response_model=AppearanceResponse)
+async def get_appearance(
+ _user: UserContext = Depends(get_current_user),
+) -> AppearanceResponse:
+ """Return the org's default theme, mode and density.
+
+ Readable by any signed-in member, not just admins: every client needs it on
+ load to know what to render for someone who has not picked a theme.
+ """
+ from acb_common import load_org_setting # noqa: PLC0415
+
+ org, updated_by, updated_at = _coerce_appearance(
+ load_org_setting(_APPEARANCE_KEY, default={})
+ )
+ return AppearanceResponse(org=org, updatedBy=updated_by, updatedAt=updated_at)
+
+
+@router.put(
+ "/appearance",
+ response_model=AppearanceResponse,
+ dependencies=[require_permission("admin:settings:manage")],
+)
+async def put_appearance(
+ body: OrgAppearance,
+ user: UserContext = Depends(get_current_user),
+) -> AppearanceResponse:
+ """Set the org's default look. Admin-gated — this changes everyone's UI."""
+ from datetime import datetime, timezone # noqa: PLC0415
+
+ from acb_common import save_org_setting # noqa: PLC0415
+
+ # Re-validate rather than trusting the model: pydantic types `mode` and
+ # `density` as plain strings (they are open vocabularies on the frontend),
+ # so the enum check has to happen here or an arbitrary value reaches every
+ # member's browser.
+ if body.mode not in _MODES:
+ raise HTTPException(status_code=400, detail=f"mode must be one of {list(_MODES)}")
+ if body.density not in _DENSITIES:
+ raise HTTPException(
+ status_code=400, detail=f"density must be one of {list(_DENSITIES)}"
+ )
+ if not _THEME_ID_RE.match(body.themeId) or len(body.themeId) > 64:
+ raise HTTPException(
+ status_code=400,
+ detail="themeId must be lowercase alphanumeric with hyphens",
+ )
+
+ updated_at = datetime.now(timezone.utc).isoformat()
+ save_org_setting(
+ _APPEARANCE_KEY,
+ {
+ "org": body.model_dump(),
+ "updatedBy": user.email or "",
+ "updatedAt": updated_at,
+ },
+ updated_by=user.email or "",
+ )
+ _log.info(
+ "settings.appearance.updated",
+ theme_id=body.themeId,
+ mode=body.mode,
+ allow_user_override=body.allowUserOverride,
+ by=user.email,
+ )
+ return AppearanceResponse(
+ org=body, updatedBy=user.email or "", updatedAt=updated_at
+ )
diff --git a/apps/services/gateway/gateway/routes/whatsapp/core.py b/apps/services/gateway/gateway/routes/whatsapp/core.py
index 79f87a420..63f20961b 100644
--- a/apps/services/gateway/gateway/routes/whatsapp/core.py
+++ b/apps/services/gateway/gateway/routes/whatsapp/core.py
@@ -9,11 +9,14 @@
from __future__ import annotations
import json
-import os
from typing import Any
-from acb_common import get_logger, get_settings
+from acb_common import get_logger
from fastapi import APIRouter, HTTPException
+
+# The shared gateway engine (BO-10) — see the DB section below.
+from gateway.db import get_db as _get_db # noqa: F401
+from gateway.db import get_session_factory as _get_session_factory # noqa: F401
from pydantic import BaseModel
from acb_auth import require_feature_router
@@ -113,39 +116,14 @@ class WhatsAppMessageModel(BaseModel):
sent_at: str | None = None
-# ── DB session (own pooled engine, same pattern as email.core) ────────────────
-
-_ENGINE = None
-_SESSION_FACTORY = None
-
-
-def _get_session_factory():
- global _ENGINE, _SESSION_FACTORY
- if _SESSION_FACTORY is None:
- from sqlalchemy.ext.asyncio import (
- async_sessionmaker,
- create_async_engine,
- )
-
- from gateway.db import engine_connect_args
- settings = get_settings()
- db_url = os.environ.get("DATABASE_URL", settings.database_url)
- if "postgresql+psycopg" in db_url:
- db_url = db_url.replace("postgresql+psycopg", "postgresql+asyncpg")
- elif db_url.startswith("postgresql://"):
- db_url = db_url.replace("postgresql://", "postgresql+asyncpg://")
- _ENGINE = create_async_engine(
- db_url, echo=False, pool_pre_ping=True,
- pool_size=5, max_overflow=10, pool_recycle=1800,
- connect_args=engine_connect_args(),
- )
- _SESSION_FACTORY = async_sessionmaker(_ENGINE, expire_on_commit=False)
- return _SESSION_FACTORY
-
-
-async def _get_db():
- """Return a new async session from the shared, pooled engine."""
- return _get_session_factory()()
+# ── DB (the one shared gateway engine — gateway/db.py, BO-10) ────────────────
+#
+# This package used to build its own engine here with its own 5+10 pool. It now
+# has none: `_get_db` / `_get_session_factory` at the top of this module are
+# re-exports of the shared seam. The private names are kept so that every
+# `from .core import _get_db` in this package — and every test that
+# monkeypatches `_get_db` on the sibling module it is imported into — keeps
+# working unchanged.
# ── provider adapter ──────────────────────────────────────────────────────────
diff --git a/apps/services/gateway/gateway/routes/workflows/core.py b/apps/services/gateway/gateway/routes/workflows/core.py
index 7e4d716e4..b6f929365 100644
--- a/apps/services/gateway/gateway/routes/workflows/core.py
+++ b/apps/services/gateway/gateway/routes/workflows/core.py
@@ -14,13 +14,16 @@
from __future__ import annotations
import json
-import os
import secrets
from typing import Any
from acb_auth import UserContext, require_feature_router
from acb_common import get_logger, get_settings
from fastapi import APIRouter, HTTPException
+
+# The shared gateway engine (BO-10) — see the DB section below.
+from gateway.db import get_db as _get_db # noqa: F401
+from gateway.db import get_session_factory as _get_session_factory # noqa: F401
from sqlalchemy import text
_log = get_logger("gateway.workflows")
@@ -41,43 +44,14 @@
MAX_RUNS_PAGE = 100
HOOK_RATE_LIMIT_PER_MINUTE = 60 # per hook token
-# ── DB (shared pooled async engine, same recipe as apps/_common.py) ──────────
-
-_ENGINE = None
-_SESSION_FACTORY = None
-
-
-def _get_session_factory() -> Any:
- global _ENGINE, _SESSION_FACTORY
- if _SESSION_FACTORY is None:
- from sqlalchemy.ext.asyncio import (
- async_sessionmaker,
- create_async_engine,
- )
-
- from gateway.db import engine_connect_args
- settings = get_settings()
- db_url = os.environ.get("DATABASE_URL", settings.database_url)
- if "postgresql+psycopg" in db_url:
- db_url = db_url.replace("postgresql+psycopg", "postgresql+asyncpg")
- elif db_url.startswith("postgresql://"):
- db_url = db_url.replace("postgresql://", "postgresql+asyncpg://")
- _ENGINE = create_async_engine(
- db_url,
- echo=False,
- pool_pre_ping=True,
- pool_size=5,
- max_overflow=10,
- pool_recycle=1800,
- connect_args=engine_connect_args(),
- )
- _SESSION_FACTORY = async_sessionmaker(_ENGINE, expire_on_commit=False)
- return _SESSION_FACTORY
-
-
-async def _get_db() -> Any:
- """Return a new async session from the shared, pooled engine."""
- return _get_session_factory()()
+# ── DB (the one shared gateway engine — gateway/db.py, BO-10) ────────────────
+#
+# This package used to build its own engine here with its own 5+10 pool. It now
+# has none: `_get_db` / `_get_session_factory` at the top of this module are
+# re-exports of the shared seam. The private names are kept so that every
+# `from .core import _get_db` in this package — and every test that
+# monkeypatches `_get_db` on the sibling module it is imported into — keeps
+# working unchanged.
# ── Small helpers ────────────────────────────────────────────────────────────
diff --git a/docs/THEMING_ENGINE_SCOPE.md b/docs/THEMING_ENGINE_SCOPE.md
new file mode 100644
index 000000000..94e978617
--- /dev/null
+++ b/docs/THEMING_ENGINE_SCOPE.md
@@ -0,0 +1,374 @@
+# CommandCenter Theming Engine — Scoping Document
+
+**Status:** Built and shipped through the coverage work (phases 1–4 plus the
+full icon migration, org-default backend, contrast gate and third-party
+surfaces). See "What was built" below; the rest
+of this document is the original research and remains the design rationale.
+**Scope:** `workbench/control_plane` (Next.js 16 · React 19 · Tailwind CSS v4)
+**Goal:** A theming engine that can restyle the entire Control Plane — colors,
+fonts, radii, shadows, icon packs, component "personality" — switchable on the
+fly from Settings, with company-wide defaults. Example target themes: the
+current RapidTool look, a Microsoft Fluent/Metro ("Lumia") look, a Google
+Material look.
+
+---
+
+## 0. What was built
+
+Four themes, switchable from **Settings → Appearance**, each changing colours,
+fonts, corner radius, effects and the icon pack across every page:
+
+| Theme | Look | Icons | Fonts |
+|---|---|---|---|
+| **RapidTool** (default) | The original design, preserved token-for-token | Lucide | Geist |
+| **Fluent** | Microsoft Fluent 2 — 4px corners, acrylic, no glow | Fluent System Icons | Segoe UI → Inter |
+| **Material** | Google Material 3 — 16px corners, flat + elevation | Material Symbols Rounded | Roboto |
+| **Graphite** | Low-distraction monochrome, monospace headings | Lucide | Geist / Geist Mono |
+
+**How it works.** A theme is a manifest in `src/lib/theme/themes.ts` — colours
+for both modes, font stacks, shape, effects, icon pack. `css.ts` compiles every
+manifest to an `html[data-theme="…"]` custom-property scope, inlined once in
+the document head, so switching is a single attribute write with no fetch and
+no flash. *Style* (`data-theme`) and *mode* (the next-themes `.light` class)
+are independent axes. A pre-paint boot script applies the stored preference
+before the first frame.
+
+Adding a theme is a manifest entry — no component, CSS or Tailwind change.
+
+**Icons** go through ``, using Lucide names as the shared
+vocabulary. **Every call site in the app is migrated** — 158 files; the only
+modules still importing `lucide-react` are `Icon.tsx` (the primitive, which
+falls back to Lucide) and `lib/icons.tsx` (the resolver used by server
+components and by `iconSvg.ts`'s static-string rendering, neither of which can
+run hooks). All 251 Lucide icons the app uses resolve in both non-Lucide packs.
+Those packs are pruned Iconify collections (275 icons, ~115 KB and ~106 KB)
+built by `scripts/build-icon-packs.mjs`, fetched lazily only when a theme needs
+them, and rendered offline.
+
+`themedIcon(name)` returns a memoised component bound to one name, for tables
+that store an icon rather than rendering it inline; `ThemedIcon` is its type.
+
+**Apps and generated UI follow the theme too.** Custom Apps, generative-UI
+cards and React artifacts run in an opaque-origin iframe: they inherit nothing —
+not our stylesheet, not `data-theme`, not one custom property. They already had
+a token vocabulary (`--cc-*`, written into `agent-app-builder`'s instructions
+and `acb_skills/design.md`), but its **values were hand-written RapidTool
+literals switching only on light/dark**, so every app ever built stayed
+RapidTool-blue while the shell around it turned Fluent or Material. Nothing
+errored; it just quietly did not theme.
+
+`app-tokens.ts` now derives that block from the active manifest, and
+`sandbox-frame.ts` builds the frame around it. Colour, ink pairs, type, shape,
+motion and the theme's *control personality* (Material's pill buttons and 8%
+state layer, Fluent's tight ring, Graphite's uppercase labels) all cross, so an
+app built a year ago picks up a new theme's behaviour and not merely its
+palette. Icons cross as SVG resolved from the active pack. The block is applied
+twice: in the frame's first `` +
+ `` +
`${mount}${BRIDGE}