# Spec 0b — Org to Instance Rename

Date: 2026-07-24
Status: Design approved, not implemented
Depends on: spec 0a (`shared-module`)
Ships: independently, before any licensing code

## Context

The licensing model separates two concepts that the codebase currently conflates
under one word:

- **Account** — a paying customer. Lives only in the admin control plane
  (spec 3). The control plane never learns about it.
- **Instance** — one deployment of Vantage: its own subdomain, its own users,
  its own servers, keys, workflows, monitors and secrets. One license attaches
  to one instance.

Today's control-plane `Org` **is** an Instance. An Account may hold several,
some cloud and some self-hosted, and the self-hosted ones have no row in the
cloud database at all.

Keeping the name `Org` would leave the control plane using a word that means
something different in the admin site, in Paddle, and in every support
conversation. This spec renames it everywhere, including on disk.

This is the highest-risk change in the programme: `org_id` is the tenant
isolation key on every document in every collection. It is done alone, before
anything else, so that nothing else is in flight when it deploys.

## Goals

1. `Instance` is the only word for a tenant, in code, API, UI and database.
2. No document is lost and no tenant scoping is weakened.
3. The migration is reversible.

## Non-goals

- Any behaviour change. Same routes' semantics, same permissions, same data.
- Introducing Accounts. The control plane never gets them.
- Touching the agent. It talks gRPC and has no concept of a tenant.

## Design

### Naming map

| Today | After |
|---|---|
| collection `orgs` | `instances` |
| collection `org_oidc` | `instance_oidc` |
| field `org_id` (all collections) | `instance_id` |
| `models.Org` | `models.Instance` |
| `Org.OrgID` | `Instance.InstanceID` |
| `User.OrgID`, `Settings.OrgID`, every `OrgID` field | `InstanceID` |
| `services/orgs.go`, `GetOrg`, `CreateOrg`, `ListOrgIDs`, `CountOrgs`, `FirstOrg`, `AdoptOrg`, `GetOrgBySlug` | `services/instances.go`, `GetInstance`, `CreateInstance`, … |
| `services/org_oidc.go` | `services/instance_oidc.go` |
| `auth/orghost.go` | `auth/instancehost.go` |
| `/api/org/users`, `/api/org/oidc` | `/api/instance/users`, `/api/instance/oidc` |
| `shared/provision.CreateOrg`, `RollbackOrg` | `CreateInstance`, `RollbackInstance` |
| session field `org_id` | `instance_id` |
| `GET /auth/me` response `org_id` / `org` | `instance_id` / `instance` |
| UI copy "Organisation" | "Instance" |

Reserved slugs gain no new entries here, but note `admin` is already reserved,
which the admin site relies on later.

### Collections carrying `org_id`

