# Spec 0a — Shared Module Extraction

Date: 2026-07-24
Status: Design approved, not implemented
Ships: independently. No dependency on any other licensing spec.

## Context

Vantage is three independent Go modules: `server`, `sitesvc`, `agent`. There is no
root `go.mod` and no `go.work`.

`sitesvc` writes into the same MongoDB collections the control plane reads, but
cannot import the control plane, so it carries hand-copied duplicates:

- `sitesvc/internal/models/models.go` — `Org` and `User` mirrored field for field
- `sitesvc/internal/provision/provision.go` — `Slugify`, `ReservedSlugs`,
  `MinSlugLength`, `MaxSlugLength`, `BcryptCost`, slug-collision rules

Both files carry comments saying they must be changed in lockstep with the
control plane, and `CLAUDE.md` names the hazard explicitly: nothing enforces the
match. **The duplication has already drifted.** The control plane's `CreateOrg`
resolves slug collisions with an inline `fmt.Sprintf("%s-%d", base, i)` loop,
while sitesvc exposes the same rule as a separate `NextSlug(base, attempt)`
helper. They currently agree by luck, not by construction.

The licensing programme adds a fourth service (`admin`) that writes the license
blob onto the same tenant document. Adding a third copy of these rules is not
acceptable. This spec removes the duplication before any licensing code is
written.

This spec is a **pure refactor**. No database document changes. No behaviour
changes. Names stay as they are today (`Org`, `org_id`) — renaming happens in
spec 0b, deliberately kept separate so that a failed deploy has one suspect
rather than two.

## Goals

1. One authoritative definition of every document shape written by more than one
   service.
2. One authoritative definition of provisioning rules (slug, bcrypt cost,
   creation, rollback).
3. `sitesvc` keeps its independence from `server` — it depends on `shared`, not
   on the control plane. The original design intent survives; only the copying
   dies.
4. The agent is untouched.

## Non-goals

- Renaming anything. That is spec 0b.
- Moving control-plane-only models. `workflow.go`, `monitor.go`, `key.go`,
  `server.go`, `secret.go`, `assignment.go`, `channel.go`, `console_session.go`,
  `audit.go`, `org_oidc.go` stay in `server/internal/models`. Only the control
  plane touches them, and hoisting them would make `shared` a dumping ground.
- Merging the repo into a single module.

## Design

### Module layout

```
vantage/
├── go.work                  # NEW: server, sitesvc, shared (NOT agent)
├── shared/                  # NEW module: github.com/mrhid6/vantage/shared
│   ├── go.mod
│   ├── models/
│   │   ├── org.go           # Org
│   │   ├── user.go          # User, RoleOwner/RoleAdmin/RoleMember, ValidRole
│   │   └── settings.go      # Settings, AlertSettings, EmailSettings, SecretsSettings
│   ├── provision/
│   │   ├── slug.go          # Slugify, BaseSlug, NextSlug, ReservedSlugs, limits
│   │   ├── org.go           # CreateOrg
│   │   ├── user.go          # CreateUser, BcryptCost
│   │   └── rollback.go      # RollbackOrg
│   └── indexes/
│       └── indexes.go       # EnsureCoreIndexes
├── server/                  # replace => ../shared
├── sitesvc/                 # replace => ../shared
└── agent/                   # untouched
```

`go.work`:

```
go 1.26

use (
    ./shared
    ./server
    ./sitesvc
)
```

Each consumer's `go.mod` also carries an explicit replace:

```
require github.com/mrhid6/vantage/shared v0.0.0
replace github.com/mrhid6/vantage/shared => ../shared
```

Both are needed. `go.work` makes editors, `go test ./...` and local tooling work
across modules. The `replace` directives make Docker builds work whether or not
`go.work` is present, and stop `go build` outside the workspace from silently
trying to resolve `shared` from the network.

`shared` depends only on `go.mongodb.org/mongo-driver/v2`,
`golang.org/x/crypto/bcrypt` and `github.com/google/uuid`. It must not import
gin, redis, guac or anything else from the control plane's tree — that is what
keeps sitesvc small.

### What moves

**`shared/models`** — the three documents written by more than one service:

| Type | From | Written by |
|---|---|---|
| `Org` | `server/internal/models/org.go` | server, sitesvc, later admin |
| `User` + role constants + `ValidRole` | `server/internal/models/user.go` | server, sitesvc |
| `Settings` and its sub-structs | `server/internal/models/settings.go` | server today; admin reads it later |

`Settings` moves now rather than later because spec 3's admin service reads it,
and moving it later would mean a second round of import churn across both
services.

`PendingSignup` does **not** move. Only sitesvc writes `site_pending_signups`,
and the control plane does not know the collection exists.

**`shared/provision`** — the rules, promoted from private helpers to a real API:

