Decisions

ADR-100: Factory growth boundaries — monolith UI, federated backends, audience-driven splits

The factory at `gyrum-labs/ai-frontend` is the operator's everything-pane: today, 2026-04-25, it has 77 routes, 1,772 tests, and is growing roughly five routes per week as PM (ADR-097), epics (ADR-098), platform tools…

#100

ADR-100: Factory growth boundaries — monolith UI, federated backends, audience-driven splits

Status: Accepted Date: 2026-04-25 Related: ADR-074 (CI/CD plane separation — establishes per-service deploy autonomy that the federated backend pattern relies on), ADR-081 (UI shell consolidation grammar — names the factory as the operator's everything-pane and is the principle this ADR protects), ADR-094 (compose-bundled-in-image — locks per-service deploy cadence so backend federation has zero coordination cost), ADR-097 (project management lives in the factory — sets the precedent that operator-facing UIs collapse into one shell while their backends stay federated)

Context

The factory at gyrum-labs/ai-frontend is the operator's everything-pane: today, 2026-04-25, it has 77 routes, 1,772 tests, and is growing roughly five routes per week as PM (ADR-097), epics (ADR-098), platform tools (ADR-093), pipelines (ADR-090), agents, infra, and observability all land their operator surfaces under one SvelteKit app. The user's question — "how big code-wise can factory get, is it structured cleanly enough or at some point we'll have to micro-framework it?" — is the right one to ask now, before the answer becomes "yes, and we did it badly." The question recurs every quarter; without a captured decision, every future Claude and every future operator will re-litigate the same three options and arrive at the same answer slightly differently each time. This ADR captures the answer once.

Three candidate shapes are on the table. (A) Monolith UI stays — keep adding routes to ai-frontend indefinitely. (B) Per-concern apps — split into pm.gyrum.ai, infra.gyrum.ai, operate.gyrum.ai, etc., each a separate SvelteKit deploy. (C) Module federation / micro-frontends — runtime composition of independently deployed UI fragments inside one shell. (D) Hybrid — keep one operator UI (the monolith), keep backends federated (warp, ai-research, gyrum-pipelines, etc., each a service the factory pulls from via a dedicated api-*.ts client per ADR-094). The factory has been running shape (D) for backends since ADR-074; the question this ADR settles is what shape the UI takes as it grows.

Decision

The factory keeps a single operator UI (one SvelteKit app at gyrum-labs/ai-frontend, served at gyrum.ai) and federated per-domain backends (one service per concern, each with its own deploy cadence, each consumed via a dedicated $lib/api-<domain>.ts client). Splits happen by audience, not by route or by code-size: an audience that shares the operator's chrome (one auth, one ⌘K palette, one design system, one nav) belongs in the same shell; an audience with a different auth model, brand, or cadence (e.g. a future gyrum-customer portal, a marketing site) belongs in a separate app. Module federation / micro-frontends is rejected outright.

The shape of the decision:

  • The split UNIT is an audience, not a route. /pm, /operate, /infra, /playbooks, /agents, /projects, /platform/* all share the operator audience and share one shell. A future "people who buy from us" UI is a different audience and gets a separate app. Within an audience, prefer SvelteKit route groups ((operate)/, (ship)/) for cosmetic grouping; URLs unchanged.
  • The factory's value IS the unified shell. One ⌘K palette that knows every route, one auth cookie, one design system, one persistent sidebar, one mode pill. Splitting the operator UI by route or by concern loses the dogfooding velocity that makes the factory worth building — every cross-concern affordance (palette, breadcrumbs, recent-items, cross-links) costs N times more to ship in N apps than in one.
  • SvelteKit handles 200-500 routes fine. The runtime cost of a route is a code-split chunk that loads on demand. The build cost scales with route count but stays comfortably under the dev-loop pain threshold up to several hundred routes. /sitemap and /explore already exist (ADR-081's grammar) so operators can find anything without memorising the tree.
  • Existing structural caps already provide pressure-valves. The 900-line/file, 30-files/dir, and 800-LOC/PR caps from CLAUDE.md force decomposition before any single artifact becomes unmaintainable. The factory has not yet hit any of these caps as a forcing function on UI structure (the api-infra split in PR ai-frontend#199 was the first api-*.ts to hit the file-length cap; that is the expected graduation path, not a sign of trouble).
  • Backend federation is already in place. Each domain owns its own service: warp = work-coordination, ai-research = playbook engine, gyrum-pipelines = pipeline lib, distill = ingest. The factory pulls from each via a dedicated $lib/api-*.ts client. ADR-094's compose-in-image rule keeps deploy cadence per-service; the factory's deploy cadence stays decoupled from any one backend's cadence. This ADR formalises that pattern; it does not change it.

Pre-emptive split signals — when to revisit this ADR:

  • Dev build time exceeds 30s on a clean build → split route loaders, lazy-load heavier groups, before splitting the app.
  • Test suite runtime exceeds 60s on a clean run → shard by route group inside the same app, before splitting the app.
  • A single $lib/api-*.ts file exceeds 900 lines → split by sub-domain inside the same app (precedent: api-infra.ts in ai-frontend PR #199).
  • Top-level routes exceed 50 → introduce SvelteKit route groups for organisation; URLs unchanged, app unchanged.
  • A concern's deploy cadence diverges sharply from the rest of the factory (e.g. 3x/day vs once/week) → peel that concern off into its own app (this is a real audience signal, not a code-size signal).
  • A new audience appears (different auth model, different brand, different operator persona) → that audience gets its own app from day one; do not bolt it into the operator shell.

Most likely first peel-off (12-18 months out): gyrum-customer — the "people who buy from us" audience UI. Different auth model (customer accounts, not operator OIDC), different brand (customer-facing, not internal-tools-grey), different cadence (release-windowed, not continuous). When that surface appears, it ships as a separate SvelteKit app at customer.gyrum.ai, sharing only the design tokens and stable widgets (via gyrum-ui, the shared component library) — not the shell, not the chrome, not the palette.

Consequences

What becomes easier:

  • Future Claudes and future operators get the same answer. The "should we split the UI?" question has a captured decision tree: is this a new audience? (split) is this a code-size or build-time problem? (apply structural caps and SvelteKit features inside the same app) is this a deploy-cadence problem? (peel off the one concern that's diverging, not the whole app). No quarterly re-litigation.
  • The factory keeps compounding. Every chrome improvement, every palette entry, every design-system token applies to every operator surface for free. New domains (the next /pm-shaped concern) inherit the shell on day one — they ship a route tree and an api-*.ts client, and they are immediately findable, palette-addressable, and visually consistent.
  • Backend federation gets a name and a rule. "One service per domain, factory consumes via api-*.ts, deploy cadence per ADR-094" is now an ADR-pinned pattern, not folklore. New domain services (the next warp-shaped service) know exactly what shape they sign up to.

What becomes harder:

  • The monolith UI's build, test, and deploy times grow with route count. This is the cost we accept. The pre-emptive split signals above (30s build, 60s test, 50 top-level routes) name the thresholds at which we apply intra-app remedies (route groups, shard tests, lazy loaders) before considering an inter-app split.
  • A concern with sharply divergent deploy cadence is a slow-burn problem. Operator-facing UI ships continuously; if a concern needs release-windowed deploys (regulated compliance UI, customer-facing payments UI) it does not belong in the operator shell. The audience rule catches this — those are different audiences — but the signal can hide for a while behind "we're just being careful with this route."
  • The "audience" judgement is fuzzy at the edges. A future /admin for non-engineering staff (finance, support) is a real call: same auth as operators? same brand? same cadence? The rule is to err toward "same audience, same shell" until a concrete operational signal (different auth model, different brand requirement, different release cadence) forces the split. Premature splits are more expensive than late ones.

What we have signed up to operate:

  • One SvelteKit app at gyrum-labs/ai-frontend serving gyrum.ai, owning every operator-facing route. Structural caps (900-line file, 30-files dir, 800-LOC PR) enforce intra-app decomposition.
  • One $lib/api-<domain>.ts client per backend service in the federation. Each is a maintained client (typed, cached, 401-handling, source-aware URL routing where relevant).
  • A documented "audience-driven splits" rule referenced from dark-factory/docs/ARCHITECTURE.md next to the existing factory paragraph, so a contributor proposing a new top-level surface is pointed at this ADR before they reach for option (B) or (C).

What we revisit:

  • Any time a pre-emptive split signal trips. The signal names the remedy; the remedy is intra-app first, inter-app only if the signal is a true audience or cadence divergence.
  • When gyrum-customer (or any other clearly distinct audience) becomes a real product. That ADR will reference this one and will name the second app explicitly.
  • If SvelteKit's route-count scaling pain arrives sooner than expected (build time over 30s before we hit 200 routes, e.g.), we revisit the "200-500 routes is fine" assumption — but the remedy is still intra-app (lazy loaders, route groups), not inter-app.

Alternatives considered

  • (A) Monolith UI stays unchanged forever. Plain monolith with no audience rule. Loses on the gyrum-customer question — when a genuinely different audience appears, bolting it into the operator shell mixes auth models, brand, and cadence in one app, which is the worst of every world. Rejected because the audience boundary is real and load-bearing; pretending it isn't just defers the cost.
  • (B) Per-concern apps from now (pm.gyrum.ai, infra.gyrum.ai, operate.gyrum.ai). Splits the operator UI by concern. Loses on every reason ADR-081 exists — the unified shell, the cross-concern palette, the persistent sidebar, the dogfooding velocity all evaporate the moment the operator has to switch tabs (and re-auth, and re-orient) to move from /pm to /infra. Also loses on the "same audience" observation: PM and infra are the same operator wearing different hats inside the same workflow, not different operators. Rejected — splitting by concern within an audience optimises the wrong axis.
  • (C) Module federation / micro-frontends. Compose independently deployed UI fragments inside one shell at runtime (Webpack Module Federation, single-spa, native ESM federation). The industry pattern around this is consistent: heavy regret within ~2 years (build complexity moves to runtime complexity, version-skew between fragments produces UX bugs invisible at build time, the shell still has to know every fragment's contract so the "independence" is mostly nominal). Rejected outright — we trade a mild build-time concern (which we don't have yet) for a severe runtime concern (which is hard to undo once shipped).
  • (D) Hybrid: monolith UI + federated backends + audience-driven splits. Chosen. Captures what we're already doing for backends, names the rule for the UI, gives future Claudes a decision tree.

What NOT to do (carried into the rule):

  • Do not introduce module federation / micro-frontends (option C). This is a one-way door; the regret window is 18-24 months and the recovery cost is rebuilding the shell from scratch.
  • Do not split by route. A route is a navigation primitive, not an audience boundary; splitting by route fragments the shell without fragmenting the audience.
  • Do not pull route handlers into gyrum-ui. The shared component library holds stable widgets (buttons, tables, palette, layout primitives) — not routes, not data fetching, not page-level logic. Pulling routes into gyrum-ui would be a reverse module-federation in a worse coat.

Supersedes: none. Composes with ADR-074 (per-service deploy autonomy), ADR-081 (UI shell consolidation grammar), ADR-094 (compose-bundled-in-image), ADR-097 (PM in factory — sets the precedent this ADR generalises). Will be referenced by ADR-099 (brief-as-PR) and any future ADR proposing a new top-level operator surface. Superseded by: