From d15ab78bd57aa77453eb3fc73f3af40f1af669d4 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Sun, 26 Jul 2026 12:16:38 +0100 Subject: [PATCH] docs: spec 6 revised for account membership An account has people, and those people belong to several cloud instances. That requires dropping the global unique index on users.email for a compound (instance_id, email), scoping the two unscoped lookups that made the global index load-bearing, and projecting HQ users into instances as real control-plane users. Self-hosted instances are never projected into; their users stay local. Restructured into three phases: identity, creation and lifecycle, membership. Co-Authored-By: Claude Opus 5 --- ...26-07-26-cloud-instance-creation-design.md | 422 +++++++++++++----- 1 file changed, 306 insertions(+), 116 deletions(-) diff --git a/docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md b/docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md index 462b705..5440dc1 100644 --- a/docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md +++ b/docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md @@ -1,4 +1,4 @@ -# Cloud instance creation — design +# Cloud instance creation and account membership — design Spec 6. Designed 2026-07-26. Depends on specs 0a, 0b, 1, 2 and 3, all shipped. @@ -15,57 +15,161 @@ It is to separate the two things that flow has conflated — **having an account and **having an instance** — so that the account exists first and the instance is something the customer asks for. +Once accounts are real, a second thing follows: an account has *people* in it, +and those people need access to the account's instances. That is what forces the +`users.email` change below, and it is the largest single item in this spec. + ## The new flow ``` site/start form ──POST──▶ admin /auth/signup account name, email, password - → accounts row + unverified customer_users row + verification email - → nothing written to the control plane + → accounts row + unverified customer_users row (account_role owner) + → verification email; nothing written to the control plane verification link ──▶ admin /auth/verify → customer_users.verified_at set → customer signs in at vantage-hq.hostxtra.co.uk HQ portal, "Create instance" ──POST──▶ admin /api/instances - → control-plane instances + users (owner; HQ password hash copied) - → admin_instances row, deployment cloud, status active - → Free licence issued, injected, and emailed as "your instance is ready" + → control-plane instances + users (creator becomes owner) + → admin_instances row + instance_members row + → Free licence issued, injected, emailed as "your instance is ready" + +HQ portal, "Invite" and "Add to instance" + → more customer_users on the account + → each grant projects a control-plane users row into that cloud instance ``` -Signup itself needs no new code: `auth.HandleSignup` already creates an account, -an unverified `customer_users` row and a verification email, and was written for -self-hosted customers. It turns out to be exactly the account-first signup cloud -needs. The new work is instance creation, the Free lifecycle, and reclaim. +Signup itself needs almost no new code: `auth.HandleSignup` already creates an +account, an unverified `customer_users` row and a verification email, and was +written for self-hosted customers. It turns out to be exactly the account-first +signup cloud needs. This supersedes the "Signup migration off sitesvc" section of spec 5 (`2026-07-24-paddle-billing-design.md`). That section moved the *existing* -signup-provisions-an-instance flow to admin unchanged. This spec changes its -shape instead. Spec 5's Paddle work is unaffected and layers on top: the £0 Free -subscription and the Paddle customer are created where this spec issues the Free -licence. +signup-provisions-an-instance flow to admin unchanged; this changes its shape. +Spec 5's Paddle work is unaffected and layers on top: the £0 Free subscription +and the Paddle customer are created where this spec issues the Free licence. Paddle is explicitly **out of scope here**. Accounts created by this spec have an empty `PaddleCustomerID`, which spec 5's account model already permits. -## Instance creation +## Phasing -`POST /api/instances`, customer session, body `{ "name": "..." }`. +Three phases, each shippable, in this order. The plan should not interleave them +— phase 1 changes an index that everything else then depends on. + +1. **Identity** — drop the global email index, scope the lookups, project the + creator as owner. No new UI. +2. **Instance creation and Free lifecycle** — `POST /api/instances`, renewal, + notices, the reaper, the sitesvc cutover. +3. **Membership** — account roles, invitations, per-instance grants, password + propagation. + +## Phase 1 — identity + +### Dropping the global email index + +`users.email` currently carries a unique index **across the whole control +plane**. `CLAUDE.md` names it a security property, and it is one today. It is +also what makes "an account's people belong to several instances" impossible: +one address can own exactly one user document anywhere. + +It is replaced by a unique compound index on `(instance_id, email)`, which is the +constraint that was actually wanted: one address is one user *within an +instance*. + +The global index is only load-bearing because two lookups are unscoped. Both are +scoped instead, and the scoped lookups are a strictly stronger guarantee than the +index was — an index prevents the ambiguity, whereas a scoped query cannot be +ambiguous in the first place. + +| Caller | Today | After | +|---|---|---| +| `auth.HandleLocalLogin` | `GetUserByEmail(email)` | resolve the instance, then `GetUserInInstanceByEmail` | +| `auth.HandleOIDCCallback` | `GetUserByEmail(email)`, then a cross-instance guard | `GetUserInInstanceByEmail(instanceID, …)`; the guard is deleted as unreachable | + +`services.GetUserByEmail` is **deleted**, not merely left unused. Leaving an +unscoped helper in place is how this bug comes back. + +Resolving the instance for local login: + +1. `InstanceFromHost` — always succeeds on cloud, where every instance has its + own subdomain. +2. Otherwise, if exactly one instance exists, use it. This is the self-hosted + case, which is single-instance by construction because a licence binds one + instance UUID. +3. Otherwise refuse with a message naming the cause, rather than guessing. + +### The index migration + +In `shared/indexes.EnsureCoreIndexes`, in this order: + +1. Create the unique compound index on `(instance_id, email)`. Fatal on failure. +2. Drop `email_1` if present, ignoring `IndexNotFound` so it is idempotent. + +Creating before dropping means a failure at step 2 leaves both indexes in place, +which is safe. A failure at step 1 leaves the old index alone, which is also +safe. + +`EnsureCoreIndexes` is called at boot by server, sitesvc and admin, so **all +three images must ship together**. `server-deploy.yml` rebuilds every image on +every push to `main`, so this happens by default; the risk is only a partial +manual rollout on the host. + +**This migration is one-way.** Once two users share an address across instances, +`email_1` cannot be recreated. Rolling the server back past this change would +leave unscoped lookups running against data that can now be ambiguous. The +rollback plan is forward-only: fix and redeploy. + +`sitesvc.EmailTaken` also does an unscoped count over `users`. It disappears with +sitesvc's signup in phase 2. + +`admin.HandleCloudLogin`'s control-plane branch does an unscoped +`FindOne({email})` too, and unlike the other two there is no instance in context +to scope it by — HQ login is not per-instance. **That branch is deleted.** Every +customer created by this spec has a `customer_users` row, which already wins in +the existing precedence. Legacy cloud customers are handled by staff, who already +attach their instances by hand per the spec README, and who gain +`POST /api/staff/accounts/:id/users` to create their HQ login. + +### The control-plane user document + +`shared/models.User` gains: + +- `hq_user_id` — the `customer_users.user_id` this row was projected from, absent + on locally-created users. +- `auth_source: "hq"` as a third value alongside `local` and `oidc`. + +An `hq`-sourced user is **managed in HQ, not in the instance**. The control plane +refuses to change its role, delete it, or change its password through +`/api/instance/users`, answering with "managed in Vantage HQ". `web/` renders +those rows read-only with the same label. Locally-created users are unaffected +and stay fully editable in the instance — a cloud instance can hold both kinds. + +This gives one owner per fact. A role that is editable in two places is a role +with two answers. + +## Phase 2 — instance creation + +`POST /api/instances`, customer session, `account_role` owner or admin, +body `{ "name": "..." }`. In order, each step undoing the previous on failure: 1. Refuse if the account already holds a non-cancelled Free instance — a pre-check of the same rule `licensing.checkFreeLimit` enforces, so we never create an instance we then cannot licence. `409`. -2. Read the session's `customer_users` row for its bcrypt hash. +2. Read the caller's `customer_users` row for its bcrypt hash. 3. `provision.CreateInstance` — control-plane instance and slug. -4. `provision.CreateUserWithHash(…, RoleOwner, "local")` with that hash. On - failure, `provision.RollbackInstance`. -5. Insert `admin_instances`. On failure, delete the control-plane user, then roll - back the instance. +4. `provision.CreateUserWithHash(…, RoleOwner, "hq")` with that hash and + `hq_user_id`. On failure, `provision.RollbackInstance`. +5. Insert `admin_instances`, then `instance_members` for the creator. On failure, + delete the control-plane user, then roll back the instance. 6. `licensing.Issue{Tier: free, Term: "monthly", Reason: ReasonNew, IssuedBy: "self-serve"}`, then `inject.Deliver`. -7. Email the customer: instance URL, sign-in address, licence expiry date. +7. Email the creator: instance URL, sign-in address, licence expiry date. Steps 6 and 7 do **not** fail the request. A licence that was not issued is recoverable — the instance exists, the customer can sign in, they see spec 2's @@ -74,78 +178,51 @@ rolling back an instance the customer can already see would be worse. This matches the rule spec 5 states for the same pair of failures: both outcomes resolve toward "the customer gets in". -### One credential, copied once - -The control-plane owner is created with the bcrypt hash already stored on -`customer_users`, so one password unlocks both HQ and the instance. - -The hash is **copied, not shared**. Changing the password on either side does not -propagate to the other, and they diverge from that moment. This is accepted: -propagating a password across two services' databases is a worse problem than two -passwords that started the same. It is worth saying in the UI at creation time. - -`HandleCloudLogin`'s control-plane fallback stays, and stays second — the -existing `customer_users`-wins branch means every customer created by this spec -authenticates against admin's own row. The fallback now serves only legacy cloud -customers who have an instance but no HQ account. - -### The global email ceiling - -`users.email` carries a **unique index across the whole control plane**, not per -instance. Two consequences, both real: - -- An address that already owns a legacy control-plane user cannot create an - instance here. `provision.CreateUserWithHash` returns `ErrEmailTaken`; the - endpoint answers `409` with a message pointing at support, rather than a - generic failure. -- One email owns at most one instance. Free is capped at one per account, so - nothing in this spec is blocked. It is a genuine ceiling on paid multi-instance - accounts later, and that is spec 5's problem to solve, not this one's. - ### Admin's control-plane write boundary `inject`'s package doc says plainly that a second write target into the control plane "is a design change and not a refactor". This is that design change, and it is made explicitly rather than by widening `inject`. -Instance provisioning lives in a **new package, `admin/internal/cloudprov`**. -`inject` is left untouched, still writing exactly three licence fields on -`instances`. `db` gains a `ControlDB() *mongo.Database` accessor, because -`shared/provision` takes a database rather than a collection. +Provisioning and membership projection live in a **new package, +`admin/internal/cloudprov`**. `inject` is left untouched, still writing exactly +three licence fields on `instances`. `db` gains a `ControlDB() *mongo.Database` +accessor, because `shared/provision` takes a database rather than a collection. -`CLAUDE.md`'s description of that boundary is updated in the same commit. It -currently claims admin's control-plane access is read-only apart from three -licence fields, and that stops being true here. +`cloudprov` writes exactly three things: instance documents (create and roll +back), user documents (create, delete, update role and password hash), and +nothing else. `CLAUDE.md`'s description of the boundary is updated in the same +commit, because it currently claims admin's control-plane access is read-only +apart from three licence fields, and that stops being true here. -## Free lifecycle +## Phase 2 — Free lifecycle A Free licence runs for one month plus the existing three-day `GracePeriod`, using the `"monthly"` term `licensing.Issue` already implements. No new term -value. +value. One Free instance per account, unchanged. ### Renewal -`POST /api/instances/:id/renew`, customer session, through `ownedInstance`. +`POST /api/instances/:id/renew`, through `ownedInstance`, account owner or admin. - Tier must be Free. Paid tiers renew through billing, not here. - Allowed once `now > expires_at - 7d`, and at any point after that up to deletion — so the same button rescues a lapsed instance rather than needing a second mechanism. -- Reissues Free with `Reason: ReasonRenewal`, injects, and emails the new date. +- Reissues Free with `Reason: ReasonRenewal`, injects, emails the new date. Renewal is deliberately manual. It is the entire reclaim signal: an instance nobody renews is an instance nobody is using. -### Status +### Status and notices `admin_instances.status` gains `deleted`. A sweep in admin flips `active` to `lapsed` when the current licence's `expires_at` passes, and the existing 15-minute reconciler — which already logs "no control-plane instance X" — flips -those to `deleted` instead of only logging. +those to `deleted` and clears their `instance_members` rows instead of only +logging. -### Notices - -Four emails, driven by the licence's `expires_at`: +Four emails to the account's owners and admins, driven by `expires_at`: | When | Says | |---|---| @@ -154,11 +231,11 @@ Four emails, driven by the licence's `expires_at`: | 7 days before deletion | Deleted in 7 days | | 1 day before deletion | Deleted tomorrow | -Each send is recorded on the `admin_instances` document, so a restart or a -double tick cannot re-send one. Renewal clears the record, so the next term -starts the sequence again. +Each send is recorded on the `admin_instances` document, so a restart or a double +tick cannot re-send one. Renewal clears the record, so the next term starts the +sequence again. -## Deletion +## Phase 2 — deletion Deletion is the only irreversible path in the system, so it is owned by the service that knows what an instance is made of. @@ -184,6 +261,8 @@ The sweep, in `server/internal/services`: admin's reconciler keeps that field current. - Every purge writes an audit entry before deleting, and logs the instance ID, slug and document counts. +- Admin's reconciler notices the instance has gone and cleans up its own + `admin_instances` status and `instance_members` rows. ### The kill switch @@ -195,6 +274,78 @@ It is unset in `deploy/docker-compose.yml` and set to `336h` only in anything — the same containment rule that keeps `LICENSE_SIGNING_KEY` in exactly one service in exactly one compose file. +## Phase 3 — accounts, people and membership + +### The model + +``` +Account + ├── customer_users the people. account_role: owner | admin | member + └── admin_instances the deployments + └── instance_members which people are on which cloud instance +``` + +`customer_users` gains `account_role`. Existing rows backfill to `owner` — they +are all account creators today. Owners and admins may invite users, create +instances, and grant instance access; billing stays owner-only. The vocabulary +deliberately matches the control plane's own three roles rather than inventing a +second one. + +`instance_members` is new: `{member_id, account_id, instance_id, +customer_user_id, role, control_user_id, created_at}`, unique on +`(instance_id, customer_user_id)`. `role` is the role the projected +control-plane user holds inside the instance. + +### Grants project, they do not federate + +Granting a user access to a cloud instance creates a real control-plane `users` +row through `cloudprov`, with `auth_source: "hq"` and `hq_user_id` set. The +instance authenticates it exactly as it authenticates any other user, with no +runtime dependency on admin. Revoking deletes that row. + +**Self-hosted instances are never projected into.** `POST /api/instances/:id/ +members` refuses when `deployment != cloud`, with that as the message. For a +self-hosted instance the account's users exist to manage the licence, and the +instance's own users are managed locally in the customer's own deployment, which +we cannot see and have no business writing to. + +Endpoints, all customer-session and all through `ownedInstance` where an instance +is named: + +``` +GET,POST /api/account/users invite; owner|admin +PUT /api/account/users/:id/role owner|admin; cannot demote the last owner +DELETE /api/account/users/:id owner|admin; revokes every grant first +PUT /api/account/password any user; propagates +GET,POST /api/instances/:id/members owner|admin +PUT /api/instances/:id/members/:uid/role +DELETE /api/instances/:id/members/:uid +``` + +Invitations reuse `auth.CreateCustomerUser`, which already does the +unverified-row-plus-verification-email dance and already deletes the row if the +email fails to send. A user cannot be granted an instance until verified. + +Revoking the last **owner** of an instance is refused, mirroring the control +plane's own `ErrLastOwner`. The check counts control-plane owners for that +instance, so it also sees owners created locally inside the instance. + +### Password propagation + +The HQ password is the single source of truth for every `hq`-sourced row. + +`PUT /api/account/password` rehashes at cost 12, updates `customer_users`, then +has `cloudprov` write the same hash to every control-plane user carrying that +`hq_user_id`. The instance refuses to change an `hq`-sourced user's password +locally, so there is no competing writer. + +Propagation is best-effort and retried, on exactly the pattern `inject` already +proves: a failure is logged and flagged, and admin's 15-minute reconciler gains a +pass that compares each `hq`-sourced row's hash against its `customer_users` +source and repairs mismatches. The worst case is a stale password on one instance +for up to fifteen minutes, which is recoverable; failing the password change +because one of three instances was unreachable is not. + ## Frontend ### `site/` @@ -208,33 +359,41 @@ goes — there is no instance yet at this point, and showing one would be a lie. account, and its "What happens next" panel gains the create-an-instance step between confirming the email and adding a key. -`SITE_API_URL` still serves the contact form. `ADMIN_API_URL` gains a -browser-reachable presence in the `site` image build, and `site`'s origin must be -listed in admin's `ADMIN_ORIGIN`. Both are new failure modes with the same -footgun `CLAUDE.md` already documents for `SITE_API_URL`. +`ADMIN_API_URL` gains a browser-reachable presence in the `site` image build, and +`site`'s origin must be listed in admin's `ADMIN_ORIGIN`. Both are new failure +modes with the same footgun `CLAUDE.md` already documents for `SITE_API_URL`. +`SITE_API_URL` still serves the contact form. ### `adminsite/` -- `(customer)/page.tsx` — the "No instances yet" panel gains a primary - **Create a free instance** action. Hidden once the account holds a Free - instance, with the reason stated rather than the button silently absent. -- `(customer)/instances/new/` — name field, live slug preview of the resulting - `.vantage.hostxtra.co.uk`, and a note that the instance password starts - as the HQ password and is changed separately afterwards. +- `(customer)/page.tsx` — the "No instances yet" panel gains a primary **Create a + free instance** action. Hidden once the account holds a Free instance, with the + reason stated rather than the button silently absent. +- `(customer)/instances/new/` — name field and a live slug preview of the + resulting `.vantage.hostxtra.co.uk`. +- `(customer)/instances/[id]/` — a members panel: who is on this instance, their + role, add and remove. Absent for self-hosted instances, replaced by a line + saying users are managed inside the install. +- `(customer)/users/` — the account's people, invitations, account roles. +- `(customer)/settings/` — change password, with a note that it applies to every + instance you belong to. - `components/InstanceCard.tsx` — expiry date, a **Renew** action inside the window, and a deletion countdown when lapsed. Per `CLAUDE.md`'s rule, licence state never reads by colour alone; the countdown is a text label. -- `lib/api.ts` — `createInstance`, `renewInstance`, and `"deleted"` added to - `InstanceStatus`. +- `lib/api.ts` — the new calls, `"deleted"` on `InstanceStatus`, and an + `AccountRole` type. ### `web/` -No changes. Spec 2's licence banner already covers a lapsed instance. +`settings/instance` gains the read-only treatment for `hq`-sourced users: role +shown, controls disabled, labelled "managed in Vantage HQ" with a link to the +portal. Everything else is unchanged; spec 2's licence banner already covers a +lapsed instance. ## sitesvc -Signup, verify, `site_pending_signups` and the provisioning calls are deleted. -sitesvc keeps the contact form only, and drops `APP_LOGIN_URL`. +Signup, verify, `site_pending_signups`, `EmailTaken` and the provisioning calls +are deleted. sitesvc keeps the contact form only, and drops `APP_LOGIN_URL`. The staged cutover from spec 5 applies unchanged, and matters for the same reason: an in-flight verification link must not break. @@ -260,48 +419,79 @@ same job the README already describes for existing cloud tenants. ## Testing -Instance creation: +Phase 1, identity: -1. Signup writes nothing to `instances` or `users`; only the emailed link makes +1. The compound index exists and `email_1` is gone after one boot; a second boot + is a no-op. +2. Two users with the same address in different instances can both be created and + both sign in, each landing in their own instance. +3. Two users with the same address in one instance are refused by the index. +4. Local login on a cloud subdomain finds only that instance's user; the same + address on another instance is not reachable from this host. +5. Local login on a bare host with one instance works; with two it refuses with a + named cause rather than picking one. +6. OIDC provisions into the instance from the callback state, and an address + belonging to another instance no longer produces a cross-org error because it + is simply not found — it provisions a new member instead, which is correct. +7. `GetUserByEmail` no longer exists. + +Phase 2, creation and lifecycle: + +8. Signup writes nothing to `instances` or `users`; only the emailed link makes the account usable. -2. Creating an instance produces an instance, an owner user, an - `admin_instances` row, a Free licence, and an injected `license_blob` on the - control-plane document. -3. The owner can sign in to the new instance with the HQ password. -4. A second Free instance on the same account is refused `409` and writes - nothing. -5. An email that already exists in control-plane `users` is refused `409` and - leaves no instance behind. -6. Owner-insert failure rolls the instance back, and rollback refuses an instance - that has users. -7. Licence issuance failure still leaves a signed-in-able instance and flags for - staff. +9. Creating an instance produces an instance, an `hq`-sourced owner user, an + `admin_instances` row, an `instance_members` row, a Free licence, and an + injected `license_blob`. +10. The creator can sign in to the new instance with their HQ password. +11. A second Free instance on the same account is refused `409` and writes + nothing. +12. Owner-insert failure rolls the instance back, and rollback refuses an + instance that has users. +13. Licence issuance failure still leaves a signed-in-able instance and flags for + staff. +14. Renew outside the window is refused; inside it, it supersedes, injects and + moves `expires_at` forward by a month plus grace. +15. Renewing a lapsed instance restores it before the reaper takes it. +16. Each notice sends once across a restart. -Lifecycle: +Phase 2, the reaper — the part that must be got right: -8. Renew outside the window is refused; inside it, it supersedes, injects and - moves `expires_at` forward by a month plus grace. -9. Renewing a lapsed instance restores it before the reaper takes it. -10. Each notice sends once across a restart. - -Reaper — the part that must be got right: - -11. With `FREE_INSTANCE_REAP_AFTER` empty, nothing is ever deleted. -12. An instance with no `license_tier` is never eligible, whatever its age. -13. A Professional instance past expiry is never eligible. -14. A Free instance one hour short of the window is not deleted; one hour past it +17. With `FREE_INSTANCE_REAP_AFTER` empty, nothing is ever deleted. +18. An instance with no `license_tier` is never eligible, whatever its age. +19. A Professional instance past expiry is never eligible. +20. A Free instance one hour short of the window is not deleted; one hour past it is. -15. A purge leaves no document carrying that `instance_id` in any collection, and +21. A purge leaves no document carrying that `instance_id` in any collection, and writes an audit entry first. -16. Purging is idempotent — a second run over a half-deleted instance completes +22. Purging is idempotent — a second run over a half-deleted instance completes it rather than erroring. +Phase 3, membership: + +23. An invited user cannot be granted an instance until verified. +24. A grant creates a control-plane user that can sign in to that instance with + the invitee's HQ password. +25. The same user can hold rows in two instances at once, with different roles. +26. Revoking deletes the control-plane row, and that user can no longer sign in + to that instance while keeping access to the others. +27. Revoking or demoting an instance's last owner is refused, including when that + owner was created locally inside the instance. +28. Granting against a self-hosted instance is refused and writes nothing to the + customer's deployment. +29. A `member` cannot invite, create instances, or grant access. +30. A password change propagates to every linked instance; with one instance's + write forced to fail, the reconciler repairs it within one pass. +31. An `hq`-sourced user's role, deletion and password are refused inside the + instance API, not merely hidden in `web/`. + ## Risks | Risk | Mitigation | |---|---| +| Dropping `email_1` is one-way and weakens a documented security property | Scoped lookups ship in the same binary that drops the index; the unscoped helper is deleted so it cannot be reintroduced; the compound index restores the equivalent guarantee; rollback plan is forward-only and stated | +| A partial rollout leaves an old service recreating `email_1` | All three services call `EnsureCoreIndexes`; `server-deploy.yml` rebuilds every image per push; the host rollout command already updates all services together | | Reaper deletes a live instance | Kill switch defaults off; eligibility needs an explicitly-Free tier and a present expiry; unset fields are never eligible; four warning emails precede it | -| Admin's widened control-plane write access grows further | Confined to `cloudprov`; `inject` untouched; `CLAUDE.md` updated to say so | -| HQ and instance passwords diverge silently | Stated in the UI at creation time and in the instance-ready email | +| Admin's widened control-plane write access grows further | Confined to `cloudprov`, which writes instances and users and nothing else; `inject` untouched; `CLAUDE.md` updated to say so | +| Password propagation leaves an instance stale | Reconciler pass compares and repairs; worst case is fifteen minutes; the instance refuses local changes so there is no competing writer | +| A projected user is edited in both places | `hq`-sourced rows are refused by the instance API, not merely hidden in the UI | | Cutover breaks an in-flight verification link | sitesvc's verify stays live until its collection is empty | -| Global `users.email` uniqueness blocks a legitimate signup | Explicit `409` naming the cause, rather than a generic failure |