M4.1's full deliverable: PLATFORM_ARCHITECTURE.md §5c schema as a single golang-migrate migration, a standalone cmd/migrate binary, and testcontainers integration tests that exercise it against a real Postgres 16.
cmd/migrate: embedded SQL via migrations/embed.go; subcommands up / down / version / force. Ships as a self-contained Orca init container in prod.
Makefile: make migrate-up / down / down-all / version / create NAME=…, make test-short for environments without Docker.
Why
M4.1 acceptance: make migrate-up on a fresh Postgres produces the documented schema. M4.2 (full REST surface + pgx-backed store) builds on top of this without changing the schema.
Linked milestone: M4.1
How
One migration file (0001_init.up.sql) lands the entire spec instead of N small migrations. Reasoning: this is the initial schema, not an evolution — splitting it across 6 files makes the round-trip review harder and the migration table noisier. Future schema changes get their own numbered pairs.
Constraints baked in: tenant.slug regex enforces ^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$ so the portal can trust slugs everywhere downstream. tenant_products is keyed on (tenant_id, product) so a product entitlement is either on or off, not duplicated. audit_log uses ON DELETE SET NULL for tenant_id so forensic history outlives a tenant delete.
Indexes chosen for the queries M4.2 + M10.2 will run: tenants_status_idx, audit_log(tenant_id, created_at DESC), audit_log(product, …), audit_log(actor_id, …), partial index on non-revoked api_keys.
updated_at triggers on the four mutable tables — no application-layer code path can forget to touch the column.
Embedded migrations: migrations/embed.go declares package migrations + //go:embed *.sql so both cmd/migrate and (future) cmd/server ship the SQL as part of the binary. No file mounting at runtime.
golang-migrate as a library, not the CLI — keeps the install surface small (no go install step in the Dockerfile), keeps the version pinned in go.mod.
Test plan
go test -race ./... — 6 unit tests + 3 testcontainers tests, all green (~25s total)
make build → static binary; make build-migrate → static migrator
Local Dockerfile build (multi-stage, both binaries land in the distroless image)
make migrate-up against the dev Postgres works end-to-end (verified locally)
Regression test added — the round-trip up/down asserts schema empties cleanly, catching any future migration that forgets symmetric DROP statements
Risk
Blast radius: repo-local. No service consumes the schema yet — M4.2 is the first consumer. Stage/prod databases don't exist yet (M1.2 brings VMs; M3.1 brings Infisical for the DB URL).
What could break:
The slug regex disallows [A-Z_.]. If anyone wants to import existing customers with non-conforming slugs, they'll need to either rewrite or relax. Caught by TestSlugConstraint.
The audit_log SET NULL on tenant delete is a deliberate choice — alternative is CASCADE (delete audit too). Talked through in code comments. If we later need full erasure for GDPR, do it in a separate redaction migration.
Rollback plan:make migrate-down-all in dev. The down migration is symmetric and tested.
Checklist
Unit tests added (testcontainers)
Docs updated (README has a full migrations section; CHANGELOG entry)
Secrets via Infisical — DATABASE_URL is read from env; Makefile defaults at the dev DSN, prod resolves via Infisical
Migration is forward-only + idempotent — migrate up is a no-op when on-version
Tenant scoping — n/a at the schema layer; M4.2 handlers enforce it
OpenAPI spec — n/a (no API changes); lands with M4.2
CHANGELOG entry under "Added"
## What
M4.1's full deliverable: `PLATFORM_ARCHITECTURE.md §5c` schema as a single `golang-migrate` migration, a standalone `cmd/migrate` binary, and testcontainers integration tests that exercise it against a real Postgres 16.
- **6 tables**: `tenants`, `tenant_projects`, `tenant_products`, `tenant_idp_config`, `api_keys`, `audit_log`.
- **4 enums**: `tenant_status`, `tenant_kind`, `idp_kind`, `tenant_project_status`.
- **`cmd/migrate`**: embedded SQL via `migrations/embed.go`; subcommands `up` / `down` / `version` / `force`. Ships as a self-contained Orca init container in prod.
- **Makefile**: `make migrate-up / down / down-all / version / create NAME=…`, `make test-short` for environments without Docker.
## Why
M4.1 acceptance: `make migrate-up` on a fresh Postgres produces the documented schema. M4.2 (full REST surface + pgx-backed store) builds on top of this without changing the schema.
Linked milestone: **M4.1**
## How
- **One migration file** (`0001_init.up.sql`) lands the entire spec instead of N small migrations. Reasoning: this is the *initial* schema, not an evolution — splitting it across 6 files makes the round-trip review harder and the migration table noisier. Future schema changes get their own numbered pairs.
- **Constraints baked in**: `tenant.slug` regex enforces `^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$` so the portal can trust slugs everywhere downstream. `tenant_products` is keyed on `(tenant_id, product)` so a product entitlement is either on or off, not duplicated. `audit_log` uses `ON DELETE SET NULL` for `tenant_id` so forensic history outlives a tenant delete.
- **Indexes** chosen for the queries M4.2 + M10.2 will run: `tenants_status_idx`, `audit_log(tenant_id, created_at DESC)`, `audit_log(product, …)`, `audit_log(actor_id, …)`, partial index on non-revoked `api_keys`.
- **`updated_at` triggers** on the four mutable tables — no application-layer code path can forget to touch the column.
- **Embedded migrations**: `migrations/embed.go` declares `package migrations` + `//go:embed *.sql` so both `cmd/migrate` and (future) `cmd/server` ship the SQL as part of the binary. No file mounting at runtime.
- **`golang-migrate` as a library, not the CLI** — keeps the install surface small (no `go install` step in the Dockerfile), keeps the version pinned in `go.mod`.
## Test plan
- [x] `go test -race ./...` — 6 unit tests + 3 testcontainers tests, all green (~25s total)
- [x] `make build` → static binary; `make build-migrate` → static migrator
- [x] Local Dockerfile build (multi-stage, both binaries land in the distroless image)
- [x] `make migrate-up` against the dev Postgres works end-to-end (verified locally)
- [x] Regression test added — the round-trip up/down asserts schema empties cleanly, catching any future migration that forgets symmetric `DROP` statements
## Risk
**Blast radius:** repo-local. No service consumes the schema yet — M4.2 is the first consumer. Stage/prod databases don't exist yet (M1.2 brings VMs; M3.1 brings Infisical for the DB URL).
**What could break:**
- The slug regex disallows `[A-Z_.]`. If anyone wants to import existing customers with non-conforming slugs, they'll need to either rewrite or relax. Caught by `TestSlugConstraint`.
- The `audit_log SET NULL` on tenant delete is a deliberate choice — alternative is CASCADE (delete audit too). Talked through in code comments. If we later need full erasure for GDPR, do it in a separate redaction migration.
**Rollback plan:** `make migrate-down-all` in dev. The down migration is symmetric and tested.
## Checklist
- [x] Unit tests added (testcontainers)
- [x] Docs updated (README has a full migrations section; CHANGELOG entry)
- [x] Secrets via Infisical — `DATABASE_URL` is read from env; Makefile defaults at the dev DSN, prod resolves via Infisical
- [x] Migration is forward-only + idempotent — `migrate up` is a no-op when on-version
- [ ] Tenant scoping — n/a at the schema layer; M4.2 handlers enforce it
- [ ] OpenAPI spec — n/a (no API changes); lands with M4.2
- [x] CHANGELOG entry under "Added"
CODEOWNERS rules
requested review from Benjamin_Boenisch 2026-05-19 10:02:12 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What
M4.1's full deliverable:
PLATFORM_ARCHITECTURE.md §5cschema as a singlegolang-migratemigration, a standalonecmd/migratebinary, and testcontainers integration tests that exercise it against a real Postgres 16.tenants,tenant_projects,tenant_products,tenant_idp_config,api_keys,audit_log.tenant_status,tenant_kind,idp_kind,tenant_project_status.cmd/migrate: embedded SQL viamigrations/embed.go; subcommandsup/down/version/force. Ships as a self-contained Orca init container in prod.make migrate-up / down / down-all / version / create NAME=…,make test-shortfor environments without Docker.Why
M4.1 acceptance:
make migrate-upon a fresh Postgres produces the documented schema. M4.2 (full REST surface + pgx-backed store) builds on top of this without changing the schema.Linked milestone: M4.1
How
0001_init.up.sql) lands the entire spec instead of N small migrations. Reasoning: this is the initial schema, not an evolution — splitting it across 6 files makes the round-trip review harder and the migration table noisier. Future schema changes get their own numbered pairs.tenant.slugregex enforces^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$so the portal can trust slugs everywhere downstream.tenant_productsis keyed on(tenant_id, product)so a product entitlement is either on or off, not duplicated.audit_logusesON DELETE SET NULLfortenant_idso forensic history outlives a tenant delete.tenants_status_idx,audit_log(tenant_id, created_at DESC),audit_log(product, …),audit_log(actor_id, …), partial index on non-revokedapi_keys.updated_attriggers on the four mutable tables — no application-layer code path can forget to touch the column.migrations/embed.godeclarespackage migrations+//go:embed *.sqlso bothcmd/migrateand (future)cmd/servership the SQL as part of the binary. No file mounting at runtime.golang-migrateas a library, not the CLI — keeps the install surface small (nogo installstep in the Dockerfile), keeps the version pinned ingo.mod.Test plan
go test -race ./...— 6 unit tests + 3 testcontainers tests, all green (~25s total)make build→ static binary;make build-migrate→ static migratormake migrate-upagainst the dev Postgres works end-to-end (verified locally)DROPstatementsRisk
Blast radius: repo-local. No service consumes the schema yet — M4.2 is the first consumer. Stage/prod databases don't exist yet (M1.2 brings VMs; M3.1 brings Infisical for the DB URL).
What could break:
[A-Z_.]. If anyone wants to import existing customers with non-conforming slugs, they'll need to either rewrite or relax. Caught byTestSlugConstraint.audit_log SET NULLon tenant delete is a deliberate choice — alternative is CASCADE (delete audit too). Talked through in code comments. If we later need full erasure for GDPR, do it in a separate redaction migration.Rollback plan:
make migrate-down-allin dev. The down migration is symmetric and tested.Checklist
DATABASE_URLis read from env; Makefile defaults at the dev DSN, prod resolves via Infisicalmigrate upis a no-op when on-versiona27072b6d0to8a35942aea8a35942aeato2a05dfc166PLATFORM_ARCHITECTURE.md §5c schema, end-to-end: enums: tenant_status (demo/trial/active/frozen/archived), tenant_kind (customer/demo), idp_kind (oidc/saml), tenant_project_status (active/archived) tables: tenants id/slug/name/status/kind/plan/erp_id/ stripe_id/trial_ends_at/contract_dates/ sales_owner tenant_projects sub-tenancy (GCP-Project style); opt-in via product manifest.supports_projects=true tenant_products tenant ↔ product matrix + JSONB config tenant_idp_config enterprise SSO (OIDC/SAML metadata) api_keys argon2 hash + prefix + scopes + revoked_at audit_log Retraced-compatible; indexed for cross- product filtering per §8.4 triggers: updated_at auto-bump on every mutable table fks: ON DELETE CASCADE for owned rows; SET NULL for audit_log cmd/migrate (new binary): golang-migrate as a library with migrations embedded via migrations/embed.go; subcommands up/down/version/force. Ships as a self-contained Orca init container in prod. Tests (require Docker; gated by -short): TestMigrate_upDownRoundTrip schema → 6 tables + 4 enums; down→ empty; up-after-down clean TestSeed_canInsertAndQuery insert across every table; FK cascade; audit_log SET-NULL keeps the row TestSlugConstraint regex rejects too-short / leading dash / trailing dash / uppercase / underscore Makefile: migrate-up/down/down-all/version/create NAME=...; test-short to skip integration when Docker isn't around; build-migrate for just the migrator. CI: pin golangci-lint to v2.12.2 (Go 1.25-compatible) + bump golangci-lint-action to v7 (v6 rejects v2.x). The handler-layer in-memory store is unchanged; M4.2 swaps it for the pgx-backed implementation against this schema. Refs: M4.12a05dfc166tof9e9f0e21b