Skip to content
sign in

==> read the decisions

ADR-0008: The shared library is its own repo, and user repos are flakes built with it

Status: Accepted. Amended by ADR-0014 (2026-10-14): a user flake is one mkRepo call and the repo layout is the contract.

A user repo has to give the platform two things and be given three.

It PROVIDES: the frontend, the backend, and the projects (with their migrations and translations).

It IS GIVEN: the toolchain the artifact is built with, the shared library (@cs/*), and the artifact contract (an ESM custom element named cs-project-<slug>, in a documented CDN path).

The first attempt left all of that as convention rather than contract: mkUserUi ran npm run build and then ASSUMED dist/<slug>.js, and provision.ts inferred everything else from directory layout. Nothing declared the interface and nothing checked it, so a repo that broke an assumption produced no error -- just a CDN path that served nothing.

The obvious fix -- make each user repo a flake, so it declares its own build -- was rejected on the grounds that it would reimplement the build N times and drag N nixpkgs pins along. Both of those are real, and both are answerable: they are consequences of a flake implementing the BUILD, not of a flake being the interface.

  • The shared library is its own repo, hosted on the hub beside the platform. It carries the shared modules, the build recipe, the toolchain versions, and the nixpkgs pin.
  • Every user repo is a flake whose only relevant input is the shared repo. It declares its projects and asks the shared library to build them.
  • The platform depends on the shared library too. There is one copy of the preference ladder and the i18n resolver, not one per consumer.
  • A user flake that pins its own nixpkgs does not get it used. The build recipe comes from the shared library and closes over the shared nixpkgs, so the toolchain is not a rule the user is asked to follow -- it is the only one in the build path. That is how "everyone builds with the same toolchain" is enforced: structurally, not by convention.
  • The flake emits the runtime manifest. provision.ts is a process, not a build, so it cannot evaluate a flake; the shared library renders the declared projects to a manifest as a build product, and the provisioner reads that. One declaration, two consumers, and no second list in a second format to drift.
  • The artifact contract has one implementation, in the shared repo, so the custom-element name, the module format, the shared-lib resolution and the CDN path cannot differ per repo.
  • A user repo is an ordinary TypeScript project that adds a thin flake.nix. It does not need to know the platform's internals, only the shared interface -- the dependency points at something small and stable instead of at the app, the API and the CDN.
  • Shipping a project stays what ADR-0004 said: build, publish, update the row. No platform deploy.
  • The fleet is still the only place the three meet: it takes shared, the platform, and each user repo as inputs and composes them.
  • Adding a repository: one more input to pin in the fleet, and one more thing for an agent to check against the contract.
  • A JSON manifest in each repo, flake = false. The interface would be data, validated by the platform, which is simpler for a user who does not want Nix. Rejected because the interface then has to be re-implemented in whatever language reads it, and because Nix is already the thing the fleet is written in -- the contract is worth expressing in the one tool that can enforce it.
  • A flake per user repo that implements its own build. Puts N implementations of one contract in the world, and N nixpkgs pins with them. This is what made the first user repo ad-hoc: a bespoke build script fetching a bundler over the network and borrowing React by absolute path.
  • shared/ staying inside the platform repo. Then every user repo depends on the platform -- the app, the API, the CDN, the deploy machinery -- to reach six small modules. The dependency points the wrong way, and a user repo should not be able to see the fleet's internals at all.
  • The platform injecting the shared library at build time (the current CS_SHARED). It works, and it is what this replaces: an injection is invisible to the repo being built, so a user cannot see, test against, or depend on the thing their UI is compiled against.

Revision (2026-10-01): user repos are no longer fleet inputs

Section titled “Revision (2026-10-01): user repos are no longer fleet inputs”

Two consequences above no longer hold. Since nixos2 ADR-0013 a user repo deploys ITSELF: a push builds that user's release (ADR-0009) on the hub against the shared library's main and installs it as their profile generation. The fleet does not take user repos as inputs and composes nothing of theirs, so adding a user is an inventory line and a repo, not an input to pin. The runtime manifest is now the release's own manifest.json, read by api/src/reconcile.ts; provision.ts is a leftover and does not run anywhere. The contract itself -- the shared library as its own repo, user repos as flakes over it, one toolchain -- stands.