Skip to content

ADR-0015: Open core and hosted edition split ​

Status: accepted (2026-10-14). Amends the license section of ADR-0001; system access still follows ADR-0006 and the outbox ADR-0005.

Context ​

Quavon runs the EU hosted edition next to the self-hosted product. Its features (sign-up, tenant lifecycle, support access, status page, plans and limits, metering, Mollie billing, VIES checks, invoices, the cost model, the hosted docs and the hosted deployment overlay) grew inside the Apache-2.0 packages. Quavon wants these features to stay proprietary while the self-hosted product stays open source, and keeps the hosted part in a separate private repository without changes to the core.

The stricter enterprise security defaults (QUAVON_EDITION=enterprise checks in authentication, OIDC, the endpoint guard, LLM key verification, doctor and telemetry) are not hosted features. They stay in the open core.

Decision ​

License ​

  • This repository is the open core under the Apache License 2.0 (LICENSE).
  • The hosted edition (hosted/) lives in a separate private repository. It is proprietary, all rights reserved, under its own license file. That text is a placeholder until counsel provides the final terms. NOTICE states the split.
  • Hosted packages are "private": true, declare "license": "UNLICENSED" and are named @quavon/hosted-*. Open-core packages declare Apache-2.0. licenses/policy.yaml (workspace) records these rules and pnpm verify:licenses enforces them.
  • The hosted edition is developed by Quavon only.

Boundary ​

  1. Hosted code may import from core packages. Nothing outside hosted/ imports from hosted/ or depends on a @quavon/hosted-* package. pnpm verify:licenses rejects such a dependency in any open-core manifest; an ESLint rule and a boundary test reject the imports.
  2. The core has no hosted names: no saas, billing, mollie or plan concepts, and no hosted tables, job names, system operations, maintenance tasks, routes, screens, i18n keys or configuration variables. The core offers neutral extension points instead.
  3. Hosted types live in @quavon/hosted-types (hosted/types); open-core types stay in @quavon/shared-types.
  4. Hosted builds compose the core through their own entry points. The core never refers to them.

Extension points in the core ​

  • API (apps/api/src/extension.ts, exported as @quavon/api/extension): buildApp(deps, extensions) takes a list of ApiExtension. An extension can wrap the authorizer (authorizer), add hooks that run before the core routes (hooks, for example a preHandler), register routes after the core routes (routes), add fields to /api/me (me; the core fields are applied last, so an extension cannot override them) and declare plannedRoutes, so the permission tests and the IDOR fuzz (describeIdorFuzz from @quavon/api/testing) cover its routes too. runApi({ systemOperations, extensions }) from @quavon/api/server is the shared server entry; apps/api/src/main.ts calls it without extensions.
  • Worker (apps/worker/src/extension.ts, exported as @quavon/worker/extension): startRuntime({ ..., extensions }) takes a list of WorkerExtension. An extension can add jobs and schedules, wrap the core handlers (wrapHandlers), provide ingest hooks (provisionTenant, tenantActive; only one extension may provision tenants), a run gate (gate), a usage hook (onUsage, called in the transaction of the measured review or triage run) and an LLM hook (onLlmRecorded, called when a model call is recorded). runWorker({ systemOperations, extensions }) from @quavon/worker/server is the shared server entry; apps/worker/src/main.ts calls it without extensions.
  • Extension queue: extension jobs share one neutral queue and job name, extension. The payload is { handler, payload } (ExtensionJob in @quavon/shared-types; handler matches ^[a-z][a-z0-9_]{0,63}$). The extension validates its own payload. A job for an unknown handler fails with a PermanentError and goes to the dead-letter queue; two extensions with the same handler name are refused at start-up. Schedules ({ id, every, handler, payload }) are job schedulers on the same queue. The core has no extension job names of its own.
  • Run gate: review and triage ask a FeatureGate (check(tenantId, 'review' | 'triage' | 'code_scanning', { repositoryId })). Without a gate every run is allowed; with several gates the first denial wins. The hosted edition implements it with plans.
  • Database:
    • Extension system operations are named, allowlisted when the system connection is created (createSystemDatabase(options, onError, extensionOperations)) and tested; see ADR-0006.
    • Migrations: registerDatabaseExtension({ name, migrations, encryptedColumns, personalData }) adds a migration source (its own folder and journal) with its own tracking table quavon_meta.<table>, plus encrypted columns and personal data entries. migrationSources() returns the core source first, so extension migrations run after the core ones. db-init, readiness, doctor, key rotation (keys) and DSAR read the registry, and pnpm verify:migrations runs the check of every package that has migrations, core and hosted.
    • The core exports tenantTables; an extension merges its own tables for its tenant deletion, its RLS tests and the IDOR fuzz.
  • Configuration: the core keeps QUAVON_EDITION and exports createVariableReader, so an extension parses its own variables with the same _FILE and error handling. A self-hosted instance never reads extension variables, and the core configuration reference lists none of them.
  • Chart: deploy/helm/codelotse has generic values only: commands, extraEnv, extraSecretFiles, extraConfigFiles and the image overrides (Kubernetes).
  • Web: apps/web exports createApp(extension?). An AppExtension adds top-level routes (routes), routes inside the signed-in shell (shellRoutes), routes under /t/:tenantId (tenantRoutes), a useNavItems hook for admin navigation entries and en/de messages that are merged into the core dictionaries. useApiClient() gives an extension the core API client for its own endpoints. apps/web/src/main.tsx renders createApp() without extensions.

