Skip to content
Architecture

Constitution vs Runtime: One Monorepo, 16 Vertical SaaS, No Forks

The inite-ecosystem repo ships zero executable code by design. Constitution above, runtime below, 16 verticals as citizens - no fork has ever happened.


Mikhail Savchenko·June 15, 2026·8 min read
multi-tenantmonorepoplatform engineeringvertical SaaSspec-first

Two repos, one platform

A monorepo that hosts more than one product runs into the same fork eventually. One vertical needs an extra column on Company, another needs to bypass the shared auth flow for SSO, a third wants its own billing webhook handler. Each one starts by patching the shared code. Each one ends up vendoring it. The platform is now seven copies of the same thing, drifting.

The split that prevents this is older than monorepos. Separate the contract from the implementation. The contract is the inite-ecosystem repo — JSON Schemas, capability YAMLs, a manifesto, a validator. The implementation is inite-shared — 19 TypeScript packages, real Postgres, real HTTP. The verticals (rent, estate, shop, events, health, education, club, and the rest of the 16 in the registry) consume both.

The constitution repo's manifesto is short and load-bearing:

Forbidden in the spec repoAllowed in the spec repo
Domain logicJSON Schemas
Imports of @inite/*Capability YAMLs
prisma/schema.prismaExample manifests
.tsx UI filesValidators and generators
External-API adaptersCompatibility fixtures

A pull request that adds any of the left column is rejected on sight. The repo serves the spec; the spec serves the verticals; the verticals run the business. That order is the whole architecture - and it maps directly onto the Inite operating model for vertical AI products covered in the companion post.

What the contract actually contracts

A vertical declares conformance in a single YAML manifest. The shape is small:

ecosystem: "^0.1.0"
id: inite.rent
name: INITE Rent
version: 1.0.0
domain: car_rental
maturity: production

capabilities:
  - { id: inbox,   version: "^1.0.0" }
  - { id: billing, version: "^1.0.0" }

runtime_packages:
  - "@inite/auth"
  - "@inite/billing"

entities:
  - { id: vehicle }
  - { id: reservation }

That manifest commits the vertical to three things. Ring 1 — five mandatory entities (User, Company, UserCompanyRole, ApiKey, CompanyPermissionOverride) must exist in its Prisma schema or be declared as aliases. Ring 2 — every capability listed in capabilities: binds the vertical to the full set of entities that capability requires. List inbox, you ship the Conversation and Message shape the inbox bundle expects. Skip the binding, you cannot list the capability. Ring 3 — anything else (vehicle, reservation, listing, appointment) is free-form. The spec does not constrain shape, naming, or semantics.

The conformance tool walks the manifest plus the Prisma schema and produces a present/partial/missing report per entity. Drift shows up as a status in the registry rather than as a silent fork.

What this buys

Three operational properties only emerge once the split is in place.

First — runtime upgrades are decoupled from contract changes. A bug fix in @inite/auth is a runtime patch. It ships to every vertical that consumes the package, on the verticals' own pull cadence. The spec does not move. No 16-product migration. No CHANGELOG entry on the constitution.

Second — breaking changes have a single forced surface. A field removed from Company is a MAJOR spec bump. The migration ships in the spec repo as a fixtures/migrations/<from>-to-<to>/ folder with before-and-after manifests for every affected vertical, a written migration walkthrough, and a deprecation window of at least one MINOR before the old schema stops validating. The verticals stay on the prior MINOR for as long as they need — because the zero-major caret semver rule treats ^0.1.0 as ~0.1.0, the manifest does not auto-float into the breaking change. They have to opt in.

Third — the registry is the source of truth about platform health. At version 0.2.0-rc.4, the registry tracks 18 verticals across seven status buckets:

StatusCountMeaning
production1declares ecosystem version, meets contract, in prod
pilot2actively calibrating the spec
migration_pending4repo exists, intends to conform, has a data-model gap
drift4consumes inite-shared but no manifest yet
non_conforming2repo exists, no shared, no manifest
candidate3not yet a vertical, being scoped
legacy2superseded by another vertical

That table updates on every spec release. A vertical that drifts past the agreed window gets flagged in the same review. A capability that has not landed in production for two spec releases gets reviewed for removal. The registry is the dashboard.

Why the runtime owns nothing in the spec

The runtime packages declare a contract_version in registry/capabilities.yaml. Eight of the nineteen are at 1.0.0 — stable contract, breaking changes require a runtime MAJOR bump. The remaining eleven sit at 0.1.0 — bundles still being calibrated, like @inite/accounting and @inite/inbox-ui, where the contract is not yet load-bearing. The spec does not pin runtime versions. It only references package contracts. A capability bundle may say @inite/inbox >= 0.5.0 but never == 1.0.3.

The reverse direction is asymmetric on purpose. The runtime cannot ship a contract change — only the spec can. The runtime can ship a contract implementation against the current spec, and that implementation can move on its own version axis. When the spec wants to drop a field from Company, the spec MAJOR ships, the runtime updates to match, the verticals migrate at their own pace within the deprecation window.

The reason this works at all is that the spec repo has nothing executable in it to break. There is no service to upgrade, no database to migrate, no behavior to test. The repo is JSON Schemas, capability YAMLs, examples, a validator. A spec patch release is a documentation edit. A spec minor is a new optional field on an entity or a new capability bundle. A spec major is the one place breaking changes are written down — explicitly, with examples, with a deprecation window.

What the verticals look like in practice

inite.rent runs on Ring 1 plus the inbox, billing, incidents, and notifications capabilities, with a Ring 3 of roughly twenty domain entities (Vehicle, Reservation, RentalContract, InsurancePolicy, MaintenanceRecord, and so on). It declares maturity: production and is the one vertical at that status today.

inite.estate runs on the same Ring 1, the same inbox capability, and a different Ring 3 — Property, Listing, Deal, Showing, Offer. The inbox capability behaves identically across both verticals because they implement the same contract; the storefront and the search experience diverge because Ring 3 is free.

inite.shop is the vertical pulling hardest on the storefront and billing capabilities — agentic commerce integration sits in this product. The Ring 3 entities are Product, Variant, Order, Cart, Customer (aliased from User via the manifest's aliases: map). The fact that Customer is locally named differently is recorded in the manifest, which is enough for inite-conform to verify Ring 1 presence even with the alias.

Three different verticals. One spec. The same five Ring 1 entities under different local names. The same inbox capability shipping the same conversation primitives. Three distinct Ring 3 domains that the spec does not see and does not care about. The cost numbers behind one of these migrations - rent moving onto the spec - are written up in the Inite Rent case study on vertical cloning.

When this is the wrong shape

The constitution-vs-runtime split is the wrong shape for two cases. The first — a single product that will never be more than itself. Adding a spec repo, a registry, and a conformance tool to a one-product company is overhead with no payoff. The shared monorepo is fine. The second — a portfolio of products that have nothing in common at the data layer. If product A is a single-player iOS game and product B is a B2B procurement platform, sharing a Company entity is not the leverage it looks like. The split pays off when there is a real, recurring contract — multi-tenant SaaS sharing identity, billing, conversations, notifications — across products that look different on the surface and the same underneath.

For Inite, 16 products share a Ring 1, the eight stable Ring 2 capabilities, and the same set of horizontal services. The split pays for itself many times over. Without it, each vertical would have its own fork of @inite/auth within a quarter. With it, the registry tracks honest drift, the spec moves on its own cadence, and the runtime ships fixes to every product at once.

Two repos. Sixteen products. One contract that is allowed to change deliberately, and a runtime that is allowed to change continuously. That is the whole pattern - the same thesis condensed elsewhere as one engine, many skins.

Frequently Asked Questions
  • 01Why a separate repo for the spec? Why not just keep it inside the runtime repo as a folder?+

    Two reasons, both load-bearing. First — the moment the spec lives in the same repo as executable code, the spec stops being a contract and becomes documentation. A folder of YAML next to TypeScript that everyone imports will drift: someone needs a new field, edits the runtime, updates the YAML as an afterthought, and now the contract describes what was just shipped rather than what should ship. Splitting the spec into its own repo with zero runtime dependencies makes that drift physically impossible — you cannot edit the spec while editing the runtime in the same commit. Second — verticals need to declare which spec version they conform to, independent of which runtime version they happen to use today. With a separate spec repo, the vertical's manifest can say ecosystem: ^0.1.0 and that line is checked by the validator against the spec repo's published schemas, not against whatever the runtime author happened to ship that week.

  • 02What stops a vertical from importing internals from @inite/auth and effectively forking the platform anyway?+

    Nothing technical — there is no sandbox. The discipline is enforced by three things working together. First, the runtime packages export a narrow public surface (resolveSession, issueJwt, permissions, apiKey for auth as an example) and not the internal modules. Reaching past the public surface is visible in a code review. Second, the conformance tool (inite-conform) reads the vertical's manifest and walks the Prisma schema to confirm every declared Ring 1 entity is present and every declared Ring 2 capability has the entities the spec requires — a vertical that monkey-patches internals shows up in the report as drift. Third, the registry tracks status publicly. A vertical that has drifted off the public surface gets the drift status in registry/verticals.yaml, which is itself reviewed during every spec release. None of this is technically airtight, but the social cost of being the drift entry on the public registry is non-trivial.

  • 03What happens when one vertical needs something the runtime does not have yet?+

    The decision tree has three branches and they correspond to where the new thing lives. If the new thing is genuinely vertical-specific — say, a car-rental product needs to model insurance coverage in a way no other vertical will reuse — it goes in Ring 3 of that vertical's own Prisma schema, with no spec change needed. If the new thing is reusable across at least two verticals — say, a discount-code engine that both rent and shop will use — it becomes a candidate Ring 2 capability bundle. That means a proposal lands in inite-ecosystem first (spec, schema, examples), then a new @inite/<thing> package lands in inite-shared (runtime), then the verticals opt in by adding it to their manifest. The order matters: spec before runtime, runtime before adoption. If the new thing is structural — a new mandatory entity in Ring 1 — it triggers a MAJOR spec bump with a written migration for every vertical and an explicit deprecation window of at least one spec release cycle.

  • 04How do you do a breaking change in the runtime without breaking 16 products at once?+

    Three discipline points cover most cases. The runtime packages declare a contract_version in the registry (1.0.0 for the eight stable ones, 0.1.0 for the bundles still being calibrated). Breaking changes inside a 1.x.y runtime package are a MAJOR bump on that package, which means every vertical's package.json pins the prior major and is upgraded deliberately, not transitively. Cross-package breaking changes — the kind that touch the spec — go through the spec MAJOR process: a CHANGELOG entry, a fixtures/migrations/<from>-to-<to>/ folder with before-and-after manifests for every affected vertical, and a deprecation window of one MINOR before the old schema stops validating. The verticals stay on the previous spec MINOR for as long as they need, because their manifest pins ecosystem: ^0.1.0 and ^0.1.0 does not float to 0.2.0 — under the zero-major caret rule, ^0.1.0 is treated as ~0.1.0. They have to deliberately bump to opt in.

  • 05Is this just a fancy way of saying nx monorepo plus some YAML?+

    No. Nx (and pnpm workspaces, and Turborepo, and Bazel) solve build orchestration — how to run tests, how to share code, how to cache. They do not say anything about what your tenanted SaaS product must look like. The constitution-vs-runtime split is doing something they cannot: it answers the question 'what does it mean for a product to be a citizen of this platform?' in a way that is checked by tooling. The Ring 1 contract is enforced by inite-conform reading your Prisma schema. The Ring 2 contracts are enforced by checking that you implement the entities each declared capability requires. The runtime imports are enforced by the registry — your manifest's runtime_packages must be a subset of the registry, which means you cannot quietly start consuming a package that the spec does not bless. You can layer this on top of any monorepo tool, including nx. It does not replace them; it is the layer above them.