> ## Documentation Index
> Fetch the complete documentation index at: https://docs.encoreos.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Workspace packages (Phase 2 overlay)

> Status: Active Owner: Platform Architecture Phase: 2 of docs/recommendations/DEV_VELOCITY_AND_ARCHITECTURE_PLAN_2026-04-22.md (lands via PR #137) Companion ADR…

**Status:** Active
**Owner:** Platform Architecture
**Phase:** 2 of `docs/recommendations/DEV_VELOCITY_AND_ARCHITECTURE_PLAN_2026-04-22.md` (lands via PR #137)
**Companion ADR:** [`ADR-016-workspace-packages-overlay.md`](../architecture/decisions/ADR-016-workspace-packages-overlay.md)

This document explains the **overlay workspace model** introduced in Phase 2: what it is, why we chose it over a full physical migration, what guarantees it provides today, and what a full migration would look like later.

***

## TL;DR

* The repo is now an **npm workspace** with one package per architectural unit:
  * `encoreos-app` / `encoreos-public` — **Turborepo / Vercel Microfrontends keys only** (placeholder `packages/*` with no `encoreos` tier metadata; see [`MICROFRONTENDS_RUNBOOK.md`](MICROFRONTENDS_RUNBOOK.md))
  * `@encoreos/platform` — Platform Foundation
  * `@encoreos/shared` — leaf-level reusable UI / lib
  * `@encoreos/core-{ce,cl,fa,fm,fw,gr,hr,it,lo,pm,rh}` — one per domain core
* **Source files have not moved.** Each `packages/<name>/package.json` references its source under `src/<scope>/<slug>/` via the `encoreos.sourceDirectory` field. Existing `@/cores/hr/...` imports keep working unchanged.
* Each package's `package.json` declares **what it is allowed to depend on**. `npm run audit:package-boundaries` enforces the constitution at the package-metadata layer:
  * Cores depend only on `@encoreos/platform` and `@encoreos/shared`.
  * `@encoreos/platform` depends on `@encoreos/shared` only.
  * `@encoreos/shared` depends on neither platform nor any core.
  * **CL is downstream:** no other core may declare a dep on `@encoreos/core-cl` (constitution §1.4).
* This unlocks **Turborepo `--filter`** so a PR touching one core can `turbo run typecheck --filter=@encoreos/core-hr...[origin/prod]` and skip cache work for the other 11.
* It also unlocks the **dependency graph view** (`npm ls --workspaces --depth=0`, Turborepo's `--graph`) so reviewers can see "who depends on whom" at a glance.

***

## Why an overlay (and not a full physical migration)?

The recommendations doc (PR #137) § 5.4 originally proposed a full physical migration: `mv src/cores/hr/** packages/core-hr/src/**` and rewrite every `@/cores/hr/...` import. That delivers the strongest possible TS-project-references win (per-core warm typecheck in seconds), but at very high cost:

|                                       | Overlay (this PR)                                                                                               | Full physical migration          |
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -------------------------------- |
| Source files moved                    | **0**                                                                                                           | \~8,300                          |
| Imports rewritten                     | **0**                                                                                                           | \~10,000+                        |
| PRs required                          | **1**                                                                                                           | 12+ (one per core)               |
| Risk to live features                 | **near-zero**                                                                                                   | meaningful                       |
| Architecture lint coverage            | **package-metadata layer** (`audit:package-boundaries`) + **import layer** (`check-architecture.js`, unchanged) | same, plus TS project boundaries |
| Turborepo `--filter` works            | **yes**                                                                                                         | yes                              |
| `tsc --build` per-package incremental | no (single TS project, \~8.4 s warm)                                                                            | yes (\~1 s per untouched core)   |
| Reversible                            | trivial — delete `packages/<name>/`                                                                             | invasive                         |

Phase 2 ships the overlay first because it captures **most** of the velocity win at **none** of the migration risk. A future Phase 2.B can still do the file moves once the overlay has lived in `prod` long enough to expose any tooling edge-cases.

***

## Layout

```
packages/
  encoreos-app/
    package.json                 # name: "encoreos-app" — microfrontends / Turborepo key only (no src)
  encoreos-public/
    package.json                 # name: "encoreos-public" — microfrontends / Turborepo key only (no src)
  platform/
    package.json                 # name: "@encoreos/platform"
                                 # encoreos.sourceDirectory: "../../src/platform"
  shared/
    package.json                 # name: "@encoreos/shared"
                                 # encoreos.sourceDirectory: "../../src/shared"
  core-ce/
    package.json                 # name: "@encoreos/core-ce"; deps on platform+shared
  core-cl/
    package.json                 # name: "@encoreos/core-cl"; deps on platform+shared;
                                 # encoreos.downstreamOnly: true (constitution §1.4)
  core-fa/  ...                  # 11 cores total
  docs/                          # pre-existing Docusaurus site; NOT in workspaces glob

src/                             # source files unchanged; this is what packages reference
  cores/{ce,cl,...,rh}/
  platform/
  shared/
```

The root `package.json` declares:

```json theme={null}
{
  "workspaces": [
    "packages/encoreos-app",
    "packages/encoreos-public",
    "packages/platform",
    "packages/shared",
    "packages/core-*"
  ]
}
```

Note: `packages/docs` is intentionally **excluded** from the workspaces glob — it has its own `package-lock.json` and dependency tree (Docusaurus) that we don't want hoisted into our root `node_modules`. Keep it standalone unless we explicitly decide to Turbo-cache docs in CI.

***

## What this package model enforces

### At the package-metadata layer (this PR's contribution)

Every `packages/<name>/package.json` carries a structured `encoreos` block:

```jsonc theme={null}
{
  "name": "@encoreos/core-hr",
  "encoreos": {
    "tier": "core",            // "platform" | "shared" | "core"
    "coreSlug": "hr",
    "sourceDirectory": "../../src/cores/hr",
    "constitutionRefs": ["§1.2", "§1.3", "§1.4"]
  },
  "dependencies": {
    "@encoreos/platform": "*", // npm workspaces use literal '*'
    "@encoreos/shared": "*"
  }
}
```

`npm run audit:package-boundaries` walks every package and verifies:

| Rule                                            | Check                                                                                         |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------- |
| Cores never depend on another core              | No `@encoreos/core-*` in another core's `dependencies`                                        |
| Platform never depends on a core                | No `@encoreos/core-*` in `@encoreos/platform`'s `dependencies`                                |
| Shared depends on neither platform nor any core | No `@encoreos/platform` or `@encoreos/core-*` in `@encoreos/shared`'s `dependencies`          |
| CL is downstream                                | No package declares a dep on `@encoreos/core-cl` (it carries `encoreos.downstreamOnly: true`) |

### At the import-statement layer (unchanged)

`scripts/utils/check-architecture.js` continues to scan every `.ts`/`.tsx` file for cross-core imports, lazy-loading, loading states, and auth patterns. It still runs as the blocking `Architecture boundaries` step in `.github/workflows/build.yml`. The two layers are complementary:

* **Package metadata** catches *intent* before code is written ("you can't even declare a dep on another core").
* **Import scan** catches *accidents* in code already written ("someone wrote `import {} from '@/cores/cl/...'` inside `src/cores/hr/`").

***

## What it unlocks immediately

* **Turborepo `--filter`** — Phase-1.A's `turbo.json` already declares `typecheck`, `lint:ci`, `test:unit`, `build` tasks. Now that workspaces exist, those tasks can be filtered:
  ```bash theme={null}
  # Only run typecheck for HR core and its dependencies
  npx turbo run typecheck --filter=@encoreos/core-hr

  # Affected-since-prod (PR-shaped invocation)
  npx turbo run typecheck lint:ci --filter=...[origin/prod]
  ```
* **`npm ls --workspaces --depth=0`** — instant view of declared dependency edges.
* **Per-package `package.json` evolution** — when a core needs a new dependency, it declares it locally (closer to the consumer). The root `package.json` doesn't grow unbounded.
* **Codegen / scaffolding hooks** can target packages individually instead of grepping `src/cores/`.

***

## What it does NOT do (yet)

* **Per-package `tsc --build`.** TS project references with `composite: true` would let `tsc --build` skip untouched packages, but `composite` requires emitting declaration files; our app uses `noEmit: true`. A future ADR can switch to a hybrid model (composite for downstream consumers; noEmit for the entry app), but that's out of scope here.
* **Per-package Vite build.** Vite still bundles `src/main.tsx` as one app. That's the right behavior until Phase 3 (Microfrontends) splits zones.
* **Physical file moves.** `src/cores/hr/...` is still where HR's source lives.
* **eslint-plugin-boundaries.** Phase 1.C remains deferred. The structural audit + the existing import-layer audit cover the same ground without adding to the current `lint:ci` warning budget (`--max-warnings 1100` in `package.json`).

***

## Migrating to a full physical layout (Phase 2.B, deferred)

When the team is ready, the migration is mechanical and per-core:

1. `git mv src/cores/hr/** packages/core-hr/src/**`
2. Update `packages/core-hr/package.json` `encoreos.sourceDirectory` → `./src`
3. Add `packages/core-hr/tsconfig.json` extending the root, with `composite: true`, `outDir: dist`, `include: ["src/**/*"]`.
4. Update root `tsconfig.app.json` `paths`: change `"@/cores/hr/*": ["./src/cores/hr/*"]` to `"@/cores/hr/*": ["./packages/core-hr/src/*"]`.
5. Add `packages/core-hr` to root `tsconfig.references.json` references list.
6. Run `npm run typecheck && npm run lint:ci && npm run test:unit && npm run build`. Land that core's PR.
7. Repeat for the next core.

Order of migration (smallest first to validate the recipe):
`lo → fm → it → ce → rh → cl → gr → fw → fa → pm → hr`

Each step is its own PR. The overlay model in this PR ensures the steps are independent and reversible.

***

## Operational notes

### npm install is now idempotent across packages

When you `npm install`, npm hoists shared dependencies to the root `node_modules` and creates symlinks under `node_modules/@encoreos/` for each workspace package. The peer-dep flag (`--legacy-peer-deps`) is still required as documented in `AGENTS.md`.

### Adding a new core (future)

1. `mkdir packages/core-{slug}`
2. Update the `CORES` list in `scripts/dev/generate-core-package-jsons.mjs`.
3. `npm run packages:regenerate-cores` writes the new `packages/core-{slug}/package.json`.
4. `npm install` to wire workspaces.
5. `npm run audit:package-boundaries` to verify.

### Removing a core (future)

1. Delete `packages/core-{slug}/`
2. Remove from the `CORES` list in the generator.
3. `npm install`.

### Why `encoreos.sourceDirectory` and not `main` / `exports`?

We don't want the packages to be *consumable* via `import {} from '@encoreos/core-hr'`. That would bypass the existing `@/cores/hr/...` alias system and create a parallel import path everyone has to keep in sync. The `encoreos.sourceDirectory` field is metadata only — read by `audit-package-boundaries.ts` and by humans, but invisible to bundlers. Vite continues to resolve `@/cores/hr/foo` via the existing path alias in `tsconfig.app.json` and `vite.config.ts`.

***

## See also

* [`packages/*/package.json`](../../packages/) — the actual package definitions
* [`scripts/audit/audit-package-boundaries.ts`](../../scripts/audit/audit-package-boundaries.ts)
* [`scripts/dev/generate-core-package-jsons.mjs`](../../scripts/dev/generate-core-package-jsons.mjs)
* [`scripts/utils/check-architecture.js`](../../scripts/utils/check-architecture.js) — the existing import-scan auditor (unchanged)
* `docs/recommendations/DEV_VELOCITY_AND_ARCHITECTURE_PLAN_2026-04-22.md` § 5.4 — the original Phase-2 plan (lands via PR #137)
* [`docs/architecture/decisions/ADR-016-workspace-packages-overlay.md`](../architecture/decisions/ADR-016-workspace-packages-overlay.md) — formal ADR for this design choice
* [Constitution](../../constitution.md) §1 (architecture and module boundaries)
* `docs/development/TURBOREPO_USAGE.md` — Phase 1.A companion (lands via PR #139)