Hosted edition &ZeroWidthSpace;

  • hosted/db (@quavon/hosted-db) holds the hosted tables, its migrations in hosted/db/migrations with their own journal, the tracking table quavon_meta.hosted_migrations, and HOSTED_SYSTEM_OPERATIONS (hosted/db/src/system.ts). registerHostedDatabase() registers them.
  • The hosted entry points register the hosted database first and then run the core: hosted/api/src/main.ts (API), hosted/worker/src/main.ts (worker), hosted/api/src/bin/cli.ts (CLI) and hosted/api/src/bin/{keys,dsar}.ts.
  • Hosted doctor and db-init must run through hosted/api/src/bin/cli.ts (node hosted/api/src/bin/cli.ts db-init). The core node apps/cli/src/bin.ts sees only the core migrations and encrypted columns, so it would neither apply nor report the hosted migrations.
  • The hosted worker extension runs its jobs as handlers on the extension queue: billing (schedules billing_cycle and billing_sandbox_progress), saas_lifecycle and status_monitor.
  • The plan catalogue (QUAVON_PLANS_FILE) and the Mollie key (QUAVON_MOLLIE_API_KEY) are parsed in hosted/config, which refuses any edition other than enterprise.

Builds &ZeroWidthSpace;

  • The self-hosted web bundle is built from apps/web (apps/web/dist) and contains no hosted code or text. hosted/web (@quavon/hosted-web) has its own Vite entry, which calls createApp with the hosted extension, and builds to hosted/web/dist, the input for the hosted web image. pnpm --filter @quavon/hosted-web verify:core-bundle proves the self-hosted bundle has no hosted module, text or name.
  • The self-hosted app and worker images (deploy/images/app/Dockerfile) exclude hosted/. The hosted images are built from hosted/deploy/images/app/Dockerfile (quavon-hosted-app, and quavon-hosted-worker with target worker) and hosted/deploy/images/web/Dockerfile (quavon-hosted-web). The hosted Helm overlay lives in hosted/deploy and uses the generic chart values only; hosted/deploy/validate.sh checks its render.
  • tooling/ci/core-server-without-hosted.sh and tooling/ci/core-web-without-hosted.sh prove that the core installs, typechecks, lints and passes its tests (and the web app builds) in a copy of the repository without hosted/. tooling/ci/core-image-without-hosted.sh proves that the self-hosted images contain no hosted file, package or name. An ESLint rule and tooling/scripts/src/boundary.test.ts reject hosted imports, dependencies and names in the core.

Documentation &ZeroWidthSpace;

Hosted documentation lives in hosted/docs (saas, billing) in the private repository. Core documents (threat model, data inventory, compliance) describe the hosted edition where they need to, refer to the hosted documentation by name and do not link into it.

Consequences &ZeroWidthSpace;

  • The open core in this repository is the public source of truth. The hosted edition builds on it and does not change it.
  • A self-hosted build cannot pick up hosted behaviour by accident, and the hosted edition lives in a private repository without core changes.
  • New hosted features need a core extension point first, and that point must be generic enough to describe without hosted names.
  • Hosted code depends on the core's internal module layout (for example @quavon/web/ui/*). Core refactors must keep the extension points stable or update the hosted edition at the same time.
  • The hosted license text is not final. Until counsel provides it, nothing in hosted/ may be distributed outside Quavon.

The open core is released under the Apache License 2.0.