-- +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;