Files
member-console/internal/db/migrations/00012_entity_keys.sql
T
cgalo5758 568383b55e Reorganize status docs and add identifier docs
Move settled investigation records into
`status/explorations/<topic>-<date>/` with README indexes,
promote durable conventions to `docs/`, and add
`docs/identifiers.md` plus upstream Doc 46 person-name
provenance documents. Update cross-references and archive
resolved issues.
2026-08-29 21:15:31 -05:00

249 lines
11 KiB
SQL

-- +goose Up
-- entity-keys (docs/identifiers.md §2 to §4, §7). Every entity has
-- an ID (UUIDv7, always database-generated; Decision 30 stands) and a name.
-- Tenants and catalog entities also carry a nullable `key`: a unique string
-- that lets something outside the database (a seed, a configuration file, a
-- test, a script, an API client) address the row without knowing its
-- generated ID. Vocabulary tables keep their primary keys as their keys.
--
-- Column contract, one rule for every table below:
-- * `key TEXT NULL`;
-- * grammar `^[a-z][a-z0-9_]*$`, at most 64 characters (the vocabulary
-- grammar already ratified for org types, resource keys, and roles);
-- * unique within the smallest namespace the entity inhabits: the table
-- for a root entity, `(parent_id, key)` for a child. NULLs are distinct,
-- so rows without a key never conflict;
-- * never derived from a person's data. The system writes a key only as a
-- fixed constant for a row it creates by design: the System tenant
-- (`system`) and each organization's default pool (`default`).
--
-- The three `slug` columns become `key` (the address role they were meant
-- for, without the defect: they were NOT NULL and filled from a login name or
-- a display name, which is why they read as asking for the name twice). The
-- plan ladder's `ladder_key` becomes `key` under the same contract.
-- Entitlement sets, products, and prices gain the column. The System tenant
-- singleton and the two name guards from the earlier cut of this migration
-- are unchanged.
-- ---------------------------------------------------------------------------
-- (a) Organizations: `slug` → `key`, nullable, root scope.
-- ---------------------------------------------------------------------------
-- The singleton first, so a database holding two system organizations fails
-- on the index that names the rule rather than on the key assignment below.
CREATE UNIQUE INDEX uq_organizations_one_system
ON core.organizations (org_type) WHERE org_type = 'system';
ALTER TABLE core.organizations
DROP CONSTRAINT uq_organizations_slug;
DROP INDEX core.idx_organizations_slug;
ALTER TABLE core.organizations
RENAME COLUMN slug TO key;
ALTER TABLE core.organizations
ALTER COLUMN key TYPE TEXT,
ALTER COLUMN key DROP NOT NULL;
-- Every existing value was derived from the owner's login name or the
-- organization's name at creation; none was chosen, none is referenced. The
-- System tenant keeps the constant it is created with; everything else is
-- cleared (a personal organization gets no key by rule).
UPDATE core.organizations
SET key = CASE WHEN org_type = 'system' THEN 'system' END;
ALTER TABLE core.organizations
ADD CONSTRAINT uq_organizations_key UNIQUE (key),
ADD CONSTRAINT chk_organizations_key_grammar
CHECK (key IS NULL OR (key ~ '^[a-z][a-z0-9_]*$' AND length(key) <= 64));
-- ---------------------------------------------------------------------------
-- (b) Workspaces: `slug` → `key`, nullable, scoped to the organization.
-- ---------------------------------------------------------------------------
ALTER TABLE core.workspaces
DROP CONSTRAINT uq_workspaces_org_id_slug;
ALTER TABLE core.workspaces
RENAME COLUMN slug TO key;
ALTER TABLE core.workspaces
ALTER COLUMN key TYPE TEXT,
ALTER COLUMN key DROP NOT NULL;
-- Every value was derived from the workspace's name; a singleton child (the
-- System tenant's workspace, an organization's first workspace) is addressed
-- through its parent and carries no key.
UPDATE core.workspaces SET key = NULL;
ALTER TABLE core.workspaces
ADD CONSTRAINT uq_workspaces_org_id_key UNIQUE (org_id, key),
ADD CONSTRAINT chk_workspaces_key_grammar
CHECK (key IS NULL OR (key ~ '^[a-z][a-z0-9_]*$' AND length(key) <= 64));
-- Workspace names disambiguate within their organization among live rows
-- (a soft-deleted workspace frees its name; upstream data model v16.1
-- documents the same predicate). Dedupe first: within each organization,
-- the second and later live workspaces sharing a name case-insensitively get
-- ' (2)', ' (3)', ... appended, oldest kept intact. created_at is the
-- authoritative order; workspace_id (uuidv7, time-ordered) breaks ties.
WITH ranked AS (
SELECT workspace_id,
ROW_NUMBER() OVER (
PARTITION BY org_id, lower(name)
ORDER BY created_at ASC, workspace_id ASC
) AS rn
FROM core.workspaces
WHERE status <> 'deleted'
)
UPDATE core.workspaces w
SET name = w.name || ' (' || ranked.rn || ')'
FROM ranked
WHERE w.workspace_id = ranked.workspace_id AND ranked.rn > 1;
CREATE UNIQUE INDEX uq_workspaces_org_id_name_ci
ON core.workspaces (org_id, lower(name)) WHERE status <> 'deleted';
-- ---------------------------------------------------------------------------
-- (c) Resource pools: `slug` → `key`, nullable, scoped to the organization.
-- ---------------------------------------------------------------------------
ALTER TABLE core.resource_pools
DROP CONSTRAINT uq_resource_pools_org_id_slug;
ALTER TABLE core.resource_pools
RENAME COLUMN slug TO key;
ALTER TABLE core.resource_pools
ALTER COLUMN key TYPE TEXT,
ALTER COLUMN key DROP NOT NULL;
-- The default pool is the one row the system creates by design for every
-- organization; uq_resource_pools_one_default_per_org (00010) guarantees at
-- most one per organization, so the constant cannot collide.
UPDATE core.resource_pools
SET key = CASE WHEN pool_type = 'default' THEN 'default' END;
ALTER TABLE core.resource_pools
ADD CONSTRAINT uq_resource_pools_org_id_key UNIQUE (org_id, key),
ADD CONSTRAINT chk_resource_pools_key_grammar
CHECK (key IS NULL OR (key ~ '^[a-z][a-z0-9_]*$' AND length(key) <= 64));
-- ---------------------------------------------------------------------------
-- (d) Plan ladders: `ladder_key` → `key`, nullable, root scope.
-- ---------------------------------------------------------------------------
ALTER TABLE core.plan_ladders
DROP CONSTRAINT uq_plan_ladders_ladder_key;
ALTER TABLE core.plan_ladders
RENAME COLUMN ladder_key TO key;
ALTER TABLE core.plan_ladders
ALTER COLUMN key TYPE TEXT,
ALTER COLUMN key DROP NOT NULL;
-- Existing ladder keys were shown to and confirmed by an operator at
-- creation, and the demo seed addresses its ladder by one, so they are kept
-- and brought under the grammar rather than cleared: lower-cased, separator
-- runs folded to '_', leading non-letters removed. A value the grammar still
-- rejects, or that collides after folding (the later row by created_at),
-- becomes NULL; nothing outside the database names those values.
UPDATE core.plan_ladders
SET key = regexp_replace(regexp_replace(lower(key), '[^a-z0-9_]+', '_', 'g'), '^[^a-z]+', '');
UPDATE core.plan_ladders
SET key = NULL
WHERE key !~ '^[a-z][a-z0-9_]*$' OR length(key) > 64;
WITH ranked AS (
SELECT plan_ladder_id,
ROW_NUMBER() OVER (PARTITION BY key ORDER BY created_at ASC, plan_ladder_id ASC) AS rn
FROM core.plan_ladders
WHERE key IS NOT NULL
)
UPDATE core.plan_ladders l
SET key = NULL
FROM ranked
WHERE l.plan_ladder_id = ranked.plan_ladder_id AND ranked.rn > 1;
ALTER TABLE core.plan_ladders
ADD CONSTRAINT uq_plan_ladders_key UNIQUE (key),
ADD CONSTRAINT chk_plan_ladders_key_grammar
CHECK (key IS NULL OR (key ~ '^[a-z][a-z0-9_]*$' AND length(key) <= 64));
-- Plan ladder names disambiguate globally: ladder names lead every catalog
-- and composite surface, so two ladders named the same are indistinguishable
-- to an operator. Same dedupe rule as (b).
WITH ranked AS (
SELECT plan_ladder_id,
ROW_NUMBER() OVER (
PARTITION BY lower(name)
ORDER BY created_at ASC, plan_ladder_id ASC
) AS rn
FROM core.plan_ladders
)
UPDATE core.plan_ladders l
SET name = l.name || ' (' || ranked.rn || ')'
FROM ranked
WHERE l.plan_ladder_id = ranked.plan_ladder_id AND ranked.rn > 1;
CREATE UNIQUE INDEX uq_plan_ladders_name_ci
ON core.plan_ladders (lower(name));
-- ---------------------------------------------------------------------------
-- (e) Entitlement sets and products gain `key` (root scope); prices gain
-- `key` scoped to their product. All NULL until a seed, a configuration
-- loader, an API client, or an operator sets one.
-- ---------------------------------------------------------------------------
ALTER TABLE core.entitlement_sets
ADD COLUMN key TEXT,
ADD CONSTRAINT uq_entitlement_sets_key UNIQUE (key),
ADD CONSTRAINT chk_entitlement_sets_key_grammar
CHECK (key IS NULL OR (key ~ '^[a-z][a-z0-9_]*$' AND length(key) <= 64));
ALTER TABLE core.products
ADD COLUMN key TEXT,
ADD CONSTRAINT uq_products_key UNIQUE (key),
ADD CONSTRAINT chk_products_key_grammar
CHECK (key IS NULL OR (key ~ '^[a-z][a-z0-9_]*$' AND length(key) <= 64));
ALTER TABLE core.prices
ADD COLUMN key TEXT,
ADD CONSTRAINT uq_prices_product_id_key UNIQUE (product_id, key),
ADD CONSTRAINT chk_prices_key_grammar
CHECK (key IS NULL OR (key ~ '^[a-z][a-z0-9_]*$' AND length(key) <= 64));
-- +goose Down
-- The rollback restores the shape, not the values: the renamed columns go
-- back to their old names, nullable, holding whatever keys were set (the
-- cleared and folded values are not recoverable), and the added columns are
-- dropped. The dedupe renames in (b) and (d) are data changes with no record
-- of the prior names; Down leaves them in place. This matches the
-- repository's snapshot-based rollback convention (see
-- 00010_schema_hardening.sql).
ALTER TABLE core.prices
DROP CONSTRAINT chk_prices_key_grammar,
DROP CONSTRAINT uq_prices_product_id_key,
DROP COLUMN key;
ALTER TABLE core.products
DROP CONSTRAINT chk_products_key_grammar,
DROP CONSTRAINT uq_products_key,
DROP COLUMN key;
ALTER TABLE core.entitlement_sets
DROP CONSTRAINT chk_entitlement_sets_key_grammar,
DROP CONSTRAINT uq_entitlement_sets_key,
DROP COLUMN key;
DROP INDEX core.uq_plan_ladders_name_ci;
ALTER TABLE core.plan_ladders
DROP CONSTRAINT chk_plan_ladders_key_grammar,
DROP CONSTRAINT uq_plan_ladders_key;
ALTER TABLE core.plan_ladders RENAME COLUMN key TO ladder_key;
ALTER TABLE core.resource_pools
DROP CONSTRAINT chk_resource_pools_key_grammar,
DROP CONSTRAINT uq_resource_pools_org_id_key;
ALTER TABLE core.resource_pools RENAME COLUMN key TO slug;
DROP INDEX core.uq_workspaces_org_id_name_ci;
ALTER TABLE core.workspaces
DROP CONSTRAINT chk_workspaces_key_grammar,
DROP CONSTRAINT uq_workspaces_org_id_key;
ALTER TABLE core.workspaces RENAME COLUMN key TO slug;
ALTER TABLE core.organizations
DROP CONSTRAINT chk_organizations_key_grammar,
DROP CONSTRAINT uq_organizations_key;
ALTER TABLE core.organizations RENAME COLUMN key TO slug;
DROP INDEX core.uq_organizations_one_system;