```go
const (
    MinSlugLength = 3
    MaxSlugLength = 40
    BcryptCost    = 12
)

var ReservedSlugs = map[string]bool{ /* www, api, app, admin, auth, install, static, _next, default */ }

func Slugify(name string) string
func BaseSlug(name string) (string, error)   // validates length + reserved
func NextSlug(base string, attempt int) string

// CreateOrg resolves a free slug and inserts. The caller supplies the
// collection handle so shared does not own a Mongo connection.
func CreateOrg(ctx context.Context, db *mongo.Database, name string) (*models.Org, error)

func CreateUser(ctx context.Context, db *mongo.Database, orgID, email, password, role string) (*models.User, error)

// RollbackOrg deletes an org only if it has no users. Refuses otherwise.
func RollbackOrg(ctx context.Context, db *mongo.Database, orgID string) error
```

`shared.CreateOrg` becomes the single implementation. The control plane's
`services.CreateOrg` shrinks to a wrapper that calls it and then runs
`SeedDefaultSteps` — seeding stays in the server, because `shared` must not know
about workflow steps. sitesvc calls `shared.CreateOrg` directly and does not
seed, which is the behaviour it has today.

Note on the slug loop: it is count-then-insert and therefore racy. It is safe
only because of the unique index on `orgs.slug`. `CreateOrg` must keep handling
`mongo.IsDuplicateKeyError` and returning a clean error — moving the code must
not lose that. Document the reliance in a comment at the loop.

**`shared/indexes`** — `EnsureCoreIndexes(ctx, db)` declares the unique indexes
on `users.email` and `orgs.slug`. Both services call it at boot; creating an
existing index is a no-op. These indexes are a security property, not an
optimisation (see `CLAUDE.md`: `GetUserByEmail` does an unscoped `FindOne`, so
duplicates would break the OIDC cross-org guard), so the shared version is
**fatal on failure** for both callers.

Server-only index builders (`EnsureSettingsIndexes`, `EnsureSecretIndexes`,
`EnsureWorkflowIndexes`) stay in the server and keep their current
fatal/warn behaviour.

### What is deleted

- `sitesvc/internal/models/models.go` — reduced to `PendingSignup` only
- `sitesvc/internal/provision/` — deleted entirely
- `sitesvc/internal/store/store.go` — org/user creation replaced by calls into
  `shared/provision`; pending-signup storage stays

### Docker and CI

Both Go Dockerfiles currently build with the module directory as context:

```dockerfile
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build ... ./cmd
```

A `replace => ../shared` cannot resolve from that context. Build contexts move
to the repo root:

```dockerfile
WORKDIR /src
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
```

The two-stage copy keeps the dependency-download layer cached, which is the
reason the current Dockerfiles are written the way they are.

`.gitea/workflows/server-deploy.yml` must set `context: .` and
`file: server/Dockerfile` (and likewise for sitesvc) for the two Go images. The
`web` and `site` image builds are unaffected.

`agent-release.yml` is untouched. The agent is not in the workspace, has no
`replace`, and cross-compiles exactly as it does today.

### Error handling

No new error paths. `shared/provision` returns the same error strings the two
callers produce today so that API responses do not change. The one place to be
careful is wording: sitesvc says "organisation" and the control plane says
"organization". `shared` standardises on **"organisation"**; the control plane's
two error strings change spelling. This is user-visible in API error text and is
called out here so it is a decision rather than an accident.

## Testing

**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.

That places the whole weight on three manual checks, which the implementation
plan makes mandatory steps rather than suggestions:

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

Evidence required, not assertions:

1. `go build ./...` succeeds in `shared`, `server` and `sitesvc`.
2. `go vet ./...` clean in all three.
3. `docker build -f server/Dockerfile .` and `docker build -f sitesvc/Dockerfile .`
   both succeed from the repo root.
4. `grep -r "org_id" sitesvc/` returns hits only in `PendingSignup` context and
   `shared` imports — no local struct redefinitions.
5. End-to-end against a scratch database: sitesvc signup form → verification link
   → org and owner created → that owner logs into the control plane
   successfully. This is the test that proves the two services still agree.
6. The agent still builds for `linux/amd64`, `linux/arm64` and `windows/amd64`.

## Rollout

Single release. `server` and `sitesvc` images must be deployed together — a skew
is harmless here (documents are unchanged) but there is no reason to split it.

No database migration. No downtime.

## Risks

| Risk | Mitigation |
|---|---|
| Docker context change breaks CI | Verified locally by building both images from root before pushing |
| Behaviour drift while moving `CreateOrg` | Unit tests written against current behaviour first, then the move |
| `shared` accumulating control-plane concerns | Explicit non-goals above; keep its `go.mod` dependency list to three entries and review any addition |
| Error-string spelling change | Called out as a decision; grep the web UI for hard-coded matches on the old strings |

## Follow-on

Spec 0b (`instance-rename`) becomes a rename inside one module plus its
consumers, rather than a rename across three independent copies. That is the
whole reason this spec goes first.
