# Architecture Rails

Authoritative source: [`docs/adr/010-product-core-vs-customer-adapter-separation.md`](../adr/010-product-core-vs-customer-adapter-separation.md)
and [`docs/adr/015-architecture-boundary-enforcement.md`](../adr/015-architecture-boundary-enforcement.md).

## The Law

> **The product core must never import from a customer-specific module.**
>
> BMW is an adapter, not the framework.

Everything in this document exists to make that one rule enforceable in code,
not just in spirit.

## The Four Circles

```
┌──────────────────────────────────────────────────────────┐
│ 1. Core         pure domain — OEE engine, master data    │
│                 schemas, RLS contracts, design system    │
├──────────────────────────────────────────────────────────┤
│ 2. Platform     cross-cutting infra — logger, supabase   │
│                 clients, auth helpers, profile loader    │
├──────────────────────────────────────────────────────────┤
│ 3. Enterprise   feature flags, branding, integrations    │
│    config       declared via composition profiles        │
├──────────────────────────────────────────────────────────┤
│ 4. Customer     BMW-specific (and any future tenant) —   │
│    adapter      lives in lib/customers/<name>/, never    │
│                 imported by 1–3                          │
└──────────────────────────────────────────────────────────┘
```

Imports may flow **inward** (4 → 3 → 2 → 1), never outward.

## The Layer Map (current repo)

| Path                    | Circle              | May import from                        |
| ----------------------- | ------------------- | -------------------------------------- |
| `app/`                  | App composition     | components, lib, hooks, config         |
| `components/`           | Core (UI)           | components, lib, hooks                 |
| `lib/<domain>/`         | Core / Platform     | lib (siblings), config (read-only)     |
| `lib/customers/<name>/` | Customer adapter    | lib (any), config — but **not vice versa**. Today: `lib/customers/bmw/branding/` (design system + palette). Phase 9: `lib/customers/bmw/adapters/{identity-webeam,otel-dynatrace,mail-gateway,itsm,postgres-ro}/`. |
| `config/profiles/`      | Enterprise config   | self only                              |
| `hooks/`                | Platform            | lib, config                            |

## Module public surfaces

**The rule:** `lib/<module>/index.ts` is the only file other modules may
import from. Internal files (everything else under `lib/<module>/`) are not
public API.

The boundaries lint does not yet *enforce* the public-surface rule (that
requires the v6 selector migration), but it is enforced by convention and
review. New modules scaffolded via `npm run new:module` get the structure
automatically.

