diff --git a/docs/superpowers/plans/2026-07-24-instance-rename.md b/docs/superpowers/plans/2026-07-24-instance-rename.md new file mode 100644 index 0000000..bd32e0c --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-instance-rename.md @@ -0,0 +1,1440 @@ +# Org to Instance Rename Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rename the tenant entity from `Org` to `Instance` everywhere — Go types, collections, the `org_id` field on every document, REST routes and UI copy — with a migration that only ever renames and never deletes. + +**Architecture:** A code rename driven from the `shared` module outward, plus migration `0004_org_to_instance` that renames two collections and `$rename`s one field across every scoped collection. The migration verifies document counts before recording its marker, so a partial run retries rather than half-completing. sitesvc refuses to boot against an unmigrated database. + +**Tech Stack:** Go 1.26, MongoDB driver v2, Next.js 16, Docker. + +**No automated tests.** This moves the tenant isolation key across 17 collections. With no test suite, the production-snapshot rehearsal in Task 8 is not optional and not a formality — it is the only evidence that no tenant's data was orphaned. Do not deploy without completing every step of it, including the rollback rehearsal. + +## Global Constraints + +- **Depends on plan 0a being merged and deployed.** Do not start otherwise; this plan assumes one definition of `Org`, `User` and `Settings` in `shared`. +- Take a database backup before deploying. The inverse rename is the first recovery path; the backup is the second. +- **The migration renames. It never deletes, drops or unsets** — the one exception is dropping indexes keyed on the old field name, which touches no documents. +- No behaviour changes. Same permissions, same responses, same data. +- The `agent` module and `agent-release.yml` are not touched by any task. +- REST route renames are breaking and ship in the same release as the frontend. No compatibility aliases. +- After Task 4, `grep -rn '"org_id"' shared/ server/ sitesvc/` must return hits **only** in `migrate_instance.go`. + +## The naming map + +Apply exactly. Every task references this table. + +| Today | After | +|---|---| +| collection `orgs` | `instances` | +| collection `org_oidc` | `instance_oidc` | +| field `org_id` | `instance_id` | +| `models.Org` | `models.Instance` | +| `Org.OrgID` | `Instance.InstanceID` | +| `User.OrgID`, `Settings.OrgID`, every `OrgID` field | `InstanceID` | +| `provision.CreateOrg`, `RollbackOrg` | `CreateInstance`, `RollbackInstance` | +| `services/orgs.go` | `services/instances.go` | +| `GetOrg`, `GetOrgBySlug`, `ListOrgIDs`, `CountOrgs`, `FirstOrg`, `AdoptOrg` | `GetInstance`, `GetInstanceBySlug`, `ListInstanceIDs`, `CountInstances`, `FirstInstance`, `AdoptInstance` | +| `CountOrgUsers`, `GetUserInOrg` | `CountInstanceUsers`, `GetUserInInstance` | +| `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` | +| session field `org_id` | `instance_id` | +| `GET /auth/me` fields `org_id`, `org` | `instance_id`, `instance` | +| `PendingSignup.OrgName` / `bson:"org_name"` | `InstanceName` / `bson:"instance_name"` | +| UI copy "Organisation"/"Organization" (tenant) | "Instance" | +| UI copy "Organisation" (customer, marketing site) | "Account" | + +--- + +## File Structure + +**Created:** + +| Path | Responsibility | +|---|---| +| `shared/models/instance.go` | `Instance` (replaces `org.go`) | +| `shared/provision/instance.go` | `CreateInstance`, `RollbackInstance` (replaces `org.go`) | +| `server/internal/services/instances.go` | replaces `orgs.go` | +| `server/internal/services/instance_oidc.go` | replaces `org_oidc.go` | +| `server/internal/auth/instancehost.go` | replaces `orghost.go` | +| `server/internal/services/migrate_instance.go` | migration `0004`, `ScopedCollections`, boot assertion | +| `server/cmd/rename-rollback/main.go` | one-shot inverse rename | + +**Modified:** `shared/models/user.go`, `shared/models/settings.go`, `shared/provision/user.go`, `shared/indexes/indexes.go`, every file in `server/internal/` referencing a renamed symbol, `server/cmd/main.go`, `sitesvc/internal/{models,store,api}`, `sitesvc/cmd/main.go`, `web/` and `site/` sources. + +--- + +### Task 1: Rename in the shared module + +Everything downstream depends on these names, so they change first. + +**Files:** +- Rename: `shared/models/org.go` → `shared/models/instance.go` +- Rename: `shared/provision/org.go` → `shared/provision/instance.go` +- Modify: `shared/models/user.go`, `shared/models/settings.go`, `shared/provision/user.go`, `shared/indexes/indexes.go` + +**Interfaces:** +- Consumes: the plan 0a API +- Produces: + - `models.Instance` with field `InstanceID string` and tag `bson:"instance_id"` + - `models.User.InstanceID`, `models.Settings.InstanceID` + - `provision.CreateInstance(ctx, db, name) (*models.Instance, error)` + - `provision.RollbackInstance(ctx, db, instanceID) error` + - `provision.CreateUser(ctx, db, instanceID, email, password, role, authSource)` — first argument renamed, signature otherwise unchanged + - `provision.CreateUserWithHash(ctx, db, instanceID, email, passwordHash, role, authSource)` + - `indexes.EnsureCoreIndexes` — now indexes `instances.slug` + +- [ ] **Step 1: Rename the Instance model** + +```bash +cd c:/Work/Repos/vantage +git mv shared/models/org.go shared/models/instance.go +``` + +Replace its contents with: + +```go +// Package models holds the MongoDB documents written by more than one Vantage +// service. +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// Instance is one deployment of Vantage: its own subdomain, users, servers, +// keys, workflows, monitors and secrets. It is the unit a licence attaches to. +// +// A paying customer may hold several. That grouping is called an Account and +// lives only in the admin control plane — this service never sees it. +type Instance struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + InstanceID string `bson:"instance_id" json:"instance_id"` + Name string `bson:"name" json:"name"` + Slug string `bson:"slug" json:"slug"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} +``` + +- [ ] **Step 2: Rename the fields on User and Settings** + +In `shared/models/user.go`, change the one field: + +```go + InstanceID string `bson:"instance_id" json:"instance_id"` +``` + +In `shared/models/settings.go`, change the one field: + +```go + InstanceID string `bson:"instance_id" json:"instance_id"` +``` + +- [ ] **Step 3: Rename the provisioning functions** + +```bash +cd c:/Work/Repos/vantage +git mv shared/provision/org.go shared/provision/instance.go +``` + +Replace its contents with: + +```go +package provision + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/shared/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +// ErrNameRejected wraps every reason a name cannot become an instance. +var ErrNameRejected = errors.New("organisation name rejected") + +const maxSlugAttempts = 50 + +// CreateInstance inserts an instance under the first free slug derived from name. +// +// The count-then-insert loop is racy on its own. It is safe only because +// instances.slug carries a unique index: a lost race surfaces as a duplicate-key +// error, which we treat as "that slug is taken" and retry. Do not remove the +// duplicate-key branch, and do not remove the index. +func CreateInstance(ctx context.Context, db *mongo.Database, name string) (*models.Instance, error) { + base, err := BaseSlug(name) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrNameRejected, err.Error()) + } + + for attempt := 1; attempt <= maxSlugAttempts; attempt++ { + slug := NextSlug(base, attempt) + + n, err := db.Collection("instances").CountDocuments(ctx, bson.M{"slug": slug}) + if err != nil { + return nil, err + } + if n > 0 { + continue + } + + inst := models.Instance{ + InstanceID: uuid.NewString(), + Name: name, + Slug: slug, + CreatedAt: time.Now().UTC(), + } + if _, err := db.Collection("instances").InsertOne(ctx, inst); err != nil { + if mongo.IsDuplicateKeyError(err) { + continue // lost the race; try the next slug + } + return nil, err + } + return &inst, nil + } + return nil, fmt.Errorf("%w: could not find a free slug for %q", ErrNameRejected, name) +} + +// RollbackInstance deletes an instance that has no users. +// +// It refuses an instance that has users. Rollback exists to clean up a +// half-finished signup, and an instance with users is not half-finished. +func RollbackInstance(ctx context.Context, db *mongo.Database, instanceID string) error { + n, err := db.Collection("users").CountDocuments(ctx, bson.M{"instance_id": instanceID}) + if err != nil { + return err + } + if n > 0 { + return fmt.Errorf("refusing to roll back instance %s: it has %d user(s)", instanceID, n) + } + _, err = db.Collection("instances").DeleteOne(ctx, bson.M{"instance_id": instanceID}) + return err +} +``` + +The `ErrNameRejected` text keeps saying "organisation name" — that message is about the *customer's* organisation name as typed into a form, which is still the right word. Task 7 revisits UI copy; this is not it. + +- [ ] **Step 4: Rename the user provisioning parameter** + +In `shared/provision/user.go`, change both signatures and the struct literal: + +```go +func CreateUser(ctx context.Context, db *mongo.Database, instanceID, email, password, role, authSource string) (*models.User, error) { + var hash string + if password != "" { + b, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost) + if err != nil { + return nil, err + } + hash = string(b) + } + return CreateUserWithHash(ctx, db, instanceID, email, hash, role, authSource) +} + +func CreateUserWithHash(ctx context.Context, db *mongo.Database, instanceID, email, passwordHash, role, authSource string) (*models.User, error) { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return nil, fmt.Errorf("email required") + } + if !models.ValidRole(role) { + return nil, fmt.Errorf("invalid role %q", role) + } + + u := &models.User{ + UserID: uuid.NewString(), + InstanceID: instanceID, + Email: email, + PasswordHash: passwordHash, + Role: role, + AuthSource: authSource, + CreatedAt: time.Now().UTC(), + } + if _, err := db.Collection("users").InsertOne(ctx, u); err != nil { + if mongo.IsDuplicateKeyError(err) { + return nil, ErrEmailTaken + } + return nil, err + } + return u, nil +} +``` + +- [ ] **Step 5: Point the index at the renamed collection** + +In `shared/indexes/indexes.go`, change the second index block: + +```go + if _, err := db.Collection("instances").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "slug", Value: 1}}, + Options: options.Index().SetUnique(true), + }); err != nil { + return fmt.Errorf("instances.slug index: %w", err) + } +``` + +- [ ] **Step 6: Build and confirm no stale references** + +```bash +cd c:/Work/Repos/vantage/shared +go build ./... && go vet ./... +cd .. +grep -rn "org_id\|OrgID\|\"orgs\"\|CreateOrg\|RollbackOrg" shared/ +``` + +Expected: no output from any of them. + +- [ ] **Step 7: Commit** + +```bash +git add shared/ +git commit -m "refactor(shared): rename Org to Instance" +``` + +--- + +### Task 2: The scoped-collection list and the boot assertion + +A code constant, not a runbook list. A collection missing from it is a collection whose tenant key never gets renamed — and with no test suite, the boot assertion is what catches that. + +**Files:** +- Create: `server/internal/services/migrate_instance.go` + +**Interfaces:** +- Consumes: nothing +- Produces: + - `var ScopedCollections []string` + - `func AssertNoScopedCollectionMissed(ctx context.Context, db *mongo.Database) error` + +- [ ] **Step 1: Write the list and the assertion** + +Create `server/internal/services/migrate_instance.go`: + +```go +package services + +import ( + "context" + "fmt" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +// ScopedCollections lists every collection carrying the tenant key. +// +// Migration 0004 renames org_id to instance_id in each. A collection missing +// from this list keeps the old field name and becomes invisible to every scoped +// query — so this list is load-bearing, not documentation. +// +// AssertNoScopedCollectionMissed checks at boot that nothing outside this list +// holds an org_id. +// +// The migrations collection is deliberately absent: it is not tenant-scoped. +// The two renamed collections appear under their post-rename names, because the +// migration renames the collections before it renames the field. +var ScopedCollections = []string{ + "instances", + "servers", + "keys", + "assignments", + "users", + "instance_oidc", + "settings", + "secrets", + "workflows", + "workflow_steps", + "workflow_runs", + "monitors", + "incidents", + "monitor_rollups", + "notification_channels", + "console_sessions", + "audit_logs", +} + +// AssertNoScopedCollectionMissed reports any collection holding an org_id that +// ScopedCollections does not know about. A hit means a collection was added +// without being added to the list, and its tenant key was never renamed. +func AssertNoScopedCollectionMissed(ctx context.Context, db *mongo.Database) error { + known := map[string]bool{} + for _, c := range ScopedCollections { + known[c] = true + } + + names, err := db.ListCollectionNames(ctx, bson.M{}) + if err != nil { + return fmt.Errorf("list collections: %w", err) + } + + for _, n := range names { + if known[n] { + continue + } + count, err := db.Collection(n).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": true}}) + if err != nil { + return fmt.Errorf("count %s: %w", n, err) + } + if count > 0 { + return fmt.Errorf("collection %q holds %d document(s) with org_id but is not in ScopedCollections", n, count) + } + } + return nil +} +``` + +- [ ] **Step 2: Cross-check the list against the documented collections** + +```bash +cd c:/Work/Repos/vantage +grep -n "console_sessions\|audit_logs\|monitor_rollups\|notification_channels" claude.md +``` + +Expected: the collection list in `claude.md` matches `ScopedCollections`, allowing for `orgs`→`instances` and `org_oidc`→`instance_oidc`. Any collection in the docs but not in the list must be added, and vice versa. `migrations` is the only intentional omission. + +- [ ] **Step 3: Cross-check against a live database** + +Against a restored production snapshot, in `mongosh`: + +```javascript +use vantage_snapshot +db.getCollectionNames().sort().forEach(function (c) { + var n = db.getCollection(c).countDocuments({org_id: {$exists: true}}); + if (n > 0) print(c + " " + n); +}) +``` + +Expected: exactly the 17 collections in `ScopedCollections`, with `orgs` and `org_oidc` in place of their renamed forms. **Anything else in that output must be added to the list before continuing.** + +- [ ] **Step 4: Build and commit** + +```bash +cd c:/Work/Repos/vantage/server && go build ./... +cd .. +git add server/internal/services/migrate_instance.go +git commit -m "chore(server): add ScopedCollections and the boot assertion" +``` + +--- + +### Task 3: Migration 0004 + +The risky part of the plan. + +**Files:** +- Modify: `server/internal/services/migrate_instance.go` + +**Interfaces:** +- Consumes: `ScopedCollections` +- Produces: `func MigrateOrgToInstance(ctx context.Context, db *mongo.Database) error` + +Takes an explicit `*mongo.Database` rather than using the package-level `db.Database`, so it can be pointed at a snapshot database during rehearsal. `server/cmd/main.go` passes `db.Database`. + +- [ ] **Step 1: Write the migration** + +Append to `server/internal/services/migrate_instance.go`: + +```go +// collectionRenames maps the two collections whose names change. Ordered so the +// migration is deterministic. +var collectionRenames = []struct{ from, to string }{ + {"orgs", "instances"}, + {"org_oidc", "instance_oidc"}, +} + +// MigrateOrgToInstance renames the tenant key from org_id to instance_id. +// +// It only ever renames documents. It never deletes, drops or unsets one, so a +// bad deploy is recovered by running the inverse rename (cmd/rename-rollback) +// rather than by restoring a backup. +// +// The steps are not atomic across collections — multi-document transactions +// would require a replica set, which self-hosted installs do not guarantee. +// Instead every step is safely repeatable: a collection rename is skipped when +// the source is already gone, and $rename matches nothing on a document that +// has already been renamed. A run that fails partway is fixed by running it +// again. +func MigrateOrgToInstance(ctx context.Context, db *mongo.Database) error { + names, err := db.ListCollectionNames(ctx, bson.M{}) + if err != nil { + return fmt.Errorf("list collections: %w", err) + } + exists := map[string]bool{} + for _, n := range names { + exists[n] = true + } + + // Step 1: rename the collections. + for _, r := range collectionRenames { + switch { + case !exists[r.from]: + // Nothing to rename: either already done or never existed. + continue + case exists[r.to]: + return fmt.Errorf("cannot rename %s to %s: both exist; resolve by hand", r.from, r.to) + } + cmd := bson.D{ + {Key: "renameCollection", Value: db.Name() + "." + r.from}, + {Key: "to", Value: db.Name() + "." + r.to}, + } + if err := db.Client().Database("admin").RunCommand(ctx, cmd).Err(); err != nil { + return fmt.Errorf("rename %s to %s: %w", r.from, r.to, err) + } + log.Printf("0004: renamed collection %s to %s", r.from, r.to) + } + + // Step 2: rename the field. + for _, c := range ScopedCollections { + res, err := db.Collection(c).UpdateMany(ctx, + bson.M{"org_id": bson.M{"$exists": true}}, + bson.M{"$rename": bson.M{"org_id": "instance_id"}}, + ) + if err != nil { + return fmt.Errorf("rename org_id in %s: %w", c, err) + } + if res.ModifiedCount > 0 { + log.Printf("0004: %s renamed %d document(s)", c, res.ModifiedCount) + } + } + + // Step 3: verify before anyone records a marker. Any mismatch aborts, and + // the migration is re-run rather than marked done. + for _, c := range ScopedCollections { + total, err := db.Collection(c).CountDocuments(ctx, bson.M{}) + if err != nil { + return fmt.Errorf("count %s: %w", c, err) + } + if total == 0 { + continue + } + + stale, err := db.Collection(c).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": true}}) + if err != nil { + return fmt.Errorf("count stale in %s: %w", c, err) + } + if stale != 0 { + return fmt.Errorf("%s still has %d document(s) with org_id; migration incomplete", c, stale) + } + + scoped, err := db.Collection(c).CountDocuments(ctx, bson.M{"instance_id": bson.M{"$exists": true}}) + if err != nil { + return fmt.Errorf("count scoped in %s: %w", c, err) + } + if scoped != total { + return fmt.Errorf("%s has %d document(s) but only %d carry instance_id", c, total, scoped) + } + } + + // Step 4: indexes keyed on the old field name now point at a field that no + // longer exists. Drop them; the boot-time index builders recreate the + // current ones. Dropping an index touches no documents. + for _, c := range ScopedCollections { + cur, err := db.Collection(c).Indexes().List(ctx) + if err != nil { + return fmt.Errorf("list indexes on %s: %w", c, err) + } + var specs []bson.M + if err := cur.All(ctx, &specs); err != nil { + return fmt.Errorf("decode indexes on %s: %w", c, err) + } + for _, s := range specs { + name, _ := s["name"].(string) + if name == "_id_" { + continue + } + keys, ok := s["key"].(bson.M) + if !ok { + continue + } + if _, keyed := keys["org_id"]; !keyed { + continue + } + if _, err := db.Collection(c).Indexes().DropOne(ctx, name); err != nil { + return fmt.Errorf("drop index %s on %s: %w", name, c, err) + } + log.Printf("0004: dropped stale index %s on %s", name, c) + } + } + + log.Printf("0004: verified %d collection(s)", len(ScopedCollections)) + return nil +} +``` + +Add `"log"` to the import block. + +- [ ] **Step 2: Build** + +Run: `cd server && go build ./... && go vet ./...` +Expected: no output. + +- [ ] **Step 3: Dry-run against a disposable copy** + +Do not wait for Task 8 to find out whether this works. Make a scratch copy now: + +```bash +mongodump --uri mongodb://localhost:27017 --db vantage --out /tmp/dump +mongorestore --uri mongodb://localhost:27017 --db vantage_dryrun /tmp/dump/vantage +``` + +Write a throwaway runner at `server/cmd/migratecheck/main.go`: + +```go +package main + +import ( + "context" + "log" + "time" + + "github.com/mrhid6/vantage/server/internal/services" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + c, err := mongo.Connect(options.Client().ApplyURI("mongodb://localhost:27017")) + if err != nil { + log.Fatal(err) + } + db := c.Database("vantage_dryrun") + + if err := services.MigrateOrgToInstance(ctx, db); err != nil { + log.Fatalf("migration: %v", err) + } + if err := services.AssertNoScopedCollectionMissed(ctx, db); err != nil { + log.Fatalf("assertion: %v", err) + } + log.Println("dry run complete") +} +``` + +Run: `cd server && go run ./cmd/migratecheck` + +Expected: per-collection rename counts, `0004: verified 17 collection(s)`, `dry run complete`, no error. + +- [ ] **Step 4: Confirm idempotency by hand** + +Run it a second time: `cd server && go run ./cmd/migratecheck` + +Expected: completes with no error and no rename counts (nothing left to rename). If it errors, the migration is not repeatable and a partial production run would be unrecoverable — fix before continuing. + +- [ ] **Step 5: Confirm it resumes from a partial run** + +Restore a fresh copy, then simulate a crash after the collection rename but before the field rename: + +```bash +mongorestore --drop --uri mongodb://localhost:27017 --db vantage_dryrun /tmp/dump/vantage +``` + +```javascript +db.adminCommand({renameCollection: "vantage_dryrun.orgs", to: "vantage_dryrun.instances"}) +``` + +Then: `cd server && go run ./cmd/migratecheck` + +Expected: completes cleanly and every document in `instances` carries `instance_id`. + +Confirm: + +```javascript +use vantage_dryrun +db.instances.countDocuments({instance_id: {$exists: true}}) +db.instances.countDocuments({org_id: {$exists: true}}) +``` + +Expected: the first equals the total document count; the second is `0`. + +- [ ] **Step 6: Delete the throwaway runner and commit** + +```bash +rm -rf server/cmd/migratecheck +git add server/internal/services/migrate_instance.go +git commit -m "feat(server): add migration 0004 org_id to instance_id" +``` + +Keep `/tmp/dump` — Task 8 needs it. + +--- + +### Task 4: Rename the control plane + +Mechanical, wide, and guarded by the compiler at every step. Work file by file and let `go build` drive. + +**Files:** +- Rename: `server/internal/services/orgs.go` → `instances.go` +- Rename: `server/internal/services/org_oidc.go` → `instance_oidc.go` +- Rename: `server/internal/auth/orghost.go` → `instancehost.go` +- Rename: `server/internal/models/org.go` → `instance.go` +- Modify: every file under `server/internal/` referencing a renamed symbol +- Modify: `server/cmd/main.go` + +**Interfaces:** +- Consumes: Task 1's shared API, Tasks 2–3's migration +- Produces: `services.GetInstance`, `GetInstanceBySlug`, `CreateInstance`, `ListInstanceIDs`, `CountInstances`, `FirstInstance`, `AdoptInstance`, `CountInstanceUsers`, `GetUserInInstance` — all with the same signatures as their `Org` predecessors + +- [ ] **Step 1: Rename the files** + +```bash +cd c:/Work/Repos/vantage +git mv server/internal/services/orgs.go server/internal/services/instances.go +git mv server/internal/services/org_oidc.go server/internal/services/instance_oidc.go +git mv server/internal/auth/orghost.go server/internal/auth/instancehost.go +git mv server/internal/models/org.go server/internal/models/instance.go +``` + +- [ ] **Step 2: Apply the identifier renames** + +Order matters — longest identifiers first, so shorter rules do not corrupt them. + +```bash +cd c:/Work/Repos/vantage/server +FILES=$(find . -name '*.go' -not -path './vendor/*') + +sed -i 's/CountOrgUsers/CountInstanceUsers/g' $FILES +sed -i 's/GetUserInOrg/GetUserInInstance/g' $FILES +sed -i 's/GetOrgBySlug/GetInstanceBySlug/g' $FILES +sed -i 's/ListOrgIDs/ListInstanceIDs/g' $FILES +sed -i 's/CountOrgs/CountInstances/g' $FILES +sed -i 's/FirstOrg/FirstInstance/g' $FILES +sed -i 's/AdoptOrg/AdoptInstance/g' $FILES +sed -i 's/CreateOrg/CreateInstance/g' $FILES +sed -i 's/GetOrg/GetInstance/g' $FILES +sed -i 's/OrgID/InstanceID/g' $FILES +sed -i 's/orgID/instanceID/g' $FILES +sed -i 's/models\.Org\b/models.Instance/g' $FILES +sed -i 's/"org_id"/"instance_id"/g' $FILES +sed -i 's/"orgs"/"instances"/g' $FILES +sed -i 's/"org_oidc"/"instance_oidc"/g' $FILES +``` + +- [ ] **Step 3: Restore the migration file** + +`sed` will have rewritten the migration's deliberate references to the old names, which would make it a no-op that silently does nothing. + +```bash +cd c:/Work/Repos/vantage +git checkout server/internal/services/migrate_instance.go +git diff server/internal/services/migrate_instance.go +``` + +Expected: no diff. The migration must keep reading `org_id`, `"orgs"` and `"org_oidc"` — that is its entire job. + +- [ ] **Step 4: Read the rest of the diff** + +`sed` is a blunt instrument. Run `git diff` and check for: + +- Log and error strings now reading "instance" where "organisation" was the correct customer-facing word. +- Comments that no longer parse as English. +- Any `instanceID` variable that was previously an unrelated `orgID` in a different sense. + +Fix anything that reads wrong before building. + +- [ ] **Step 5: Alias the model** + +Replace `server/internal/models/instance.go` with: + +```go +package models + +import shared "github.com/mrhid6/vantage/shared/models" + +// Instance is defined in the shared module because sitesvc and the admin +// control plane write the same documents. +type Instance = shared.Instance +``` + +- [ ] **Step 6: Build and fix what falls out** + +Run: `cd server && go build ./... 2>&1 | head -40` + +Expected initially: a list of errors. Work through them. Common ones: +- A server-only model still declaring `OrgID` — rename the field there too. +- `reservedSlugs` references in `AdoptInstance` — should already point at `provision.ReservedSlugs` from plan 0a. + +Repeat until the build is clean. + +- [ ] **Step 7: Rename the REST routes** + +In the router setup under `server/internal/api/`, change the route group path: + +```go + instanceGroup := api.Group("/instance", auth.RequireRole(models.RoleOwner, models.RoleAdmin)) + instanceGroup.GET("/users", listUsers) + instanceGroup.POST("/users", createUser) + instanceGroup.PUT("/users/:id/role", updateUserRole) + instanceGroup.DELETE("/users/:id", deleteUser) + instanceGroup.GET("/oidc", getOIDC) + instanceGroup.PUT("/oidc", putOIDC) +``` + +- [ ] **Step 8: Rename the session and /auth/me fields** + +In the session struct and the `/auth/me` handler, rename the JSON keys `org_id` to `instance_id` and `org` to `instance`. + +```bash +cd c:/Work/Repos/vantage +grep -rn '"org' server/internal/auth/ server/internal/api/ +``` + +Expected: no output. + +- [ ] **Step 9: Wire the migration and assertion into boot** + +In `server/cmd/main.go`, after the existing migrations (`MigrateMissedOrgScopes`, itself renamed by Step 2 to `MigrateMissedInstanceScopes`) and before the index builders: + +```go + migCtx, migCancel := context.WithTimeout(context.Background(), 10*time.Minute) + err = services.MigrateOrgToInstance(migCtx, db.Database) + migCancel() + if err != nil { + log.Fatalf("instance rename migration failed: %v", err) + } + + assertCtx, assertCancel := context.WithTimeout(context.Background(), 30*time.Second) + err = services.AssertNoScopedCollectionMissed(assertCtx, db.Database) + assertCancel() + if err != nil { + log.Fatalf("scoped collection check failed: %v", err) + } +``` + +Both are fatal. A half-renamed database must not serve traffic. + +- [ ] **Step 10: Build, vet, and confirm the rename is total** + +```bash +cd c:/Work/Repos/vantage/server +go build ./... && go vet ./... +cd .. +grep -rn "org_id\|OrgID\|\"orgs\"\|/api/org" server/ | grep -v migrate_instance.go +``` + +Expected: no output from any of them. + +- [ ] **Step 11: Commit** + +```bash +git add server/ +git commit -m "refactor(server): rename Org to Instance" +``` + +--- + +### Task 5: The inverse rename + +The recovery path. Deliberately a separate one-shot command rather than a migration — the only reason to run it is a decision to revert the release, which is a human decision. + +**Files:** +- Create: `server/cmd/rename-rollback/main.go` + +**Interfaces:** +- Consumes: `services.ScopedCollections` +- Produces: a binary, not an API + +- [ ] **Step 1: Write it** + +Create `server/cmd/rename-rollback/main.go`: + +```go +// Command rename-rollback reverses migration 0004. +// +// Run it only as part of a decision to revert the release that introduced the +// instance rename. It renames instance_id back to org_id and restores the two +// collection names. Like the migration, it only renames — it deletes no +// documents. +// +// rename-rollback -uri mongodb://host:27017 -db vantage -confirm +package main + +import ( + "context" + "flag" + "log" + "time" + + "github.com/mrhid6/vantage/server/internal/services" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func main() { + uri := flag.String("uri", "mongodb://localhost:27017", "MongoDB URI") + dbName := flag.String("db", "vantage", "database name") + confirm := flag.Bool("confirm", false, "required; refuses to run without it") + flag.Parse() + + if !*confirm { + log.Fatal("refusing to run without -confirm") + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + client, err := mongo.Connect(options.Client().ApplyURI(*uri)) + if err != nil { + log.Fatalf("connect: %v", err) + } + defer client.Disconnect(ctx) + + db := client.Database(*dbName) + + for _, c := range services.ScopedCollections { + res, err := db.Collection(c).UpdateMany(ctx, + bson.M{"instance_id": bson.M{"$exists": true}}, + bson.M{"$rename": bson.M{"instance_id": "org_id"}}, + ) + if err != nil { + log.Fatalf("rename instance_id in %s: %v", c, err) + } + if res.ModifiedCount > 0 { + log.Printf("%s: reverted %d document(s)", c, res.ModifiedCount) + } + } + + for _, r := range []struct{ from, to string }{ + {"instances", "orgs"}, + {"instance_oidc", "org_oidc"}, + } { + cmd := bson.D{ + {Key: "renameCollection", Value: *dbName + "." + r.from}, + {Key: "to", Value: *dbName + "." + r.to}, + } + if err := client.Database("admin").RunCommand(ctx, cmd).Err(); err != nil { + log.Printf("rename %s to %s: %v (continuing)", r.from, r.to, err) + continue + } + log.Printf("renamed collection %s to %s", r.from, r.to) + } + + // Remove the marker so a redeployed new binary re-runs the migration. + if _, err := db.Collection("migrations").DeleteOne(ctx, bson.M{"_id": "0004_org_to_instance"}); err != nil { + log.Printf("clear migration marker: %v", err) + } + + log.Println("rollback complete") +} +``` + +- [ ] **Step 2: Build and confirm the guard** + +```bash +cd c:/Work/Repos/vantage/server +go build -o /tmp/rename-rollback ./cmd/rename-rollback +/tmp/rename-rollback +``` + +Expected: exits with `refusing to run without -confirm`. + +- [ ] **Step 3: Rehearse the round trip on the dry-run database** + +The dry-run database from Task 3 is already migrated. Revert it: + +```bash +/tmp/rename-rollback -uri mongodb://localhost:27017 -db vantage_dryrun -confirm +``` + +Then in `mongosh`: + +```javascript +use vantage_dryrun +db.getCollectionNames().sort() +db.orgs.countDocuments({org_id: {$exists: true}}) +db.orgs.countDocuments({instance_id: {$exists: true}}) +``` + +Expected: `orgs` and `org_oidc` are back, the first count equals the total document count, and the second is `0`. + +- [ ] **Step 4: Commit** + +```bash +git add server/cmd/rename-rollback +git commit -m "feat(server): add rename-rollback command for migration 0004" +``` + +--- + +### Task 6: Rename sitesvc and add its boot guard + +sitesvc writes the same documents. A version skew where it writes `org_id` while the control plane reads `instance_id` creates tenants the application cannot see — so it refuses to start against an unmigrated database. + +**Files:** +- Modify: `sitesvc/internal/models/models.go`, `sitesvc/internal/store/store.go`, `sitesvc/internal/api/*.go`, `sitesvc/cmd/main.go` + +**Interfaces:** +- Consumes: Task 1's shared API +- Produces: `store.RequireMigratedDatabase(ctx context.Context) error` + +- [ ] **Step 1: Rename the pending-signup field** + +In `sitesvc/internal/models/models.go`, replace the `OrgName` field with: + +```go + InstanceName string `bson:"instance_name"` +``` + +This collection is sitesvc-private and its records live at most 24 hours, so no migration is needed — an in-flight signup written under the old field name simply expires. That has a consequence for rollout; see the Rollout section. + +- [ ] **Step 2: Apply the identifier renames** + +```bash +cd c:/Work/Repos/vantage/sitesvc +FILES=$(find . -name '*.go') + +sed -i 's/CreateOrg/CreateInstance/g' $FILES +sed -i 's/RollbackOrg/RollbackInstance/g' $FILES +sed -i 's/OrgName/InstanceName/g' $FILES +sed -i 's/orgName/instanceName/g' $FILES +sed -i 's/OrgID/InstanceID/g' $FILES +sed -i 's/sharedmodels\.Org\b/sharedmodels.Instance/g' $FILES +sed -i 's/\borg\b/instance/g' $FILES +sed -i 's/"org_id"/"instance_id"/g' $FILES +sed -i 's/"orgs"/"instances"/g' $FILES +sed -i 's/"org_name"/"instance_name"/g' $FILES +``` + +Read the diff. `\borg\b` is the aggressive rule — check it did not rename an unrelated local variable or mangle a comment. Also check the JSON request field in `internal/api`: the signup form posts `org_name`, so the marketing site's field name and the handler's binding tag must change together (Task 8 handles the site side). + +- [ ] **Step 3: Write the boot guard** + +Add to `sitesvc/internal/store/store.go`: + +```go +// RequireMigratedDatabase refuses to start against a control-plane database +// that has not run migration 0004. +// +// Provisioning into `orgs` while the control plane reads `instances` would +// create tenants nobody can see — the exact skew failure the shared module was +// built to prevent. Failing to start is strictly better. +func RequireMigratedDatabase(ctx context.Context) error { + names, err := database.ListCollectionNames(ctx, bson.M{}) + if err != nil { + return fmt.Errorf("list collections: %w", err) + } + + var hasInstances, hasOrgs bool + for _, n := range names { + switch n { + case "instances": + hasInstances = true + case "orgs": + hasOrgs = true + } + } + + // A brand-new database has neither. That is fine — whichever service starts + // first creates `instances`. + if !hasInstances && !hasOrgs { + return nil + } + if !hasInstances { + return errors.New("instances collection not found; deploy the control plane first") + } + return nil +} +``` + +- [ ] **Step 4: Call it at boot** + +In `sitesvc/cmd/main.go`, immediately after the successful `store.Connect` and before `store.EnsureIndexes`: + +```go + guardCtx, guardCancel := context.WithTimeout(context.Background(), 10*time.Second) + err := store.RequireMigratedDatabase(guardCtx) + guardCancel() + if err != nil { + log.Fatalf("database check failed: %v", err) + } +``` + +Note `main` currently uses `:=` for its first `err`; adjust to `=` or `:=` as the surrounding code requires — `go build` will tell you. + +- [ ] **Step 5: Build and confirm** + +```bash +cd c:/Work/Repos/vantage/sitesvc +go build ./... && go vet ./... +cd .. +grep -rn "org_id\|OrgID\|OrgName\|\"orgs\"\|org_name" sitesvc/ +``` + +Expected: no output. + +- [ ] **Step 6: Exercise the guard by hand** + +Against the un-migrated copy — restore one: + +```bash +mongorestore --drop --uri mongodb://localhost:27017 --db vantage_guardtest /tmp/dump/vantage +``` + +```bash +cd c:/Work/Repos/vantage/sitesvc +MONGO_URI=mongodb://localhost:27017/vantage_guardtest \ + PUBLIC_URL=http://localhost:8082 SITE_ORIGIN=http://localhost:3001 \ + SMTP_HOST=localhost SMTP_PORT=1025 SMTP_FROM=noreply@example.com \ + go run ./cmd +``` + +Expected: exits immediately with +`database check failed: instances collection not found; deploy the control plane first` + +Then migrate that database (boot the server against it) and run sitesvc again. + +Expected: starts normally and logs `sitesvc listening on :8082`. + +- [ ] **Step 7: Commit** + +```bash +git add sitesvc/ +git commit -m "refactor(sitesvc): rename Org to Instance, refuse an unmigrated database" +``` + +--- + +### Task 7: Rename the control-plane frontend + +Breaking route changes ship here, in the same release as Task 4. + +**Files:** +- Modify: `web/lib/` API client, every route under `web/app/(app)/`, `web/components/` +- Rename: `web/app/(app)/settings/org/` → `web/app/(app)/settings/instance/` + +**Interfaces:** +- Consumes: the renamed REST API from Task 4 +- Produces: no API + +- [ ] **Step 1: Find every reference and save the list** + +```bash +cd c:/Work/Repos/vantage/web +grep -rn "org_id\|orgId\|/api/org\|Organisation\|Organization\|organisation\|organization\|settings/org" \ + --include=*.ts --include=*.tsx . | tee /tmp/web-org-refs.txt +wc -l /tmp/web-org-refs.txt +``` + +Every line must be resolved by Step 5. + +- [ ] **Step 2: Rename the settings route** + +```bash +cd c:/Work/Repos/vantage +git mv "web/app/(app)/settings/org" "web/app/(app)/settings/instance" +``` + +- [ ] **Step 3: Apply the renames** + +```bash +cd c:/Work/Repos/vantage/web +FILES=$(grep -rl "org_id\|orgId\|/api/org\|Organisation\|Organization\|organisation\|organization\|settings/org" \ + --include=*.ts --include=*.tsx .) + +sed -i 's|/api/org/|/api/instance/|g' $FILES +sed -i 's/org_id/instance_id/g' $FILES +sed -i 's/orgId/instanceId/g' $FILES +sed -i 's|settings/org|settings/instance|g' $FILES +sed -i 's/Organisation/Instance/g' $FILES +sed -i 's/Organization/Instance/g' $FILES +sed -i 's/organisation/instance/g' $FILES +sed -i 's/organization/instance/g' $FILES +``` + +- [ ] **Step 4: Read every copy change** + +The copy substitutions are the ones most likely to produce nonsense. Run `git diff` and read **every changed user-facing string**. "Instance name too short" is fine; "Create your instance" is fine; a sentence that only worked with the old word needs rewriting rather than substituting. Fix them now — nobody else will. + +- [ ] **Step 5: Confirm nothing was missed** + +```bash +cd c:/Work/Repos/vantage/web +grep -rn "org_id\|orgId\|/api/org\|Organisation\|Organization\|organisation\|organization" \ + --include=*.ts --include=*.tsx . +``` + +Expected: no output. + +- [ ] **Step 6: Build** + +Run: `cd web && npm run build` +Expected: build succeeds with no type errors. + +- [ ] **Step 7: Commit** + +```bash +git add web/ +git commit -m "refactor(web): rename Organisation to Instance" +``` + +--- + +### Task 8: Marketing site copy + +The marketing site uses "organisation" for two different things, and only one becomes "Instance". Where it means the customer, it becomes **Account** — a word that now has a specific meaning in this system, and the marketing site is where a customer meets it first. + +**Files:** +- Modify: `site/app/`, `site/components/` + +- [ ] **Step 1: Find every reference** + +```bash +cd c:/Work/Repos/vantage/site +grep -rn "organisation\|Organisation\|organization\|Organization\|org_name" \ + --include=*.ts --include=*.tsx --include=*.md . +``` + +- [ ] **Step 2: Decide each one individually** + +**Do not bulk-substitute here.** For each hit, decide: + +- The thing that gets a subdomain, holds servers and carries a licence → **Instance** +- The customer who pays and may hold several → **Account** + +- [ ] **Step 3: Update the signup form** + +This is the case that matters most and the only functional change in this task. The form currently asks for an "organisation name" and posts `org_name`; that name becomes the slug, so it is creating an **Instance**. + +- Field label becomes "Instance name" +- Helper text explains it becomes the subdomain +- The posted JSON field becomes `instance_name`, matching the sitesvc handler renamed in Task 6 Step 2 + +- [ ] **Step 4: Build** + +Run: `cd site && npm run build` +Expected: succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add site/ +git commit -m "refactor(site): distinguish Instance from Account in copy" +``` + +--- + +### Task 9: Full verification against a production snapshot + +The gate. With no automated tests, this is the entire safety net. Nothing ships until every step passes against **restored production data**. + +**Files:** none + +- [ ] **Step 1: Build everything, including the agent** + +```bash +cd c:/Work/Repos/vantage +(cd shared && go build ./... && go vet ./...) +(cd server && go build ./... && go vet ./...) +(cd sitesvc && go build ./... && go vet ./...) +(cd agent && go build ./... && go vet ./...) +(cd web && npm run build) +(cd site && npm run build) +``` + +Expected: all succeed. + +- [ ] **Step 2: Restore a production snapshot and record the baseline** + +```bash +mongorestore --drop --uri mongodb://localhost:27017 --db vantage_snapshot /tmp/dump/vantage +``` + +In `mongosh`: + +```javascript +use vantage_snapshot +db.getCollectionNames().sort().forEach(function (c) { + print(c + " " + db.getCollection(c).countDocuments({})) +}) +``` + +Save the output to `/tmp/baseline.txt`. Everything below compares against it. + +- [ ] **Step 3: Record the per-tenant baseline** + +Pick three real tenants from `db.orgs.find({}, {org_id: 1})`. For each: + +```javascript +["", "", ""].forEach(function (t) { + print(t + + " servers=" + db.servers.countDocuments({org_id: t}) + + " keys=" + db.keys.countDocuments({org_id: t}) + + " workflows=" + db.workflows.countDocuments({org_id: t}) + + " monitors=" + db.monitors.countDocuments({org_id: t}) + + " secrets=" + db.secrets.countDocuments({org_id: t}) + + " audit=" + db.audit_logs.countDocuments({org_id: t})) +}) +``` + +Save to `/tmp/tenants-before.txt`. + +- [ ] **Step 4: Run the migration** + +Boot the server against the snapshot: + +```bash +cd c:/Work/Repos/vantage/server +MONGO_URI=mongodb://localhost:27017 MONGO_DB=vantage_snapshot \ + GRPC_HOST=localhost:9090 go run ./cmd +``` + +Expected in the logs: `0004: renamed collection orgs to instances`, `0004: renamed collection org_oidc to instance_oidc`, per-collection rename counts, and `0004: verified 17 collection(s)`. No fatal error, and no `scoped collection check failed`. + +- [ ] **Step 5: Compare document counts** + +Re-run the Step 2 snippet and diff against the baseline: + +```javascript +use vantage_snapshot +db.getCollectionNames().sort().forEach(function (c) { + print(c + " " + db.getCollection(c).countDocuments({})) +}) +``` + +Expected: identical counts, with exactly two names changed — `orgs` now `instances`, `org_oidc` now `instance_oidc`. **Any count difference is a stop-the-release defect.** + +- [ ] **Step 6: The tenant isolation test** + +For the same three tenants: + +```javascript +["", "", ""].forEach(function (t) { + print(t + + " servers=" + db.servers.countDocuments({instance_id: t}) + + " keys=" + db.keys.countDocuments({instance_id: t}) + + " workflows=" + db.workflows.countDocuments({instance_id: t}) + + " monitors=" + db.monitors.countDocuments({instance_id: t}) + + " secrets=" + db.secrets.countDocuments({instance_id: t}) + + " audit=" + db.audit_logs.countDocuments({instance_id: t})) +}) +``` + +Expected: byte-identical to `/tmp/tenants-before.txt` apart from the field name in the query. **This is the step that proves tenant isolation survived.** Anything else stops the release. + +- [ ] **Step 7: Confirm no document kept the old field** + +```javascript +db.getCollectionNames().forEach(function (c) { + var n = db.getCollection(c).countDocuments({org_id: {$exists: true}}); + if (n > 0) print("STALE " + c + " " + n); +}) +``` + +Expected: no output. + +- [ ] **Step 8: Application smoke test** + +Against the migrated snapshot, with `web` running: + +1. Log in as a real user. +2. Confirm the servers list, keys, workflows, monitors, secrets and audit log all populate with the expected number of rows. +3. Open a server detail page; confirm inventory and assigned keys render. +4. Open `/settings/instance`; confirm members and OIDC settings load. +5. Confirm the sidebar and page copy say "Instance", never "Organisation". +6. Create something — a monitor is cheapest — and confirm it saves and appears. + +- [ ] **Step 9: sitesvc against the migrated database** + +Boot sitesvc against `vantage_snapshot` and complete a signup end to end: form, verification email via MailHog, link, then log into the control plane with the new credentials. + +Expected: succeeds, and the new instance appears in `db.instances`. + +- [ ] **Step 10: sitesvc against an unmigrated database** + +```bash +mongorestore --drop --uri mongodb://localhost:27017 --db vantage_unmigrated /tmp/dump/vantage +``` + +Boot sitesvc against it. + +Expected: exits immediately with +`database check failed: instances collection not found; deploy the control plane first` + +- [ ] **Step 11: Rollback rehearsal** + +**Mandatory.** Do not deploy without having done this. + +```bash +mongorestore --drop --uri mongodb://localhost:27017 --db vantage_rollback /tmp/dump/vantage +``` + +Migrate it (boot the server against it), then revert: + +```bash +/tmp/rename-rollback -uri mongodb://localhost:27017 -db vantage_rollback -confirm +``` + +Then: + +1. Re-run the Step 2 count snippet against `vantage_rollback` and diff against `/tmp/baseline.txt`. Expected: identical, including the original collection names. +2. Re-run the Step 3 per-tenant snippet with `org_id`. Expected: identical to `/tmp/tenants-before.txt`. +3. Check out the **pre-release** commit, build the old server, and boot it against `vantage_rollback`. Expected: starts and serves normally. + +- [ ] **Step 12: Confirm the agent is untouched** + +```bash +cd c:/Work/Repos/vantage +git diff --name-only main -- agent/ .gitea/workflows/agent-release.yml +``` + +Expected: no output. + +- [ ] **Step 13: Clean up and commit** + +```javascript +["vantage_snapshot","vantage_dryrun","vantage_guardtest","vantage_unmigrated","vantage_rollback"] + .forEach(function (d) { db.getSiblingDB(d).dropDatabase() }) +``` + +```bash +git add -A +git commit -m "chore: verify instance rename against a production snapshot" +``` + +--- + +## Rollout + +**Order matters. Read this before deploying.** + +1. **Take a database backup.** Not optional. +2. Confirm the Task 9 rollback rehearsal was actually performed, not just read. +3. Deploy `server`, `web`, `site` and `sitesvc` from **one commit, together**. +4. The server boots first, runs migration `0004`, records the marker. +5. sitesvc may start before the server and exit with the guard message. It restarts and succeeds once the migration has run. **This is expected, not an incident.** + +```bash +cd /opt/vantage && \ + docker compose -f docker-compose.yml -f docker-compose.site.yml pull && \ + docker compose -f docker-compose.yml -f docker-compose.site.yml up -d --remove-orphans +``` + +Expect a short API outage during the server restart while the migration runs. Agents are unaffected — they reconnect, and no gRPC message carries a tenant ID. + +**In-flight signups are lost.** Any unverified signup recorded before the deploy carries `org_name` and will fail verification. There are at most 24 hours' worth. Either accept it, or wait for the collection to drain: + +```javascript +db.site_pending_signups.countDocuments({}) +``` + +Post-deploy checks: + +1. Control-plane login works and the fleet dashboard populates. +2. A fresh signup completes end to end. +3. Server logs show `0004: verified 17 collection(s)` and no scoped-collection failure. +4. `db.servers.countDocuments({status: "active"})` matches the pre-deploy figure — agents are still reporting. + +**If something is wrong:** deploy the previous images, run `rename-rollback -confirm`, and confirm the old binaries boot. Do not attempt a partial fix against a live half-renamed database. + +## What this unblocks + +Plan 1 (`licensing-core`) can define a licence payload bound to `instance_id` +without inventing a word the codebase does not use. diff --git a/docs/superpowers/plans/2026-07-24-shared-module.md b/docs/superpowers/plans/2026-07-24-shared-module.md new file mode 100644 index 0000000..1e73b02 --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-shared-module.md @@ -0,0 +1,1341 @@ +# Shared Module Extraction Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extract a `shared` Go module holding the document shapes and provisioning rules that `server` and `sitesvc` both write, deleting the hand-copied duplicates in sitesvc. + +**Architecture:** A new Go module `github.com/mrhid6/vantage/shared` containing `models`, `provision` and `indexes` packages. `server` and `sitesvc` consume it via `replace` directives plus a root `go.work`. Functions take a `*mongo.Database` handle from the caller so `shared` never owns a connection. Docker build contexts move to the repo root so the `replace` paths resolve. + +**Tech Stack:** Go 1.26, MongoDB driver v2, `golang.org/x/crypto/bcrypt`, `github.com/google/uuid`, Docker, Gitea Actions. + +**No automated tests.** Verification is by compiler, `grep`, and running the two services end to end against a scratch database. Every task ends with checks that produce observable output. + +## Global Constraints + +- Go version in every `go.mod`: `go 1.26` +- Module path: `github.com/mrhid6/vantage/shared` +- `shared/go.mod` may require **only** these three: `go.mongodb.org/mongo-driver/v2`, `golang.org/x/crypto`, `github.com/google/uuid`. Any addition needs review — this constraint is what keeps sitesvc small. +- `MinSlugLength = 3`, `MaxSlugLength = 40`, `BcryptCost = 12` — exact values, no literals elsewhere. +- Reserved slugs, exactly: `www`, `api`, `app`, `admin`, `auth`, `install`, `static`, `_next`, `default` +- User-facing error text uses **"organisation"** (British spelling) everywhere. The control plane currently says "organization"; that changes in this plan. +- **No renaming.** `Org`, `OrgID`, `org_id`, `orgs` stay exactly as they are. Renaming is plan 0b. +- **No behaviour changes** other than the two explicitly listed in Task 3. +- The `agent` module is not touched by any task in this plan. +- A local MongoDB is needed for the end-to-end checks: `docker run -d -p 27017:27017 --name vantage-dev-mongo mongo:7` + +--- + +## File Structure + +**Created:** + +| Path | Responsibility | +|---|---| +| `go.work` | Workspace over shared, server, sitesvc | +| `shared/go.mod`, `shared/go.sum` | Module definition | +| `shared/models/org.go` | `Org` document | +| `shared/models/user.go` | `User` document, role constants, `ValidRole` | +| `shared/models/settings.go` | `Settings` and sub-structs | +| `shared/provision/slug.go` | `Slugify`, `BaseSlug`, `NextSlug`, `ReservedSlugs`, length constants | +| `shared/provision/org.go` | `CreateOrg`, `RollbackOrg` | +| `shared/provision/user.go` | `CreateUser`, `CreateUserWithHash`, `BcryptCost` | +| `shared/indexes/indexes.go` | `EnsureCoreIndexes` | + +**Modified:** + +| Path | Change | +|---|---| +| `server/go.mod` | require + replace shared | +| `server/internal/models/org.go`, `user.go`, `settings.go` | Replaced by type aliases to shared | +| `server/internal/services/orgs.go` | `CreateOrg` delegates to shared | +| `server/internal/services/users.go` | `CreateUser` delegates to shared | +| `server/internal/services/migrate.go` | `EnsureAuthIndexes` delegates to shared | +| `server/Dockerfile` | Build from repo root | +| `sitesvc/go.mod` | require + replace shared | +| `sitesvc/internal/models/models.go` | Reduced to `PendingSignup` | +| `sitesvc/internal/store/store.go` | Uses shared provision | +| `sitesvc/Dockerfile` | Build from repo root | +| `.gitea/workflows/server-deploy.yml` | Root context for the two Go images | + +**Deleted:** + +| Path | +|---| +| `sitesvc/internal/provision/provision.go` | + +--- + +### Task 1: Scaffold the shared module + +**Files:** +- Create: `shared/go.mod` +- Create: `go.work` + +**Interfaces:** +- Consumes: nothing +- Produces: an importable but empty module + +- [ ] **Step 1: Create the module** + +```bash +cd c:/Work/Repos/vantage +mkdir -p shared +cd shared +go mod init github.com/mrhid6/vantage/shared +go get go.mongodb.org/mongo-driver/v2@latest +go get golang.org/x/crypto@latest +go get github.com/google/uuid@latest +``` + +- [ ] **Step 2: Create the workspace** + +Create `go.work` at the repo root: + +``` +go 1.26 + +use ( + ./shared + ./server + ./sitesvc +) +``` + +`./agent` is deliberately absent. The agent stays standalone with its own release pipeline. + +- [ ] **Step 3: Verify the workspace resolves** + +Run: `cd c:/Work/Repos/vantage && go work sync && go list -m all | head -5` +Expected: output includes `github.com/mrhid6/vantage/shared`, `github.com/mrhid6/vantage/server` and `github.com/mrhid6/vantage/sitesvc`. No error. + +- [ ] **Step 4: Commit** + +```bash +git add go.work shared/go.mod shared/go.sum +git commit -m "chore: scaffold shared module" +``` + +--- + +### Task 2: Slug rules and models + +Pure definitions with no database access. Grouped into one task because neither is independently reviewable — a struct with no consumer and a regex with no caller are the same review. + +**Files:** +- Create: `shared/provision/slug.go` +- Create: `shared/models/org.go` +- Create: `shared/models/user.go` +- Create: `shared/models/settings.go` + +**Interfaces:** +- Consumes: nothing +- Produces: + - `const MinSlugLength = 3`, `MaxSlugLength = 40` + - `var ReservedSlugs map[string]bool` + - `func Slugify(name string) string` + - `func BaseSlug(name string) (string, error)` + - `func NextSlug(base string, attempt int) string` — attempt 1 returns base, attempt 2 returns `base-2` + - `models.Org`, `models.User`, `models.Settings`, `models.AlertSettings`, `models.EmailSettings`, `models.SecretsSettings` + - `models.RoleOwner`/`RoleAdmin`/`RoleMember`, `models.ValidRole(string) bool` + +- [ ] **Step 1: Write the slug rules** + +Create `shared/provision/slug.go`: + +```go +// Package provision holds the tenant creation rules shared by the control +// plane and sitesvc. +// +// These rules used to be duplicated: the control plane owned one copy and +// sitesvc mirrored it by hand. The copies had already drifted — sitesvc retried +// on a lost slug race while the control plane returned an error. This package +// is the single definition; neither service may reimplement any of it. +package provision + +import ( + "fmt" + "regexp" + "strings" +) + +const ( + MinSlugLength = 3 + MaxSlugLength = 40 +) + +var slugStrip = regexp.MustCompile(`[^a-z0-9]+`) + +// ReservedSlugs are subdomain labels the platform needs for itself. +var ReservedSlugs = map[string]bool{ + "www": true, "api": true, "app": true, "admin": true, "auth": true, + "install": true, "static": true, "_next": true, "default": true, +} + +// Slugify lowercases a name and collapses every run of non-alphanumeric +// characters into a single hyphen, trimming hyphens from both ends. +func Slugify(name string) string { + s := strings.ToLower(name) + s = slugStrip.ReplaceAllString(s, "-") + return strings.Trim(s, "-") +} + +// BaseSlug turns a name into a validated slug stem, or explains why it cannot. +func BaseSlug(name string) (string, error) { + base := Slugify(name) + if len(base) < MinSlugLength { + return "", fmt.Errorf("organisation name too short (slug must be at least %d characters)", MinSlugLength) + } + if len(base) > MaxSlugLength { + base = base[:MaxSlugLength] + } + if ReservedSlugs[base] { + return "", fmt.Errorf("that organisation name is reserved") + } + return base, nil +} + +// NextSlug returns the candidate slug for a given attempt. Attempt 1 is the +// base itself; later attempts append a counter. +func NextSlug(base string, attempt int) string { + if attempt < 2 { + return base + } + return fmt.Sprintf("%s-%d", base, attempt) +} +``` + +- [ ] **Step 2: Create the Org model** + +Create `shared/models/org.go`: + +```go +// Package models holds the MongoDB documents written by more than one Vantage +// service. Documents only the control plane touches stay in +// server/internal/models. +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +type Org struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + OrgID string `bson:"org_id" json:"org_id"` + Name string `bson:"name" json:"name"` + Slug string `bson:"slug" json:"slug"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} +``` + +- [ ] **Step 3: Create the User model** + +Create `shared/models/user.go`: + +```go +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +const ( + RoleOwner = "owner" + RoleAdmin = "admin" + RoleMember = "member" +) + +func ValidRole(role string) bool { + switch role { + case RoleOwner, RoleAdmin, RoleMember: + return true + } + return false +} + +type User struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + UserID string `bson:"user_id" json:"user_id"` + OrgID string `bson:"org_id" json:"org_id"` + Email string `bson:"email" json:"email"` + PasswordHash string `bson:"password_hash,omitempty" json:"-"` + Role string `bson:"role" json:"role"` + AuthSource string `bson:"auth_source" json:"auth_source"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + LastLogin *time.Time `bson:"last_login,omitempty" json:"last_login,omitempty"` +} +``` + +- [ ] **Step 4: Create the Settings model** + +Create `shared/models/settings.go`: + +```go +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +type AlertSettings struct { + Enabled bool `bson:"enabled" json:"enabled"` + WebhookURL string `bson:"webhook_url" json:"webhook_url"` + OfflineThresholdMinutes int `bson:"offline_threshold_minutes" json:"offline_threshold_minutes"` +} + +type EmailSettings struct { + Enabled bool `bson:"enabled" json:"enabled"` + SMTPHost string `bson:"smtp_host" json:"smtp_host"` + SMTPPort int `bson:"smtp_port" json:"smtp_port"` + Username string `bson:"username" json:"username"` + Password string `bson:"password" json:"password"` + FromAddr string `bson:"from_addr" json:"from_addr"` + ToAddrs []string `bson:"to_addrs" json:"to_addrs"` + UseTLS bool `bson:"use_tls" json:"use_tls"` +} + +type SecretsSettings struct { + ReadTokenHash string `bson:"read_token_hash,omitempty" json:"-"` + ReadTokenSet bool `bson:"-" json:"read_token_set"` + RotatedAt time.Time `bson:"rotated_at,omitempty" json:"rotated_at,omitempty"` +} + +type Settings struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + OrgID string `bson:"org_id" json:"org_id"` + Alerts AlertSettings `bson:"alerts" json:"alerts"` + Email EmailSettings `bson:"email" json:"email"` + Secrets SecretsSettings `bson:"secrets" json:"secrets"` + + WorkflowLogRetentionDays *int `bson:"workflow_log_retention_days,omitempty" json:"workflow_log_retention_days,omitempty"` +} +``` + +- [ ] **Step 5: Verify the bson tags match production exactly** + +Without tests, this diff is the only thing standing between you and orphaned production data. A changed bson tag means every existing document silently stops matching. + +```bash +cd c:/Work/Repos/vantage +diff <(grep -o 'bson:"[^"]*"' server/internal/models/org.go) \ + <(grep -o 'bson:"[^"]*"' shared/models/org.go) +diff <(grep -o 'bson:"[^"]*"' server/internal/models/user.go) \ + <(grep -o 'bson:"[^"]*"' shared/models/user.go) +diff <(grep -o 'bson:"[^"]*"' server/internal/models/settings.go) \ + <(grep -o 'bson:"[^"]*"' shared/models/settings.go) +``` + +Expected: **no output from any of the three.** Any output is a defect — stop and fix it before continuing. + +- [ ] **Step 6: Sanity-check the slug rules by hand** + +Create a throwaway `shared/cmd/slugcheck/main.go`: + +```go +package main + +import ( + "fmt" + + "github.com/mrhid6/vantage/shared/provision" +) + +func main() { + for _, n := range []string{"Acme", "Acme Corp", "ACME CORP", "Acme & Co.", " Acme ", "---Acme---", "!!!", "ab", "admin"} { + base, err := provision.BaseSlug(n) + fmt.Printf("%-14q slugify=%-12q base=%-12q err=%v\n", n, provision.Slugify(n), base, err) + } + fmt.Println(provision.NextSlug("acme", 1), provision.NextSlug("acme", 2), provision.NextSlug("acme", 3)) +} +``` + +Run: `cd shared && go run ./cmd/slugcheck` + +Expected output: + +``` +"Acme" slugify="acme" base="acme" err= +"Acme Corp" slugify="acme-corp" base="acme-corp" err= +"ACME CORP" slugify="acme-corp" base="acme-corp" err= +"Acme & Co." slugify="acme-co" base="acme-co" err= +" Acme " slugify="acme" base="acme" err= +"---Acme---" slugify="acme" base="acme" err= +"!!!" slugify="" base="" err=organisation name too short (slug must be at least 3 characters) +"ab" slugify="ab" base="" err=organisation name too short (slug must be at least 3 characters) +"admin" slugify="admin" base="" err=that organisation name is reserved +acme acme-2 acme-3 +``` + +Then delete it: `rm -rf shared/cmd/slugcheck` + +- [ ] **Step 7: Build and commit** + +```bash +cd c:/Work/Repos/vantage/shared && go build ./... && go vet ./... +cd .. +git add shared/provision/slug.go shared/models +git commit -m "feat(shared): add slug rules and shared document models" +``` + +--- + +### Task 3: Provisioning + +The single `CreateOrg`, `RollbackOrg` and `CreateUser`. **Two deliberate behaviour changes**, both adopting sitesvc's version because it is the correct one: + +1. On a duplicate-key race the slug loop **retries the next slug** instead of returning an error. The control plane previously failed the request. +2. Error text uses "organisation", not "organization". + +**Files:** +- Create: `shared/provision/org.go` +- Create: `shared/provision/user.go` + +**Interfaces:** +- Consumes: `models.Org`, `models.User`, `models.ValidRole`, `provision.BaseSlug`, `provision.NextSlug` +- Produces: + - `var ErrNameRejected error`, `var ErrEmailTaken error` + - `const BcryptCost = 12` + - `func CreateOrg(ctx context.Context, db *mongo.Database, name string) (*models.Org, error)` + - `func RollbackOrg(ctx context.Context, db *mongo.Database, orgID string) error` + - `func CreateUser(ctx context.Context, db *mongo.Database, orgID, email, password, role, authSource string) (*models.User, error)` + - `func CreateUserWithHash(ctx context.Context, db *mongo.Database, orgID, email, passwordHash, role, authSource string) (*models.User, error)` + +`CreateUserWithHash` exists because sitesvc hashes the password at signup time and stores the hash in the pending record; by verification time it holds a hash, not a password. Without it sitesvc would have to insert the document by hand, which is the duplication this plan removes. + +- [ ] **Step 1: Write org provisioning** + +Create `shared/provision/org.go`: + +```go +package provision + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/shared/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +// ErrNameRejected wraps every reason a name cannot become an organisation. +var ErrNameRejected = errors.New("organisation name rejected") + +const maxSlugAttempts = 50 + +// CreateOrg inserts an organisation under the first free slug derived from name. +// +// The count-then-insert loop is racy on its own. It is safe only because +// orgs.slug carries a unique index: a lost race surfaces as a duplicate-key +// error, which we treat as "that slug is taken" and retry. Do not remove the +// duplicate-key branch, and do not remove the index. +func CreateOrg(ctx context.Context, db *mongo.Database, name string) (*models.Org, error) { + base, err := BaseSlug(name) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrNameRejected, err.Error()) + } + + for attempt := 1; attempt <= maxSlugAttempts; attempt++ { + slug := NextSlug(base, attempt) + + n, err := db.Collection("orgs").CountDocuments(ctx, bson.M{"slug": slug}) + if err != nil { + return nil, err + } + if n > 0 { + continue + } + + org := models.Org{ + OrgID: uuid.NewString(), + Name: name, + Slug: slug, + CreatedAt: time.Now().UTC(), + } + if _, err := db.Collection("orgs").InsertOne(ctx, org); err != nil { + if mongo.IsDuplicateKeyError(err) { + continue // lost the race; try the next slug + } + return nil, err + } + return &org, nil + } + return nil, fmt.Errorf("%w: could not find a free slug for %q", ErrNameRejected, name) +} + +// RollbackOrg deletes an organisation that has no users. +// +// It refuses an organisation that has users. Rollback exists to clean up a +// half-finished signup, and an organisation with users is not half-finished. +func RollbackOrg(ctx context.Context, db *mongo.Database, orgID string) error { + n, err := db.Collection("users").CountDocuments(ctx, bson.M{"org_id": orgID}) + if err != nil { + return err + } + if n > 0 { + return fmt.Errorf("refusing to roll back organisation %s: it has %d user(s)", orgID, n) + } + _, err = db.Collection("orgs").DeleteOne(ctx, bson.M{"org_id": orgID}) + return err +} +``` + +- [ ] **Step 2: Write user provisioning** + +Create `shared/provision/user.go`: + +```go +package provision + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/shared/models" + "go.mongodb.org/mongo-driver/v2/mongo" + "golang.org/x/crypto/bcrypt" +) + +// BcryptCost is the work factor for every password hash Vantage writes. +// Changing it changes nothing about existing hashes, which carry their own cost. +const BcryptCost = 12 + +// ErrEmailTaken is returned when the unique index on users.email rejects an insert. +var ErrEmailTaken = errors.New("email already registered") + +// CreateUser hashes password and inserts the user. An empty password leaves the +// hash empty, which is how OIDC users are stored. +func CreateUser(ctx context.Context, db *mongo.Database, orgID, email, password, role, authSource string) (*models.User, error) { + var hash string + if password != "" { + b, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost) + if err != nil { + return nil, err + } + hash = string(b) + } + return CreateUserWithHash(ctx, db, orgID, email, hash, role, authSource) +} + +// CreateUserWithHash inserts a user whose password was already hashed +// elsewhere. sitesvc hashes at signup and only holds the hash by the time the +// verification link is opened. +func CreateUserWithHash(ctx context.Context, db *mongo.Database, orgID, email, passwordHash, role, authSource string) (*models.User, error) { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return nil, fmt.Errorf("email required") + } + if !models.ValidRole(role) { + return nil, fmt.Errorf("invalid role %q", role) + } + + u := &models.User{ + UserID: uuid.NewString(), + OrgID: orgID, + Email: email, + PasswordHash: passwordHash, + Role: role, + AuthSource: authSource, + CreatedAt: time.Now().UTC(), + } + if _, err := db.Collection("users").InsertOne(ctx, u); err != nil { + if mongo.IsDuplicateKeyError(err) { + return nil, ErrEmailTaken + } + return nil, err + } + return u, nil +} +``` + +- [ ] **Step 3: Build** + +Run: `cd shared && go build ./... && go vet ./...` +Expected: no output. + +- [ ] **Step 4: Commit** + +```bash +git add shared/provision/org.go shared/provision/user.go +git commit -m "feat(shared): add CreateOrg, RollbackOrg and CreateUser + +Adopts sitesvc's retry-on-duplicate-key slug loop. The control plane +previously returned an error when it lost the slug race." +``` + +--- + +### Task 4: Core indexes + +`users.email` and `orgs.slug` unique indexes are a tenant-isolation property, not an optimisation — `GetUserByEmail` does an unscoped `FindOne`, so a duplicate email would let the OIDC cross-org guard compare against an arbitrary user. Both services declare them, and both treat failure as fatal. + +**Files:** +- Create: `shared/indexes/indexes.go` + +**Interfaces:** +- Consumes: nothing +- Produces: `func EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error` + +- [ ] **Step 1: Write it** + +Create `shared/indexes/indexes.go`: + +```go +// Package indexes declares the MongoDB indexes more than one Vantage service +// depends on. +package indexes + +import ( + "context" + "fmt" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// EnsureCoreIndexes declares the unique indexes on users.email and orgs.slug. +// +// These are a security property, not an optimisation. GetUserByEmail does an +// unscoped FindOne, so a duplicate email would let the OIDC cross-org guard +// compare against an arbitrary user. Every caller must treat a failure here as +// fatal. +// +// Creating an index that already exists with the same specification is a no-op, +// so this is safe to call at every boot from every service. +func EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error { + if _, err := db.Collection("users").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "email", Value: 1}}, + Options: options.Index().SetUnique(true), + }); err != nil { + return fmt.Errorf("users.email index: %w", err) + } + + if _, err := db.Collection("orgs").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "slug", Value: 1}}, + Options: options.Index().SetUnique(true), + }); err != nil { + return fmt.Errorf("orgs.slug index: %w", err) + } + + return nil +} +``` + +- [ ] **Step 2: Build and confirm the dependency list is still clean** + +```bash +cd c:/Work/Repos/vantage/shared +go build ./... && go vet ./... +go list -m all | grep -Ev "^github.com/mrhid6/vantage/shared$|mongo-driver|golang.org/x|github.com/google/uuid|github.com/golang/snappy|github.com/klauspost|github.com/xdg-go|github.com/youmark|go.mongodb.org" +``` + +Expected: no output from the build, and no unexpected module from the list. Gin, redis or guac appearing means something was moved into `shared` that should not have been. + +- [ ] **Step 3: Commit** + +```bash +git add shared/indexes +git commit -m "feat(shared): add EnsureCoreIndexes" +``` + +--- + +### Task 5: Wire the control plane to shared + +Server keeps its own package paths so no call site outside these files changes. `server/internal/models` re-exports the shared types as aliases — a type alias is identical to the aliased type, so `models.Org` in existing server code keeps working untouched. + +**Files:** +- Modify: `server/go.mod` +- Modify: `server/internal/models/org.go`, `user.go`, `settings.go` +- Modify: `server/internal/services/orgs.go` (`CreateOrg`, `AdoptOrg`, delete `reservedSlugs`) +- Modify: `server/internal/services/users.go` (`CreateUser`) +- Modify: `server/internal/services/migrate.go` (`EnsureAuthIndexes`) + +**Interfaces:** +- Consumes: everything from Tasks 2–4 +- Produces: no new exported API. `services.CreateOrg(name string) (*models.Org, error)` and `services.CreateUser(orgID, email, password, role, authSource string) (*models.User, error)` keep their exact signatures. + +- [ ] **Step 1: Add the dependency** + +```bash +cd c:/Work/Repos/vantage/server +go mod edit -require=github.com/mrhid6/vantage/shared@v0.0.0 +go mod edit -replace=github.com/mrhid6/vantage/shared=../shared +go mod tidy +``` + +- [ ] **Step 2: Alias the models** + +Replace the entire contents of `server/internal/models/org.go` with: + +```go +package models + +import shared "github.com/mrhid6/vantage/shared/models" + +// Org is defined in the shared module because sitesvc writes the same +// documents. Aliased rather than re-declared so existing call sites are +// unchanged and the two services cannot drift. +type Org = shared.Org +``` + +Replace the entire contents of `server/internal/models/user.go` with: + +```go +package models + +import shared "github.com/mrhid6/vantage/shared/models" + +type User = shared.User + +const ( + RoleOwner = shared.RoleOwner + RoleAdmin = shared.RoleAdmin + RoleMember = shared.RoleMember +) + +func ValidRole(role string) bool { return shared.ValidRole(role) } +``` + +Replace the entire contents of `server/internal/models/settings.go` with: + +```go +package models + +import shared "github.com/mrhid6/vantage/shared/models" + +type ( + Settings = shared.Settings + AlertSettings = shared.AlertSettings + EmailSettings = shared.EmailSettings + SecretsSettings = shared.SecretsSettings +) +``` + +- [ ] **Step 3: Verify the build still passes** + +Run: `cd server && go build ./...` +Expected: no output. If anything fails, an alias is missing — add it rather than reverting. + +- [ ] **Step 4: Delegate CreateOrg** + +In `server/internal/services/orgs.go`, delete the `reservedSlugs` package variable and replace the whole `CreateOrg` function with: + +```go +// CreateOrg creates an organisation and seeds its default workflow steps. +// +// The creation rules live in shared/provision because sitesvc creates +// organisations too. Seeding stays here: shared must not know about workflow +// steps. +func CreateOrg(name string) (*models.Org, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + o, err := provision.CreateOrg(ctx, db.Database, name) + if err != nil { + return nil, err + } + + if created, updated, err := SeedDefaultSteps(o.OrgID); err != nil { + log.Printf("warning: failed to seed default steps for new org %s: %v", o.OrgID, err) + } else { + log.Printf("default steps seeded for new org %s: %d created, %d updated", o.OrgID, created, updated) + } + return o, nil +} +``` + +`AdoptOrg` also references `reservedSlugs` and `Slugify` and hard-codes the lengths `3` and `40`. Change its references to `provision.ReservedSlugs`, `provision.Slugify`, `provision.MinSlugLength` and `provision.MaxSlugLength`. + +Add to the import block: + +```go + "github.com/mrhid6/vantage/shared/provision" +``` + +Remove `"github.com/google/uuid"` and `"go.mongodb.org/mongo-driver/v2/mongo"` **only if** nothing else in the file still uses them — `go build` will tell you. + +- [ ] **Step 5: Delegate CreateUser** + +In `server/internal/services/users.go`, replace the whole `CreateUser` function with: + +```go +func CreateUser(orgID, email, password, role, authSource string) (*models.User, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + u, err := provision.CreateUser(ctx, db.Database, orgID, email, password, role, authSource) + if errors.Is(err, provision.ErrEmailTaken) { + return nil, fmt.Errorf("email already registered") + } + return u, err +} +``` + +The `ErrEmailTaken` translation preserves the exact error string the API returns today. Add `"github.com/mrhid6/vantage/shared/provision"` to the imports. + +- [ ] **Step 6: Delegate EnsureAuthIndexes** + +In `server/internal/services/migrate.go`, replace the body of `EnsureAuthIndexes` with: + +```go +func EnsureAuthIndexes() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return indexes.EnsureCoreIndexes(ctx, db.Database) +} +``` + +Add `"github.com/mrhid6/vantage/shared/indexes"` to the imports. + +- [ ] **Step 7: Build and vet** + +```bash +cd c:/Work/Repos/vantage/server +go build ./... && go vet ./... +``` + +Expected: no output from either. + +- [ ] **Step 8: Confirm no duplicated rules remain in the server** + +```bash +cd c:/Work/Repos/vantage +grep -rn "reservedSlugs\|bcrypt.GenerateFromPassword\|func Slugify" server/internal/ +``` + +Expected: no hits in `services/orgs.go` or `services/users.go`. Hits elsewhere — a password-change handler, for example — are fine **only** if they use `provision.BcryptCost` rather than a literal `12`. Fix any that do not. + +- [ ] **Step 9: Commit** + +```bash +git add server/go.mod server/go.sum server/internal/models server/internal/services +git commit -m "refactor(server): use shared models, provision and indexes" +``` + +--- + +### Task 6: Wire sitesvc to shared and delete the duplicates + +**Files:** +- Modify: `sitesvc/go.mod` +- Modify: `sitesvc/internal/models/models.go` +- Modify: `sitesvc/internal/store/store.go` +- Delete: `sitesvc/internal/provision/provision.go` + +**Interfaces:** +- Consumes: everything from Tasks 2–4 +- Produces: `store.Verify(ctx, rawToken) (*sharedmodels.Org, error)`. `store.CreatePending`, `store.EmailTaken`, `store.Connect`, `store.EnsureIndexes` keep their signatures. + +- [ ] **Step 1: Add the dependency** + +```bash +cd c:/Work/Repos/vantage/sitesvc +go mod edit -require=github.com/mrhid6/vantage/shared@v0.0.0 +go mod edit -replace=github.com/mrhid6/vantage/shared=../shared +go mod tidy +``` + +- [ ] **Step 2: Reduce the models file** + +Replace the entire contents of `sitesvc/internal/models/models.go` with: + +```go +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// PendingSignup lives here rather than in the shared module because only +// sitesvc writes site_pending_signups. The control plane does not know the +// collection exists. +// +// Org and User used to be mirrored here by hand. They now come from +// github.com/mrhid6/vantage/shared/models, which is the only copy. +type PendingSignup struct { + ID bson.ObjectID `bson:"_id,omitempty"` + PendingID string `bson:"pending_id"` + OrgName string `bson:"org_name"` + Email string `bson:"email"` + PasswordHash string `bson:"password_hash"` + TokenHash string `bson:"token_hash"` + CreatedAt time.Time `bson:"created_at"` + ExpiresAt time.Time `bson:"expires_at"` +} +``` + +- [ ] **Step 3: Delete the duplicated rules** + +```bash +cd c:/Work/Repos/vantage +rm -rf sitesvc/internal/provision +``` + +- [ ] **Step 4: Rewrite the store's provisioning paths** + +In `sitesvc/internal/store/store.go`: + +Replace the two local imports + +```go + "github.com/mrhid6/vantage/sitesvc/internal/models" + "github.com/mrhid6/vantage/sitesvc/internal/provision" +``` + +with + +```go + "github.com/mrhid6/vantage/shared/indexes" + sharedmodels "github.com/mrhid6/vantage/shared/models" + "github.com/mrhid6/vantage/shared/provision" + "github.com/mrhid6/vantage/sitesvc/internal/models" +``` + +Replace the error variables so `errors.Is` keeps working for existing callers in `internal/api`: + +```go +var ( + ErrEmailTaken = provision.ErrEmailTaken + ErrBadToken = errors.New("verification link is invalid or has expired") + ErrNameRejected = provision.ErrNameRejected +) +``` + +Replace `EnsureIndexes` with: + +```go +func EnsureIndexes() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // users.email and orgs.slug are declared in the shared module so both + // services agree. Re-declaring at boot means sitesvc does not depend on the + // control plane having started first. + if err := indexes.EnsureCoreIndexes(ctx, database); err != nil { + return err + } + + _, err := col("site_pending_signups").Indexes().CreateMany(ctx, []mongo.IndexModel{ + { + Keys: bson.D{{Key: "token_hash", Value: 1}}, + Options: options.Index().SetUnique(true), + }, + {Keys: bson.D{{Key: "email", Value: 1}}}, + { + Keys: bson.D{{Key: "expires_at", Value: 1}}, + Options: options.Index().SetExpireAfterSeconds(0), + }, + }) + if err != nil { + return fmt.Errorf("pending signup indexes: %w", err) + } + return nil +} +``` + +Delete the local `createOrg` and `rollbackOrg` functions entirely. + +Replace `Verify` with: + +```go +func Verify(ctx context.Context, rawToken string) (*sharedmodels.Org, error) { + var pending models.PendingSignup + err := col("site_pending_signups").FindOneAndDelete(ctx, bson.M{ + "token_hash": hashToken(rawToken), + "expires_at": bson.M{"$gt": time.Now().UTC()}, + }).Decode(&pending) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, ErrBadToken + } + if err != nil { + return nil, err + } + + org, err := provision.CreateOrg(ctx, database, pending.OrgName) + if err != nil { + return nil, err + } + + // The password was hashed when the signup was recorded; only the hash + // survives to this point. + _, err = provision.CreateUserWithHash(ctx, database, org.OrgID, pending.Email, + pending.PasswordHash, sharedmodels.RoleOwner, "local") + if err != nil { + // Leaving an org behind would permanently occupy a slug nobody owns. + if rbErr := provision.RollbackOrg(ctx, database, org.OrgID); rbErr != nil { + log.Printf("verify: failed to roll back org %s: %v", org.OrgID, rbErr) + } + return nil, err + } + + return org, nil +} +``` + +`CreatePending` already calls `provision.BaseSlug` and `provision.BcryptCost`; those now resolve to the shared package with no line changes beyond the import swap. + +- [ ] **Step 5: Build and vet** + +```bash +cd c:/Work/Repos/vantage/sitesvc +go build ./... && go vet ./... +``` + +Expected: no output. If `internal/api` fails because `Verify` now returns a different `*models.Org`, update its import to `sharedmodels` there too. + +- [ ] **Step 6: Confirm the duplication is gone** + +```bash +cd c:/Work/Repos/vantage +grep -rn "func Slugify\|ReservedSlugs =\|BcryptCost =\|bson:\"org_id\"" sitesvc/ +``` + +Expected: no output. Any `provision.`-qualified *references* are fine; what must be gone are local *definitions*. + +- [ ] **Step 7: Commit** + +```bash +git add sitesvc/ +git commit -m "refactor(sitesvc): use shared models and provision + +Deletes internal/provision and the hand-mirrored Org and User structs. The +control plane and sitesvc now share one definition of both." +``` + +--- + +### Task 7: Docker and CI + +`replace => ../shared` cannot resolve when the build context is the module directory. Both Go images build from the repo root instead. + +**Files:** +- Modify: `server/Dockerfile`, `sitesvc/Dockerfile` +- Modify: `.gitea/workflows/server-deploy.yml` + +**Interfaces:** +- Consumes: the module layout from Tasks 1–6 +- Produces: images identical in content to today's, built from a different context + +- [ ] **Step 1: Rewrite the server Dockerfile build stage** + +Replace the build stage of `server/Dockerfile` with: + +```dockerfile +# Build stage +# +# Context is the repository root, not server/, because server depends on the +# shared module through a replace directive. +FROM golang:1.26 AS builder + +WORKDIR /src + +# Manifests first so the dependency layer caches independently of source edits. +COPY shared/go.mod shared/go.sum ./shared/ +COPY server/go.mod server/go.sum ./server/ +RUN cd server && go mod download + +COPY shared/ ./shared/ +COPY server/ ./server/ + +ARG VERSION=dev +RUN cd server && CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd +``` + +Leave the runtime stage exactly as it is. + +- [ ] **Step 2: Rewrite the sitesvc Dockerfile build stage** + +Replace the build stage of `sitesvc/Dockerfile` with: + +```dockerfile +# Context is the repository root; sitesvc depends on the shared module. +FROM golang:1.26-alpine AS builder + +WORKDIR /src + +COPY shared/go.mod shared/go.sum ./shared/ +COPY sitesvc/go.mod sitesvc/go.sum ./sitesvc/ +RUN cd sitesvc && go mod download + +COPY shared/ ./shared/ +COPY sitesvc/ ./sitesvc/ + +RUN cd sitesvc && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/sitesvc ./cmd +``` + +Leave the runtime stage exactly as it is. + +- [ ] **Step 3: Build both images locally** + +```bash +cd c:/Work/Repos/vantage +docker build -f server/Dockerfile -t vantage-server:test . +docker build -f sitesvc/Dockerfile -t vantage-sitesvc:test . +``` + +Expected: both succeed, each ending with `naming to docker.io/library/vantage-...:test`. + +- [ ] **Step 4: Update the CI workflow** + +In `.gitea/workflows/server-deploy.yml`, for the `server` and `sitesvc` image build steps only: + +```yaml + context: . + file: server/Dockerfile +``` + +and + +```yaml + context: . + file: sitesvc/Dockerfile +``` + +Leave the `web` and `site` build steps untouched — they are Node images and do not use the shared module. + +- [ ] **Step 5: Confirm the agent pipeline is untouched** + +```bash +cd c:/Work/Repos/vantage +git diff --name-only HEAD -- .gitea/workflows/agent-release.yml agent/ +``` + +Expected: no output. If either appears, revert those changes — the agent is explicitly out of scope. + +- [ ] **Step 6: Commit** + +```bash +git add server/Dockerfile sitesvc/Dockerfile .gitea/workflows/server-deploy.yml +git commit -m "build: build Go images from the repo root for the shared module" +``` + +--- + +### Task 8: End-to-end verification + +No new code. This is the gate, and with no automated tests it is the **only** evidence the refactor worked. Every step must be performed and its output observed — not assumed. + +**Files:** none + +- [ ] **Step 1: Build everything, including the agent** + +```bash +cd c:/Work/Repos/vantage +(cd shared && go build ./... && go vet ./...) +(cd server && go build ./... && go vet ./...) +(cd sitesvc && go build ./... && go vet ./...) +(cd agent && go build ./... && go vet ./...) +``` + +Expected: no output from any of the eight commands. + +- [ ] **Step 2: Cross-compile the agent** + +```bash +cd c:/Work/Repos/vantage/agent +GOOS=linux GOARCH=amd64 go build -o /tmp/a1 ./cmd +GOOS=linux GOARCH=arm64 go build -o /tmp/a2 ./cmd +GOOS=windows GOARCH=amd64 go build -o /tmp/a3.exe ./cmd +``` + +Expected: all three succeed. This proves the workspace did not leak into the agent's build. + +- [ ] **Step 3: Boot the control plane against a scratch database** + +```bash +cd c:/Work/Repos/vantage/server +MONGO_URI=mongodb://localhost:27017 MONGO_DB=vantage_scratch \ + GRPC_HOST=localhost:9090 go run ./cmd +``` + +Expected in the logs: `connected to MongoDB`, no index error, no migration error, and the HTTP listener starting. + +Confirm the indexes were created — in `mongosh`: + +```javascript +use vantage_scratch +db.users.getIndexes() +db.orgs.getIndexes() +``` + +Expected: a unique index on `email` and a unique index on `slug` respectively. + +- [ ] **Step 4: Bootstrap through the control plane** + +With the server still running: + +```bash +curl -s localhost:8080/auth/bootstrap-status +curl -s -X POST localhost:8080/auth/bootstrap \ + -H 'Content-Type: application/json' \ + -d '{"org_name":"Acme Corp","email":"owner@example.com","password":"hunter2hunter2"}' +``` + +Expected: bootstrap succeeds. Then in `mongosh`: + +```javascript +db.orgs.findOne({}, {org_id: 1, name: 1, slug: 1}) +``` + +Expected: `slug: "acme-corp"`, a non-empty `org_id`, `name: "Acme Corp"`. + +This exercises `shared.CreateOrg` and `shared.CreateUser` through the control plane. + +- [ ] **Step 5: Confirm slug collision handling** + +```bash +curl -s -X POST localhost:8080/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"owner@example.com","password":"hunter2hunter2"}' -c /tmp/c.txt +``` + +Then create two more organisations named `Acme Corp` — through the UI, or directly in `mongosh` by calling the server again if a bootstrap-only path is not available. Confirm: + +```javascript +db.orgs.find({}, {slug: 1}).sort({slug: 1}) +``` + +Expected: `acme-corp`, `acme-corp-2`, `acme-corp-3`. + +- [ ] **Step 6: The end-to-end agreement test** + +**This is the step that proves the refactor worked.** It confirms the two services still agree about the documents they share. + +Start sitesvc against the same scratch database: + +```bash +cd c:/Work/Repos/vantage/sitesvc +MONGO_URI=mongodb://localhost:27017/vantage_scratch \ + PUBLIC_URL=http://localhost:8082 \ + SITE_ORIGIN=http://localhost:3001 \ + SMTP_HOST=localhost SMTP_PORT=1025 SMTP_FROM=noreply@example.com \ + go run ./cmd +``` + +Use a local mail catcher for SMTP: `docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog` + +Then: + +1. `curl -s -X POST localhost:8082/api/signup -H 'Content-Type: application/json' -d '{"org_name":"Globex Ltd","email":"new@example.com","password":"hunter2hunter2"}'` +2. Open MailHog at `http://localhost:8025`, copy the verification link. +3. Open the verification link. +4. Log into the control plane with the new credentials: + +```bash +curl -s -X POST localhost:8080/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"new@example.com","password":"hunter2hunter2"}' -c /tmp/c2.txt +curl -s localhost:8080/auth/me -b /tmp/c2.txt +``` + +Expected: login succeeds and `/auth/me` returns the org sitesvc created, with slug `globex-ltd`. + +**If login fails, the two services disagree about a document shape. The cause is in Task 5 or 6.** Do not proceed. + +- [ ] **Step 7: Confirm rollback still refuses** + +Create a throwaway `shared/cmd/rbcheck/main.go`: + +```go +package main + +import ( + "context" + "fmt" + "os" + + "github.com/mrhid6/vantage/shared/provision" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func main() { + c, err := mongo.Connect(options.Client().ApplyURI("mongodb://localhost:27017")) + if err != nil { + panic(err) + } + err = provision.RollbackOrg(context.Background(), c.Database("vantage_scratch"), os.Args[1]) + fmt.Println("err =", err) +} +``` + +Run it with the `org_id` of an org that has a user: + +```bash +cd shared && go run ./cmd/rbcheck +``` + +Expected: `err = refusing to roll back organisation : it has 1 user(s)` + +Then run it with a manually inserted org that has no users. Expected: `err = `, and the org is gone. + +Delete the throwaway: `rm -rf shared/cmd/rbcheck` + +- [ ] **Step 8: Confirm the duplication is gone repo-wide** + +```bash +cd c:/Work/Repos/vantage +grep -rn "func Slugify" --include=*.go . +grep -rn "ReservedSlugs = map" --include=*.go . +grep -rn "bson:\"org_id\"" --include=*.go . +``` + +Expected: +- `func Slugify` — exactly one hit, in `shared/provision/slug.go` +- `ReservedSlugs = map` — exactly one hit, in `shared/provision/slug.go` +- `bson:"org_id"` — hits only in `shared/models/` and `server/internal/models/` for server-only documents. **No hit anywhere under `sitesvc/`.** + +- [ ] **Step 9: Drop the scratch database and commit** + +```javascript +use vantage_scratch +db.dropDatabase() +``` + +```bash +git add -A +git commit -m "chore: verify shared module extraction end to end" +``` + +--- + +## Rollout + +Single release, no database migration, no downtime beyond the normal restart. + +`server` and `sitesvc` images are built from one commit and deployed together. A version skew is harmless here because no document changed — but there is no reason to split it. + +```bash +cd /opt/vantage && \ + docker compose -f docker-compose.yml -f docker-compose.site.yml pull && \ + docker compose -f docker-compose.yml -f docker-compose.site.yml up -d --remove-orphans +``` + +Post-deploy checks: + +1. Control plane login works. +2. A signup through the marketing site completes and the new owner can log in. +3. Server logs show no index errors at boot. + +## What this unblocks + +Plan 0b (`instance-rename`) becomes a rename inside one module plus its two +consumers, rather than a rename across three independent copies of the same +structs. That is the reason this plan goes first. diff --git a/docs/superpowers/specs/2026-07-24-instance-rename-design.md b/docs/superpowers/specs/2026-07-24-instance-rename-design.md index 40b3baa..92a86d1 100644 --- a/docs/superpowers/specs/2026-07-24-instance-rename-design.md +++ b/docs/superpowers/specs/2026-07-24-instance-rename-design.md @@ -160,23 +160,38 @@ meaning and the marketing site is the first place a customer meets it. ## Testing -Extends the suite started in spec 0a. +**No automated tests.** Decision taken 2026-07-24, consistent with spec 0a. -1. **Migration test, empty database.** Runs clean, writes the marker. -2. **Migration test, seeded database.** Seed one document in every collection in - `scopedCollections` with a known `org_id`. Run. Assert every document has - `instance_id` with the same value, no document has `org_id`, and per-collection - counts are unchanged. -3. **Idempotency.** Run the migration twice. Second run is a no-op and does not - error. -4. **Interrupted run.** Rename half the collections, then run the full migration. - It completes the rest without error and passes verification. -5. **Guard.** With `instances` present and `orgs` absent, the migration - short-circuits and records the marker. -6. **Scoped-collection completeness.** A test that reads the collection list from - the live database and fails if any collection outside `scopedCollections` - contains an `org_id` field. This test protects the list from going stale. -7. **`shared/provision` tests from 0a** pass unchanged after the rename. +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 diff --git a/docs/superpowers/specs/2026-07-24-shared-module-design.md b/docs/superpowers/specs/2026-07-24-shared-module-design.md index 3213599..66cc283 100644 --- a/docs/superpowers/specs/2026-07-24-shared-module-design.md +++ b/docs/superpowers/specs/2026-07-24-shared-module-design.md @@ -226,23 +226,27 @@ called out here so it is a decision rather than an accident. ## Testing -The repo has no Go test suite today. This refactor is where one starts, because -the moved code is exactly the code whose correctness is load-bearing. +**No automated tests.** Decision taken 2026-07-24: the repo has no Go test suite +and one is not being started here. Verification is by compiler, `grep`, and +running both services end to end. -`shared/provision` unit tests, against a real MongoDB (testcontainers or a -`MONGO_TEST_URI` env guard — skip when unset rather than fail): +That places the whole weight on three manual checks, which the implementation +plan makes mandatory steps rather than suggestions: -1. `Slugify` — table test covering the existing regex behaviour: casing, - punctuation runs collapsing to a single `-`, leading/trailing trim. -2. `BaseSlug` — rejects under `MinSlugLength`, truncates over `MaxSlugLength`, - rejects every entry in `ReservedSlugs`. -3. `CreateOrg` — a second org with a colliding name gets `-2`; a third gets `-3`. -4. `CreateOrg` under a concurrent duplicate insert returns the clean - "slug already taken" error rather than a raw Mongo error. -5. `CreateUser` — hash verifies with bcrypt at cost 12; duplicate email is - rejected by the unique index. -6. `RollbackOrg` — deletes an org with no users; refuses one that has users. -7. `EnsureCoreIndexes` — idempotent across two calls. +1. **bson tag diff** — `diff` the `bson:"…"` tags of each moved struct against + the originals. A changed tag orphans production data silently, and this is + the only thing that catches it. +2. **Slug behaviour walkthrough** — a throwaway `main` printing `Slugify`, + `BaseSlug` and `NextSlug` output for a fixed input table, compared against + expected output recorded in the plan. +3. **End-to-end agreement** — sign up through sitesvc against a scratch + database, open the verification link, then log into the control plane with + those credentials. This is the check that proves the two services still agree + about the documents they share. If it passes, the refactor worked. + +Plus `grep` assertions that exactly one definition of `Slugify` and +`ReservedSlugs` survives repo-wide, and that no struct under `sitesvc/` carries +a `bson:"org_id"` tag. ## Verification before merge