Decisions

ADR-063: Tenant scoping + portability

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…

#063

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>.yaml gains a top-level tenant: <id>)
  • Catalogs (per ADR-060, the envelope already has target.id; add tenant: <id> alongside)
  • Pipeline definitions (pipelines/*.json gains "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:

  1. Explicit URL prefix (/t/<tenant>/...) when present — supports side-by-side comparison without state-flipping
  2. Tenant cookie / session value when no URL prefix
  3. 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:

  1. Source operator: gyrum-tenant-export ... --include secrets produces a manifest like [{name: "BUZZY_DB_PASSWORD", purpose: "DB auth"}, ...]
  2. Target operator: receives the bundle, sees the manifest, populates a matching secrets.env file from their own secret store
  3. Provisioning consumes secrets.env at 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: default backward-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.tenant exists in tenants/).
  • 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: default is 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-projects library'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.