Today's barrels: `lib/api/`, `lib/cache/`, `lib/control-plane/`,
`lib/customers/bmw/`, `lib/entitlement/`, `lib/oee/`, `lib/owner-audit/`,
`lib/provisioning/`, `lib/support/`, `lib/tenant/`. New external imports
should target these, not deep paths. Existing deep imports are tolerated;
migrate opportunistically (don't churn).

## The API contract

The single source of truth for the public, externally-stable API surface is
`openapi/v1/openapi.json`. See [`api-contract.md`](./api-contract.md) for the
full rules. Quick version:

- `/api/v1/*` is the public contract (versioned, documented, breaking changes
  require `/api/v2/`).
- `/api/admin/*`, `/api/owner/*`, `/api/demo/*`, `/api/planning/*`,
  `/api/wertstrom/*`, etc. are internal — not bound by the v1 contract.
- All v1 routes return the shared envelope from `@/lib/api`
  (`ok`, `list`, `err`, etc.).
- Adding or changing a v1 route requires updating `openapi/v1/openapi.json`
  in the same PR. CI fails otherwise (`npm run check:openapi`).

## The Enforcement Stack

Four layers, each catches what the others miss:

1. **`eslint-plugin-boundaries`** (`eslint.config.mjs`) — forbids the import
   directions above. **Promoted to error** in Phase 2 (R-01 + R-06 closed).
   Run a focused boundary check with `npm run check:boundaries` (uses the
   ESLint API and exits non-zero only on `boundaries/element-types` hits, so
   it isn't muddied by unrelated lint findings). Use `npm run lint` for the
   full lint report.

2. **Forbidden-strings check** (`scripts/check-forbidden-strings.mjs`) —
   greps for customer-specific identifiers (BMW, Q-numbers, ITSM.next, etc.)
   in the product core. Allowlist: `lib/customers/**`, `config/profiles/bmw*`,
   `governance/**`, plus line-level escape `// allow-customer-string`.
   Run: `npm run check:forbidden`. CI runs with `CHECK_FORBIDDEN_LEVEL=error`
   in `portability-matrix.yml` — any hit in product-core paths fails the build
   (R-01 enforcement, Phase 2).

3. **OpenAPI structural check** (`scripts/check-openapi.mjs`) — verifies the
   v1 spec parses, every documented path resolves to an `app/api/v1/.../route.ts`
   file, and every operation is tagged with at least one 2xx response. Run:
   `npm run check:openapi`. Bundled into `npm run check:portability`.

4. **Console discipline** (`no-console: error` in `eslint.config.mjs`) —
   all application code must log through `@/lib/logger`. Raw `console.*`
   is banned in `app/`, `components/`, `lib/`, `hooks/`, `config/`. The
   only allowed zones are the logger transport itself (`lib/logger.ts`
   inside `emit()`), tests, and the globally-ignored `scripts/` / `.cjs` /
   `.mjs` tooling. See [`logger-usage.md`](./logger-usage.md).

5. **CSP structural check** (`scripts/check-csp.mjs`) — verifies that
   `lib/security/csp.ts` still ships a per-request nonce + `'strict-dynamic'`
   on `script-src`, never re-introduces `'unsafe-inline'` there, and keeps
   the documented allowlist for `img-src` / `connect-src`. Guards against
   silent CSP weakening. Run: `npm run check:csp`. Bundled into
   `npm run check:portability`. See [`csp-strategy.md`](./csp-strategy.md).

6. **Portability matrix** (`.github/workflows/portability-matrix.yml`) — runs
   schema validation, lint, typecheck, and the forbidden-strings check under
   each profile (`default`, `bmw`). If a change breaks the *default* profile,
   it broke the framework.

## What This Means In Practice

- **Adding BMW-specific behavior?** Put it in `lib/customers/bmw/`, expose a
  generic interface from the core, wire the adapter in via the profile.
- **Need a feature flag?** Add it to `FeatureFlagsSchema` in
  `config/profiles/profile.ts`. Toggle per-profile in `default.ts` / `bmw.ts`.
- **Tempted to write `if (profile === 'bmw')` in core code?** Don't. Define a
  capability on the profile and branch on the capability instead.
- **Deleting a customer requires zero changes to core code.** That's the test.

## Shared vs module — the test that matters

`components/ui/` and `lib/i18n/` are *shared*. They MUST be domain-free.

- A widget that knows a database table name is **not** UI primitive — it's a
  domain widget. Put it under `components/<module>/`.
- A localization helper that knows a domain shape (`AssessmentQuestion`,
  `MasterDataValue`, …) is **not** generic i18n — it's a module helper. Put
  it next to the module's other files.

If you need a domain type *only* in shape (no behavior), prefer a small
structural prop interface declared in the shared file rather than importing
the domain type. If you need the domain semantics, the file is in the wrong
layer — relocate it.

## How to fix a boundary violation

1. Run `npm run check:boundaries` to see the focused report.
2. For each hit, ask: *which side is in the wrong layer?* Usually the file
   doing the import — domain widgets/helpers in `components/ui/` or
   `lib/i18n/` are nearly always misplaced.
3. Move the misplaced file into its module home. Update import paths.
4. Re-run `npm run check:boundaries`. If you cannot move the file in this
   PR, log a bypass in `governance/scorecard/bypass-log.md` with a sunset
   condition. A bypass without a sunset condition needs an ADR.

See [follow-ups.md](./follow-ups.md) for remaining Phase 2 work.
