ADR-063: Tenant scoping + portability
Status: Accepted Date: 2026-04-22 Related: ADR-058 (5-mode UI), ADR-059 (project-first IA), ADR-060 (catalog-driven infrastructure), ADR-061 (extract pipelines + projects libraries)
Context
The gyrum platform is operated as a single-tenant system today: one operator, one set of projects, one set of infrastructure, one set of secrets, one fleet dashboard. Every dashboard query is implicitly scoped to "the operator's stuff".
Two pressures argue for changing that — even before the platform ever goes multi-tenant in the public sense:
1. Dashboards are only meaningful when scoped
The fleet KPIs that ADR-058/059's homepage exposes — "Products live", "Fleet uptime", "Open alerts", "Monthly spend" — are only useful when they aggregate things that belong together. If Project A's stats are mixed with Project B's stats and Project B was never meant to be in scope, the number is noise. The operator's framing on 2026-04-22:
the dashboard of stats is only useful if you don't pollute projects together that shouldn't be
Today this is solved by happenstance: there is exactly one operator, so everything they see IS their stuff. Add a second operator (or a contractor with limited visibility, or a partner project) and the dashboard becomes incoherent.
2. Portability is the same problem viewed sideways
Imagine spinning up a fresh org and migrating the platform to it: the projects YAMLs, the infrastructure catalogs (ADR-060), the pipeline definitions, the secrets references. The export-from-source / import-into- target loop is exactly the same primitive as "give Operator B a scoped slice of Operator A's view": both require every artifact in the system to have a stable scope identifier. Without one, neither operation is safe.
The gyrum platform is also where multi-product founders will land: someone building three SaaS products under three brands today, who tomorrow wants to spin one off into a separate company with its own org, secrets, infrastructure. That migration is "extract tenant T from system S, install into system S'". Designing for it from the start makes the eventual migration mechanical instead of a months-long forensic exercise.
Decision
Adopt a tenant-scoped data model with explicit portability primitives.
1. Every artifact carries a tenant scope
Every artifact created or stored by the platform has a tenant_id (or
tenant for short). This includes:
- Projects (
projects/<slug>.yamlgains a top-leveltenant: <id>) - Catalogs (per ADR-060, the envelope already has
target.id; addtenant: <id>alongside) - Pipeline definitions (
pipelines/*.jsongains"tenant": "<id>") - Pipeline runs (every run record carries the tenant of the pipeline it belongs to)
- Findings (every finding tag inherits its target's tenant)
- Block registry entries — initially tenant: shared for all blocks; promotion to tenant-scoped if/when a block is private to one tenant
- Deploys, alerts, audit invocations, retro notes — all tenant-scoped
For backward compatibility today: missing tenant field means
tenant: default. Single-tenant operators see no behaviour change. The
field becomes required once the migration window closes.
2. Tenants are first-class but not products
A tenant is a scope identifier, not a product or a customer. The data model is:
# tenants/default.yaml — the canonical tenant definition file
slug: default
name: Default tenant
owner: jonathanleahy
created_at: 2026-04-22
status: active # active | archived | sunset
visibility: private # private | shared | public
Tenants live in their own directory (tenants/) in dark-factory, the same
way projects live in projects/. Adding a tenant is a YAML PR. The set of
valid tenant slugs is the union of files in tenants/.
Tenants are flat for v1: no parent/child, no nesting. If tenant hierarchy turns out to be needed (e.g. "this tenant inherits these projects from a parent tenant"), it is a future ADR with explicit inheritance semantics — never silent.
3. Tenant boundary in the dashboard
Every fleet KPI, every list, every dashboard query is automatically scoped to the current tenant. The current tenant is determined by:
- Explicit URL prefix (
/t/<tenant>/...) when present — supports side-by-side comparison without state-flipping - Tenant cookie / session value when no URL prefix
- Default tenant (
default) for unauthenticated or first-time use
Cross-tenant aggregation is an explicit operation behind an "All tenants" toggle — opt-in, never the default. The toggle is gated by a role (initially: only the platform operator can see it).
4. The Project header includes its tenant
Per ADR-059, project pages live at /projects/<slug> (or /<slug> for
products at the root). When the URL is tenant-prefixed
(/t/<tenant>/projects/<slug>), the project page renders that tenant's
view of the project. Without a tenant prefix, the current-tenant scope
applies.
Project YAML's tenant field is enforced at validation time
(scripts/validate-projects.sh per PR #294 / ADR-059 follow-up): a
project file's tenant must reference an existing entry in tenants/.
5. Portability primitives (the export/import loop)
Three new commands, all wrapping existing primitives:
gyrum-tenant-export <tenant-slug> [--include secrets|catalogs|all]
Produces a single tarball / signed bundle:
tenant-default-2026-04-22T22-30Z.tar.gz
├── tenant.yaml # tenants/default.yaml
├── projects/ # all projects/*.yaml with this tenant
├── pipelines/ # all pipelines with this tenant
├── catalogs/ # latest catalog per server (ADR-060)
├── findings.jsonl # findings for this tenant
├── secrets-manifest.yaml # *names only*, never values (see below)
└── manifest.yaml # what's in the bundle, schemaVersion, signed digest
--include catalogs adds full catalogs; default is the latest baseline
catalog per target only. --include secrets adds the manifest only —
NEVER values; values stay in the operator's secret store.
gyrum-tenant-import <bundle> --as <new-tenant-slug>
Inverse: unpacks the tarball into the target system, rewriting all
internal tenant: fields to the new slug, validating the schema, and
writing files. Reports any references that need manual fix-up (e.g.
secrets named in the manifest that the target system does not have).
Does not run anything; produces a "ready to provision" tenant snapshot.
gyrum-tenant-replay <bundle> --as <new-tenant-slug>
After import, this command kicks off the catalog-driven provisioning
pipeline (ADR-060's pipelines/server-provision-from-catalog.json) for
every catalog in the bundle, rebuilding the captured infrastructure in
the target environment. Secrets must be filled in beforehand via
operator-supplied secrets.env (matching the manifest names).
6. Secrets boundary — why this is the hard part
Per ADR-060's boundary rules, catalogs never carry secret values. The same rule applies to the export bundle: it carries secret manifests (names + descriptions), not values.
Cross-tenant secrets workflow:
- Source operator:
gyrum-tenant-export ... --include secretsproduces a manifest like[{name: "BUZZY_DB_PASSWORD", purpose: "DB auth"}, ...] - Target operator: receives the bundle, sees the manifest, populates a
matching
secrets.envfile from their own secret store - Provisioning consumes
secrets.envat apply time
This means moving a tenant between systems is always a two-action flow: move the bundle (mechanical), populate the secrets (out-of-band, human- gated). That separation is the trust boundary; we are not in the "automatic secret migration" business.
7. Lifecycle states for a tenant
| State | Meaning | Default visibility |
|---|---|---|
active |
In normal use | Yes |
archived |
Read-only; no new pipelines kicked off; data retained | Hidden by default |
sunset |
Scheduled for deletion (with a date) | Hidden, audit-log retained |
Sunsetting a tenant blocks until: no active pipeline runs, no live catalogs newer than X days, all projects under the tenant are themselves sunset (per ADR-059's lifecycle). Mechanical safety net.
Migration path (from today's single-tenant)
This is non-disruptive for the current operator. Sequence:
| Step | What | Risk |
|---|---|---|
| 1 | Land this ADR; nothing changes in code | None |
| 2 | Add tenant: default to every existing project YAML; validate |
None — backward-compat during window |
| 3 | Add tenant_id column to whatever stores pipelines/runs/findings (when those move to a DB) |
Schema migration |
| 4 | Implement gyrum-tenant-export (tarball generator) |
None — read-only |
| 5 | Implement gyrum-tenant-import (idempotent installer with --as) |
Tested via export-import-back-to-source equality check |
| 6 | Add /t/<tenant>/... URL prefix support to ai-frontend |
Existing routes still work without prefix |
| 7 | Add "tenant chooser" widget (only renders when >1 tenant defined) | Cosmetic |
| 8 | Make tenant field required on all artifacts (close the back-compat window) |
Final step |
Step 1 is this ADR. Steps 2–8 each independently shippable.
Consequences
Easier:
- Migration-as-feature: spinning up a new org / fresh environment is
export → import → replay, not "rebuild from memory". - Disaster recovery rehearsal: the export-replay loop IS DR; rehearsing it is the same flow.
- Contractor / partner access: scoped views drop out for free — filter on tenant, surface only what they own.
- Multi-product founders: someone running three SaaS products under one operator account can split one off into its own tenant cleanly.
- Eventual public multi-tenancy: if and when the platform itself becomes a SaaS product, the data model is already there; auth/billing are the work, not data scoping.
Harder:
- Every existing artifact gets a new field; back-compat window must close eventually.
- Cross-tenant comparisons (when the operator wants them) are an intentional opt-in, slightly more clicks than today.
- Tenant boundary discipline must hold — no "global" pipeline runs that silently span tenants. Every aggregation declares its scope.
- Secrets remain a manual handoff. We cannot automate that across orgs; attempts to do so are usually how secrets leak.
What we sign up to maintain:
- The
tenant: defaultbackward-compat default until step 8 of the migration. After: every artifact MUST have a tenant. - Validation that every tenant-scoped reference resolves
(
projects/<slug>.yaml.tenantexists intenants/). - The export bundle schema as a stable contract — bundles produced today must be importable by versions released N years from now (subject to documented migration steps).
- The secrets boundary — values never enter the bundle, period.
Alternatives considered
- Stay single-tenant; revisit when needed. Rejected: retrofitting
tenant_id on every stored artifact is a 6-month forensic project once
the data has accumulated. The single-tenant case costs nothing today
if
tenant: defaultis the default. - Heavyweight multi-tenancy with row-level security and per-tenant
encryption keys. Rejected as scope: that is appropriate for a SaaS
product with paying enterprise customers. Today's gyrum needs scope
- portability, not isolation guarantees.
- Use OS-level tenant isolation (separate VMs / containers per tenant). Rejected: fights against the catalog/provisioning work in ADR-060. The catalog model assumes a single orchestrator can see everything; per-tenant VMs put the catalog inside the VM, where the orchestrator can't see it without per-tenant access.
- Unique GitHub orgs per tenant from day one. Rejected: the gyrum platform's value is consolidating fleet operations under one orchestrator. Forcing org-per-tenant duplicates GitHub Actions spending, branch protections, secrets, etc. Tenant-as-data-field is much lighter.
Related ADRs
- ADR-058 (5-mode UI): tenant chooser sits next to mode chooser when
multiple tenants exist; URL prefix
/t/<tenant>/...precedes the mode segment. - ADR-059 (project-first IA): projects gain a
tenant:field; validate-projects.sh enforces it. - ADR-060 (catalog-driven infrastructure): catalogs are the primary asset moved by export/import. Provision-from-catalog is the replay primitive.
- ADR-061 (libraries extraction): the export bundle format becomes
part of the
gyrum-projectslibrary's public schema. - ADR-062 (design system): tenant chooser becomes a
<gy-tenant- chooser>Web Component sitting next to<gy-mode-switcher>.
Open questions for follow-up
- Bundle signing — sign with what key? Operator's GPG key? gyrum-issued cert? Pick when implementing step 4.
- Bundle storage — local files? S3-compatible blob store? Both? File- first; promote to blob when bundles routinely exceed 100MB.
- Cross-tenant block-registry promotion — if Operator A writes a useful custom block, can Operator B import it? Probably yes, but governance story TBD; out of v1 scope.
- Audit log of tenant operations — every export/import logged. Where? Per-tenant or global? Lean per-tenant with a global summary view.