Dependency Flow
| As of | |
|---|---|
| Version | 1.7.0 |
| Kind | spec |
| Length | 2,233 words, about 10 min |
| Tags | system-designmonorepocli |
Dependencies flow one way, from apps down to the foundation. A package never imports an app, and a lower layer never imports a higher one. A layer may skip the layers between: an app may import a feature, a platform or a foundation package directly, and a feature may import a foundation package directly.
An arrow means "may import". Packages in the same layer never import each other.
Which layer a package belongs to
Foundation says how, platform says what, features say which.
| Layer | Holds | Packages |
|---|---|---|
| Foundation | Domain-free machinery: DB client and RLS, auth, the API guard, logging, design-system primitives and tokens, i18n. Knows nothing about entities or features. | packages/foundation/*: core, ui, i18n (copy until lesson I1) |
| Platform | A shared domain concept, with its data and rules, that more than one feature uses. Owns its own tables and migrations. | packages/platform/* (first: synergy) |
| Feature | One product area. | packages/features/* (from Phase 3, the sandbox features) |
To place a new package, ask in order:
- Does it know about a specific feature? Then it is that feature, or the registry.
- Does it own shared domain data or rules? Then it is a platform package.
- Otherwise it is pure mechanism: a foundation package.
Every layer has its own folder, so the path tells you the layer: packages/foundation/*, packages/platform/*, packages/features/*. core, ui and copy move into packages/foundation/ in Phase 3a (Pratik, 2026-10-31); their package names (@sidekick/core and so on) don't change, so no import changes. The registry, tooling (packages/eslint-plugin-sidekick) and ops (packages/ops) are singletons and stay at the root of packages/.
Lesson 1 moves copy into packages/foundation/ with the others, and lesson I1 deletes it a few weeks later for packages/foundation/i18n (Internationalization). The short-lived move is expected: lesson 1 moves every foundation package so the boundaries config can drop its catch-all, and skipping copy would need a temporary pattern for it.
A design system (token names, primitives such as buttons and inputs, and Sidekick's default theme as an opt-in file) is domain-free, so it is foundation and lives in packages/foundation/ui (Design tokens). ui holds no words: every word is a prop (D249). A component that knows a platform concept, such as the Sneak Peek Link (resolved links, states, dots), lives in that platform package and builds on ui.
Who may import whom
- Apps (
apps/app, andapps/clilater) may import the registry, feature, platform and foundation packages. - The public site (
apps/www) may import only foundation packages, and nothing imports it (D258). packages/features-registryis the catalog of every feature. It may import feature, platform and foundation packages.- Feature packages may import platform and foundation packages. They never import each other or the registry.
- Platform packages may import only foundation packages. They never import each other, a feature or the registry; loosen that deliberately, with a new rule, when a real need appears. They take feature keys and entity types as data.
packages/opsmay import the registry, platform and foundation packages.- Foundation packages stay feature-agnostic (core) and never import the registry, a feature, a platform package or each other. If
coreimported the registry, it would depend on every feature, and every feature already depends oncore: a cycle with no valid build order.
eslint-plugin-boundaries enforces this in pnpm lint (eslint.config.js in the Sidekick repo). Its default is to disallow, and it lists only the allowed directions above. Each layer and singleton has an explicit element pattern (packages/foundation/*, packages/platform/*, packages/features/*, and one per singleton), with no packages/* catch-all, and a package that matches no pattern fails lint. Without that, a package created in the wrong place would silently count as foundation. pnpm-workspace.yaml globs each layer folder. All of this lands in Phase 3a with the first platform package. See Platform layer and eslint-plugin-boundaries.
The site and content
D258 decision #monorepo #site: apps/www is its own boundaries element type, site, that may import only foundation packages and that nothing imports, so the public site can never reach core's database client, the registry or a feature. derived-from D48
The public site is a static build on a public Worker. If it imported core, a build with a database URL set could bake private data into public HTML, and review would be the only guard. As its own element it can use ui's tokens and primitives and nothing that knows a person's data (Pratik, 2026-11-06). The site reads content/ by path at build time, which is not user data, so it is not a consumer of the API under D48; that changes when datums move into the database at Taxila beta, and then its datum index reads through the API like any other consumer.
D259 rule #monorepo #content: content/ holds content only: its package.json has only name, private, version and license, no code, exports or scripts, and a check script fails CI and the pre-commit hook on anything else. derived-from D215
@sidekick/content is a workspace package for two reasons only (D215): Turbo hashes a dependency's files into the site's cache key, so a content edit rebuilds the site, and Vercel's Skip deployment reads the dependency graph, so a content-only commit leaves the App unbuilt. It is not an ESLint element, because ESLint never reads Markdown. The guard is structural (Pratik, 2026-11-05):
- No
main,exportsortypes, so anyimport "@sidekick/content"fails to resolve andpnpm typecheckfails. scripts/check-content.mjs(root,pnpm check:content) walkscontent/, skips.obsidian/, and fails on a file outside the allowlist (.md, images such as.svgand.png,LICENSE,package.json) or apackage.jsonkey outside the four above. CI runs it as a step, which is the guarantee; husky's pre-commit runs it too, for fast feedback. Built in Lesson 0, PR 3.- If content ever needs code, the code belongs in
apps/www.
The questions this note used to leave open are settled: features live in packages/features/*, platform packages in packages/platform/*, the registry in packages/features-registry, and the boundaries rule enforces the layers instead of folder depth.
Platform Layer
D57 decision #monorepo #synergy: A platform layer, packages/platform/*, sits between features and the foundation, one package per shared service; Synergy is the first.
What
A platform layer sits between features and the foundation: packages/platform/*, one package per shared service. Synergy is the first, packages/platform/synergy (@sidekick/synergy). It owns the entity index, version log, relationships, relation vocabulary, tags, outbox and their rules, the resolver and renderer contracts, and the Sneak Peek Link component. packages/core keeps the DB client and RLS, auth, the API guard, entitlements and logging.
Foundation says how, platform says what, features say which. Each layer has its own folder (Pratik, 2026-10-31): packages/foundation/* (core, ui, copy, moved there in Phase 3a), packages/platform/* and packages/features/*. The registry, tooling and ops stay at the root of packages/ as singletons. The placement rule is in Dependency flow.
Why
- Each package owns its migrations (Migration). Phase 3 needs several migration streams anyway, because the mock note and task modules have tables of their own.
- Cost to Add Later. Moving TypeScript out of
corelater is a mechanical refactor. Moving tables out ofcore's Drizzle migration history is not: the new package's drizzle-kit would try to create them again unless its snapshot is built by hand. The cheap moment to choose an owner is before the first migration. - It settles where the contracts live (research 05). Features import the resolver and renderer contracts from Synergy, as Backstage modules import from a separate catalog "node" package. The registry or the app builds the map.
corenever imports either. corestays small instead of turning into "everything shared".- A folder per layer, foundation included. With foundation left at the root, ESLint needs a
packages/*catch-all, so a package created in the wrong place silently counts as foundation. Explicit layer folders let the config drop the catch-all and fail on a package in no layer, and the path shows the layer. Package names don't change, so the move touches only paths: threetsconfigextends, core's.env.localpaths, the workspace globs, the boundaries patterns and a few ESLint file globs. It only gets dearer as Phase 3 adds per-package migrations and new packages that point at these paths. - One package per service, not one
platformpackage. In a single package, boundaries rules don't check imports between services, and unrelated services would share one migration history and one test suite.
Options not taken
- Synergy inside
packages/core(the PRD's first assumption). It works, and provider and type stay plain strings. It loses on the migration point above. - The feature registry owns the rows. The registry sits above features, so a feature couldn't write its own index row without injection, and it would mix a catalog that changes only on deploy with per-user data.
- Foundation stays at the root, with an explicit pattern per package (
packages/core,packages/ui,packages/copy). It gives the same lint safety without a move, but every new foundation package needs a config edit, and the layout stays the one exception to "the folder is the layer". - Lazy indexing (a row on first link, tag or search). It saves about 150 bytes per entity, breaks "search every module" (spec story 70), and makes every save first check whether the entity has a row yet.
Consequences
- One package, three entry points (2026-10-31):
@sidekick/synergy/server(repository, save call, resolver contract; importsserver-only),/react(the Sneak Peek Link component and renderer contract, which run on the server and in the browser) and/contracts(zod schemas, types, error codes, content helpers). Drizzle code and a React component live in one package, so the split keeps database code out of browser bundles. Type registrations come in a server half and a React half for the same reason. - Phase 3a moves
core,uiandcopyintopackages/foundation/withgit mv. eslint.config.jsgets aplatformelement type (packages/platform/*) and afoundationelement type (packages/foundation/*), loses thepackages/*catch-all, and reports files in no element. Apps, the registry, features and ops may import platform packages, and platform packages may import only foundation packages. Platform packages may not import each other until a rule allows it.pnpm-workspace.yamlglobspackages/foundation/*,packages/platform/*andpackages/features/*.pnpm db:migrateruns each package's migrations in dependency order:core, then platform, then features and ops.- Overrides the PRD and spec: the Sneak Peek Link component lives in
@sidekick/synergy, not inpackages/ui. It knows resolved links, states and dots, andui(foundation) can't import Synergy's types. It builds onuiprimitives. - Overrides the PRD and spec: "module" is renamed provider: the package that registered an entity type. Resolver and renderer calls are batched per provider. Each type registration names the feature whose entitlement gates it (the mock notes and tasks: providers
sandbox-notesandsandbox-tasks, each gated by the feature of the same key); the mapping lives in code, not in rows. Feature flags (rollout gating) are a separate foundation package, built when something first needs one. - The mock providers are feature packages (Phase 3 planning, 2026-11-01):
packages/features/_sandbox/notesandpackages/features/_sandbox/tasks, notpackages/ops, because nothing may importops, so neither the app nor the registry could use them there. Two packages, so revoking one feature locks links into it while the other stays readable. The underscore marks them dev-only. See the Phase 3 plan. - Supersedes points 1 and 2 of the retired Design Constraints page (entity types in the registry, edges in
core).
React as Peer Dependency
D58 decision #react #dependencies: Packages that use React list it as a peer dependency, and each app that renders React (apps/app, and apps/www for its content components) installs it from the pnpm catalog.
What
Every workspace package that uses React (@sidekick/ui, the /react entry of @sidekick/synergy, feature packages later) lists react (and react-dom, if it imports it) as a peer dependency. The apps own the installed version (Pratik, 2026-10-31): apps/app, and apps/www, which renders content components with renderToStaticMarkup (D227). Both take it from the catalog, so they can't drift apart (revised 2026-11-06).
-
One version string for the repo, in a pnpm catalog in
pnpm-workspace.yaml:YAMLcatalog: react: 19.3.1 react-dom: 19.3.1apps/appandapps/wwwdepend onreact: "catalog:". Each package that uses React haspeerDependencies: { react: "catalog:" }, plus the same entry indevDependenciesso its own typecheck and component tests can run. Renovate bumps the catalog once, and every package moves together. -
The same goes for
@types/reactin the catalog, so types and runtime can't drift apart.
Why
- Two copies of React in one page break it: a context provided by one copy is invisible to components rendered by the other ("context store" lost), and hooks throw "Invalid hook call". A package that brings its own
reactdependency can end up with a second copy whenever its range and the app's resolve differently. - A peer dependency says "use the host's React", which is also what a third-party host (Notion, an embed page) needs from the Sneak Peek Link later.
- A catalog keeps the peer range, the dev copy and the app's version from drifting apart, without hand-editing several
package.jsonfiles on every upgrade.
Consequences
- A check in CI or a lint step that
pnpm why reactresolves to one version is cheap; add it when the first React package beyonduilands (Phase 3,@sidekick/synergy). - Server-only entry points (
@sidekick/synergy/server,/contracts) import no React, so bots and scripts that use them don't need it.