All of: `servers`, `keys`, `assignments`, `users`, `org_oidc`, `settings`,
`secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `monitors`,
`incidents`, `monitor_rollups`, `notification_channels`, `console_sessions`,
`audit_logs`, plus `orgs` itself. `migrations` does not carry one.

`site_pending_signups` does not carry `org_id`, but its `org_name` field becomes
`instance_name` for consistency; it is sitesvc-private so this is free.

The migration must derive this list from a constant in code, not from a
hand-written list in a runbook, so that a collection added between design and
deploy is not silently missed:

```go
var scopedCollections = []string{ /* the list above */ }
```

A boot-time assertion (spec 2 onwards) checks that no collection outside this
list contains an `org_id` field. Cheap insurance against a future collection
being added without being renamed.

### Migration `0004_org_to_instance`

Recorded in `migrations` like the existing three. Runs after
`0003_missed_org_scopes`.

**The migration only renames. It never deletes and never drops.** A bad deploy
is recovered by running the inverse rename, not by restoring a backup.

Steps, in order:

1. **Guard.** If collection `instances` already exists and `orgs` does not, the
   migration has already run against this database by an earlier binary; record
   the marker and return. Idempotency matters because the marker write and the
   data work are not in one transaction.
2. **Rename collections.** `orgs` → `instances`, `org_oidc` → `instance_oidc`,
   via `adminCommand{renameCollection}`. Fails loudly if the target exists.
3. **Rename the field.** For each collection in `scopedCollections`:
   `UpdateMany({org_id: {$exists: true}}, {$rename: {"org_id": "instance_id"}})`.
   Record `matched` and `modified` per collection in the log.
4. **Verify.** For each collection, assert
   `CountDocuments({org_id: {$exists: true}}) == 0` and
   `CountDocuments({instance_id: {$exists: true}}) == totalCount`. Any mismatch
   aborts before the marker is written, leaving the migration to retry.
5. **Indexes.** Drop and recreate indexes that name `org_id` in their key spec:
   unique `settings.instance_id`, the ESO token-hash index, and any compound
   scoping indexes. Unique `instances.slug` and `users.email` are unaffected by
   the field rename but are re-declared idempotently.
6. **Write the marker.**

Steps 2–4 are not atomic across collections. Mongo multi-document transactions
would require a replica set, which is not guaranteed for self-hosted installs.
Instead the migration is written to be **safely re-runnable**: `$rename` on a
document that has already been renamed matches nothing, and the collection
rename is guarded in step 1.

Rollback, if ever needed, is the same code with the rename reversed, shipped as
a one-shot command rather than a migration — deliberately manual, because the
only reason to run it is a decision to revert the release.

### Version skew

`sitesvc` and `server` write the same documents. A skew where one writes
`org_id` and the other reads `instance_id` creates tenants that are invisible to
the application — the exact failure `CLAUDE.md` warns about.

After spec 0a both read the shape from `shared`, so the skew window is a
deployment-ordering problem rather than a code-drift problem:

- Both images are built from the same commit and deployed together.
- The migration runs from the `server` container at boot, as the existing three
  do.
- `sitesvc` at boot asserts that collection `instances` exists and refuses to
  start otherwise, with the message
  `instances collection not found; deploy the control plane first`. Failing to
  start is strictly better than provisioning into a collection nobody reads.

The self-hosted deployment runs no sitesvc, so it sees only the server change.

### API and frontend

REST route renames are **breaking**, but every consumer is first-party (`web/`)
and ships in the same release. No compatibility aliases — a permanent dual path
in the tenant-scoping layer is worse than a coordinated release.

`web/` changes: the API client's paths, the `useMe` shape, all UI copy from
"Organisation" to "Instance", and the settings route `/settings/org` →
`/settings/instance`.

`site/` marketing copy changes where it says "organisation" about a tenant. Where
it means the customer, it becomes "account" — that word now has a specific
meaning and the marketing site is the first place a customer meets it.

## Testing

**No automated tests.** Decision taken 2026-07-24, consistent with spec 0a.

This is the change where that costs the most: it moves the tenant isolation key
across 17 collections, and a mistake orphans a customer's entire fleet rather
than breaking a build. The compensating controls are therefore not optional, and
the implementation plan makes each a mandatory step:

1. **Dry run against a restored copy** before the code is even committed —
   migrate a `mongorestore`d duplicate of production and read the per-collection
   rename counts.
2. **Idempotency by hand** — run the dry run twice; the second must complete
   with no error and nothing left to rename.
3. **Interrupted-run recovery by hand** — rename `orgs` manually, then run the
   migration; it must complete and leave every document carrying `instance_id`.
4. **Count comparison against a production snapshot** — record every
   collection's document count before and after; any difference stops the
   release.
5. **Per-tenant isolation comparison** — for three real tenants, count rows in
   `servers`, `keys`, `workflows`, `monitors`, `secrets` and `audit_logs` by
   `org_id` before and by `instance_id` after. Identical, or the release stops.
   This is the check that proves tenant isolation survived.
6. **Stale-field sweep** — assert no collection anywhere still holds an
   `org_id`.
7. **Rollback rehearsal** — migrate a third copy, run `rename-rollback`, confirm
   the counts return to baseline and the pre-release binary boots against it.
   Deploying without having done this is not permitted.
8. **Boot guard, both directions** — sitesvc must refuse an unmigrated database
   and start normally against a migrated one.

`AssertNoScopedCollectionMissed` runs at every boot and is fatal. With no test
suite it is the standing protection against a future collection being added
without being added to `ScopedCollections`.

## Verification before merge

Run against a **restored production snapshot**, not a synthetic database:

1. Record `db.getCollectionNames()` and per-collection `countDocuments()` before.
2. Run the migration.
3. Assert every count is identical afterwards.
4. Assert `instances.countDocuments()` equals the old `orgs.countDocuments()`.
5. Pick three real tenants; run the same scoped query before (by `org_id`) and
   after (by `instance_id`) and confirm identical result sets. This is the test
   that proves tenant isolation survived.
6. Boot the server against the migrated snapshot; log in as a real user; confirm
   servers, keys, workflows, monitors and secrets all list correctly.
7. Boot sitesvc against the migrated snapshot; complete a signup end to end.
8. Boot sitesvc against an **un**migrated snapshot; confirm it refuses to start
   with the expected message.

## Rollout

1. Take a database backup. Not optional — this is the one change where the
   inverse rename is the recovery path and the backup is the second.
2. Deploy `server`, `web`, `site` and `sitesvc` from one commit, together.
3. Server boots, migration runs, marker recorded.
4. Watch for the sitesvc guard message; if it appears, sitesvc started first and
   will restart cleanly.

Expect a short window during the server restart where the API is unavailable.
Agents are unaffected: they reconnect, and no gRPC message carries a tenant ID.

## Risks

| Risk | Mitigation |
|---|---|
| Partial migration leaves mixed field names | Step 4 verification aborts before the marker; migration is re-runnable |
| A collection missed from the list | List is a code constant plus a completeness test plus a boot-time assertion |
| sitesvc deployed before server | Boot guard refuses to start |
| An index still keyed on `org_id` | Step 5 drops and recreates; verification includes an index listing diff |
| A hard-coded `org_id` string outside the model layer | `grep -rn '"org_id"' server/ sitesvc/ shared/` must return only the migration file after the change |
| Frontend missed a renamed route | Full manual pass over every route in the UI before release |

## Follow-on

With `Instance` established, spec 1 (`licensing-core`) can define a license
payload that binds to `instance_id` without inventing a word the codebase does
not use.
