Files
member-console/internal/db/migrations/00004_doc41_schema_and_data.sql
T
cgalo5758 fe19ee415c Add CLA and SPDX headers, fix docs
- Pin Dockerfile to Go 1.23 to match go.mod
- Record README front-door audit findings
2026-09-07 21:32:14 -05:00

434 lines
20 KiB
PL/PgSQL

-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
-- SPDX-FileCopyrightText: 2025-2026 Christian Galo
-- +goose Up
-- Doc 41 (product-kind taxonomy dissolution + grant-issuance uniformity),
-- §9 steps 1-7. Steps 8-10 live in 00005 (conferral functions + enclosure)
-- and 00006 (position sweep + catalog rename). Data normalization runs
-- strictly before constraint installation; any row the mechanical bucket
-- rules cannot classify halts the migration with the offending ids rather
-- than guessing (halt-and-report). Rollback of the data steps is
-- snapshot-based; the Down section reverses schema mechanically but does
-- not resurrect dropped data.
-- ---------------------------------------------------------------------------
-- Step 1 — prices: consumption model is a per-price fact (Decision 135).
-- This schema predates usage pricing, so usage_type is added here rather
-- than normalized. A price billed per-use or per-seat only makes sense
-- against a recurring period; on-demand rate rows carry usage_type NULL and
-- are discriminated by their own mechanism, never by this flag.
-- ---------------------------------------------------------------------------
ALTER TABLE core.prices ADD COLUMN usage_type VARCHAR(20);
ALTER TABLE core.prices ADD CONSTRAINT chk_prices_usage_type_domain
CHECK (usage_type IS NULL OR usage_type IN ('metered', 'licensed'));
ALTER TABLE core.prices ADD CONSTRAINT chk_prices_usage_pricing_requires_recurrence
CHECK (usage_type IS NULL OR recurring_interval IS NOT NULL);
-- ---------------------------------------------------------------------------
-- Step 2 — provision bounds. quantity is already NOT NULL DEFAULT 1 in this
-- schema; only the positivity rule is missing.
-- ---------------------------------------------------------------------------
ALTER TABLE core.pool_provisions ADD CONSTRAINT chk_pool_provisions_quantity_positive
CHECK (quantity > 0);
-- ---------------------------------------------------------------------------
-- Steps 3+4 — grants: data normalization. Ordering within this block
-- matters and deliberately interleaves §9's steps 3 and 4: the set-direct
-- repoint (step 4 data) must run before the reason buckets and CHECKs
-- (step 3 constraints), because the bare-set grant is system-authored and
-- its 'default' reason is only trustworthy once it points at a rank-0 tier
-- product.
-- ---------------------------------------------------------------------------
-- 3a. 'trial' renames to 'evaluation' preserving semantic identity; expiry
-- stays on valid_until (Decision 139; 'trial' is a subscription/price word).
UPDATE core.grants SET grant_reason = 'evaluation' WHERE grant_reason = 'trial';
-- 4-data. Set-direct grants (product_id IS NULL): repoint to the
-- lowest-ranked tier product of the single ladder whose tier products share
-- the grant's entitlement set, and give live provisions an honest
-- retroactive ladder attachment (the pool genuinely occupied that position
-- all along). Zero or multiple candidate ladders → halt.
-- +goose StatementBegin
DO $$
DECLARE
g RECORD;
v_ladder_id UUID;
v_ladder_count INTEGER;
v_product_id UUID;
v_rank INTEGER;
prov RECORD;
v_conflict UUID;
BEGIN
FOR g IN SELECT grant_id, entitlement_set_id FROM core.grants WHERE product_id IS NULL LOOP
IF g.entitlement_set_id IS NULL THEN
RAISE EXCEPTION 'doc41 §9 step 4: grant % has neither product_id nor entitlement_set_id', g.grant_id;
END IF;
SELECT COUNT(DISTINCT t.plan_ladder_id) INTO v_ladder_count
FROM core.plan_ladder_tiers t
JOIN core.products p ON p.product_id = t.product_id
WHERE p.entitlement_set_id = g.entitlement_set_id;
IF v_ladder_count <> 1 THEN
RAISE EXCEPTION 'doc41 §9 step 4: set-direct grant % resolves to % ladders via its entitlement set; manual resolution required', g.grant_id, v_ladder_count;
END IF;
SELECT t.plan_ladder_id, t.product_id, t.rank INTO v_ladder_id, v_product_id, v_rank
FROM core.plan_ladder_tiers t
JOIN core.products p ON p.product_id = t.product_id
WHERE p.entitlement_set_id = g.entitlement_set_id
ORDER BY t.rank ASC
LIMIT 1;
UPDATE core.grants
SET product_id = v_product_id,
description = COALESCE(description || E'\n', '') ||
'[doc41 migration: set-direct grant repointed to tier product ' || v_product_id || ']'
WHERE grant_id = g.grant_id;
FOR prov IN
SELECT provision_id, pool_id, status, activated_at, ended_at
FROM core.pool_provisions
WHERE grant_id = g.grant_id AND status <> 'ended'
LOOP
-- Halt rather than collide with the GiST exclusion: an existing
-- active attachment on this (pool, ladder) means the pool held
-- two positions at once and needs a human decision.
SELECT l.provision_id INTO v_conflict
FROM core.pool_provision_ladders l
WHERE l.pool_id = prov.pool_id
AND l.plan_ladder_id = v_ladder_id
AND l.status = 'active'
LIMIT 1;
IF v_conflict IS NOT NULL THEN
RAISE EXCEPTION 'doc41 §9 step 4: retroactive attachment for grant % (provision %) conflicts with active attachment of provision % on ladder %; manual resolution required',
g.grant_id, prov.provision_id, v_conflict, v_ladder_id;
END IF;
INSERT INTO core.pool_provision_ladders
(provision_id, plan_ladder_id, pool_id, status, activated_at, ended_at, product_id)
VALUES
(prov.provision_id, v_ladder_id, prov.pool_id, prov.status, prov.activated_at, prov.ended_at, v_product_id);
INSERT INTO core.pool_provision_transitions
(pool_id, provision_id, plan_ladder_id, from_rank, to_rank, transition_type, actor_type, actor_id, reason, effective_at)
VALUES
(prov.pool_id, prov.provision_id, v_ladder_id, NULL, v_rank, 'initiate', 'system', NULL,
'doc41 §9 migration: retroactive position registration for set-direct grant', prov.activated_at);
END LOOP;
END LOOP;
END;
$$;
-- +goose StatementEnd
-- 3b. Operator-authored grants claiming 'default' are reclassified as
-- 'manual' with the claim preserved (finding #48 population).
UPDATE core.grants
SET description = COALESCE(description || E'\n', '') ||
'[doc41 migration: grant_reason was ''default'' but grant is operator-authored]',
grant_reason = 'manual'
WHERE grant_reason = 'default' AND granted_by_person_id IS NOT NULL;
-- 3c. Operator-authored grants with out-of-domain reasons (free text)
-- reclassify as 'manual', original preserved for the record.
UPDATE core.grants
SET description = COALESCE(description || E'\n', '') ||
'[doc41 migration: original grant_reason ''' || grant_reason || ''']',
grant_reason = 'manual'
WHERE grant_reason NOT IN ('manual', 'default', 'evaluation', 'promotional', 'complimentary', 'sponsored', 'board_decision', 'legacy')
AND granted_by_person_id IS NOT NULL;
-- 3d. Assertions before installing CHECKs: anything left over is a row the
-- mechanical rules cannot classify.
-- +goose StatementBegin
DO $$
DECLARE
v_ids TEXT;
BEGIN
-- System-authored rows must carry 'default' (the biconditional's other
-- half). A leftover here is a system-authored grant with a free-text or
-- non-default reason — unclassifiable mechanically.
SELECT string_agg(grant_id::text, ', ') INTO v_ids
FROM core.grants
WHERE granted_by_person_id IS NULL AND grant_reason <> 'default';
IF v_ids IS NOT NULL THEN
RAISE EXCEPTION 'doc41 §9 step 3: system-authored grants with non-default reason need manual resolution: %', v_ids;
END IF;
-- A 'default' grant is trusted as a genuine system default only if its
-- product sits at the lowest rank of some ladder (the shape the
-- automatic pipeline always produces).
SELECT string_agg(g.grant_id::text, ', ') INTO v_ids
FROM core.grants g
WHERE g.grant_reason = 'default'
AND NOT EXISTS (
SELECT 1
FROM core.plan_ladder_tiers t
WHERE t.product_id = g.product_id
AND t.rank = (SELECT MIN(t2.rank) FROM core.plan_ladder_tiers t2 WHERE t2.plan_ladder_id = t.plan_ladder_id)
);
IF v_ids IS NOT NULL THEN
RAISE EXCEPTION 'doc41 §9 step 3: ''default'' grants whose product is not a rank-0 tier need manual resolution: %', v_ids;
END IF;
END;
$$;
-- +goose StatementEnd
-- 3-constraints. grant_reason is already NOT NULL (load-bearing: both new
-- CHECKs are NULL-permeable under three-valued logic without it).
ALTER TABLE core.grants DROP CONSTRAINT chk_grants_system_actor;
ALTER TABLE core.grants ADD CONSTRAINT chk_grants_reason_domain
CHECK (grant_reason IN ('manual', 'default', 'evaluation', 'promotional', 'complimentary', 'sponsored', 'board_decision', 'legacy'));
ALTER TABLE core.grants ADD CONSTRAINT chk_grants_default_iff_system_authored
CHECK ((grant_reason = 'default') = (granted_by_person_id IS NULL));
-- 4-constraints. Every grant names a real product; the set-direct escape
-- closes structurally (Decision 137, partial reversal of Decision 112).
ALTER TABLE core.grants ALTER COLUMN product_id SET NOT NULL;
ALTER TABLE core.grants DROP CONSTRAINT chk_grants_entitlement_source;
ALTER TABLE core.grants DROP CONSTRAINT fk_grants_entitlement_set_id;
ALTER TABLE core.grants DROP COLUMN entitlement_set_id;
-- ---------------------------------------------------------------------------
-- Step 5 — provisions carry product identity. Backfill traces each
-- provision to its source arc; ambiguity halts.
-- ---------------------------------------------------------------------------
ALTER TABLE core.pool_provisions ADD COLUMN product_id UUID;
ALTER TABLE core.pool_provisions ADD CONSTRAINT fk_pool_provisions_product_id
FOREIGN KEY (product_id) REFERENCES core.products(product_id);
-- Grant-sourced: the grant's product.
UPDATE core.pool_provisions pp
SET product_id = g.product_id
FROM core.grants g
WHERE pp.grant_id = g.grant_id AND pp.product_id IS NULL;
-- Subscription-sourced: via subscription items' prices, matched on the
-- provision's entitlement set; only an unambiguous (single-product) match
-- is written.
UPDATE core.pool_provisions pp
SET product_id = m.product_id
FROM (
SELECT si.subscription_id, prod.entitlement_set_id, MIN(prod.product_id::text)::uuid AS product_id
FROM core.subscription_items si
JOIN core.prices pr ON pr.price_id = si.price_id
JOIN core.products prod ON prod.product_id = pr.product_id
GROUP BY si.subscription_id, prod.entitlement_set_id
HAVING COUNT(DISTINCT prod.product_id) = 1
) m
WHERE pp.subscription_id = m.subscription_id
AND pp.entitlement_set_id = m.entitlement_set_id
AND pp.product_id IS NULL;
-- Purchase-sourced provisions cannot exist (purchase_id is a loose slot; no
-- purchases table). Anything still NULL is ambiguous — halt-and-report.
-- +goose StatementBegin
DO $$
DECLARE
v_ids TEXT;
BEGIN
SELECT string_agg(provision_id::text, ', ') INTO v_ids
FROM core.pool_provisions WHERE product_id IS NULL;
IF v_ids IS NOT NULL THEN
RAISE EXCEPTION 'doc41 §9 step 5: provisions with ambiguous product identity need manual resolution: %', v_ids;
END IF;
-- Pre-check the per-source live-uniqueness the indexes below will
-- enforce, so the failure names rows instead of an index error.
SELECT string_agg(src, '; ') INTO v_ids FROM (
SELECT 'grant ' || grant_id::text AS src
FROM core.pool_provisions
WHERE grant_id IS NOT NULL AND status <> 'ended'
GROUP BY grant_id HAVING COUNT(*) > 1
UNION ALL
SELECT 'subscription ' || subscription_id::text || ' product ' || product_id::text
FROM core.pool_provisions
WHERE subscription_id IS NOT NULL AND status <> 'ended'
GROUP BY subscription_id, product_id HAVING COUNT(*) > 1
) dup;
IF v_ids IS NOT NULL THEN
RAISE EXCEPTION 'doc41 §9 step 5: sources with multiple live provisions need manual resolution: %', v_ids;
END IF;
END;
$$;
-- +goose StatementEnd
ALTER TABLE core.pool_provisions ALTER COLUMN product_id SET NOT NULL;
-- At most one live provision per source; status-partial so ended sources
-- can be honestly re-provisioned (reapply_defaults) and retries are
-- idempotent at the index (Invariant 6).
CREATE UNIQUE INDEX uq_pool_provisions_live_grant
ON core.pool_provisions (grant_id)
WHERE grant_id IS NOT NULL AND status <> 'ended';
CREATE UNIQUE INDEX uq_pool_provisions_live_purchase
ON core.pool_provisions (purchase_id)
WHERE purchase_id IS NOT NULL AND status <> 'ended';
CREATE UNIQUE INDEX uq_pool_provisions_live_subscription_product
ON core.pool_provisions (subscription_id, product_id)
WHERE subscription_id IS NOT NULL AND status <> 'ended';
-- ---------------------------------------------------------------------------
-- Step 6 — occupancy episodes get permanent identity (Decision 138). The
-- (provision, ladder) pair stays unique only among live rows, so a rung
-- lost and later regained is a fresh episode, never a reopened row.
-- ---------------------------------------------------------------------------
ALTER TABLE core.pool_provision_ladders ADD COLUMN provision_ladder_id UUID NOT NULL DEFAULT uuidv7();
ALTER TABLE core.pool_provision_ladders DROP CONSTRAINT pk_pool_provision_ladders;
ALTER TABLE core.pool_provision_ladders ADD CONSTRAINT pk_pool_provision_ladders PRIMARY KEY (provision_ladder_id);
CREATE UNIQUE INDEX uq_pool_provision_ladders_live
ON core.pool_provision_ladders (provision_id, plan_ladder_id)
WHERE status <> 'ended';
-- Audit rows name the specific occupancy episode. Backfill is exact: the
-- old composite PK guaranteed at most one episode per pair, so the join is
-- unambiguous. Rows predating provision attribution (provision_id IS NULL)
-- stay NULL; the three constraints below are NOT VALID — they govern new
-- writes and do not rewrite append-only history.
ALTER TABLE core.pool_provision_transitions ADD COLUMN provision_ladder_id UUID;
UPDATE core.pool_provision_transitions tr
SET provision_ladder_id = l.provision_ladder_id
FROM core.pool_provision_ladders l
WHERE tr.provision_id = l.provision_id
AND tr.plan_ladder_id = l.plan_ladder_id
AND tr.provision_id IS NOT NULL;
ALTER TABLE core.pool_provision_transitions
ADD CONSTRAINT fk_pool_provision_transitions_occupancy
FOREIGN KEY (provision_ladder_id) REFERENCES core.pool_provision_ladders(provision_ladder_id) NOT VALID;
ALTER TABLE core.pool_provision_transitions DROP CONSTRAINT chk_pool_provision_transitions_type;
ALTER TABLE core.pool_provision_transitions
ADD CONSTRAINT chk_pool_provision_transitions_type
CHECK (transition_type IN ('initiate', 'upgrade', 'downgrade', 'end', 'transfer')) NOT VALID;
ALTER TABLE core.pool_provision_transitions
ADD CONSTRAINT chk_pool_provision_transitions_occupancy_named
CHECK (provision_ladder_id IS NOT NULL) NOT VALID;
-- ---------------------------------------------------------------------------
-- Step 7 — sync trigger gains the monotonic-end guard; the conferral-shape
-- view is created. An individually-ended junction row is never reopened by
-- parent-provision updates: regaining a rung is a fresh episode.
-- ---------------------------------------------------------------------------
-- +goose StatementBegin
CREATE OR REPLACE FUNCTION core.sync_pool_provision_ladders()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.pool_id IS DISTINCT FROM OLD.pool_id
OR NEW.status IS DISTINCT FROM OLD.status
OR NEW.activated_at IS DISTINCT FROM OLD.activated_at
OR NEW.ended_at IS DISTINCT FROM OLD.ended_at THEN
UPDATE core.pool_provision_ladders
SET pool_id = NEW.pool_id,
status = NEW.status,
activated_at = NEW.activated_at,
ended_at = NEW.ended_at
WHERE provision_id = NEW.provision_id
-- Monotonic-end guard (Doc 41 §6.2): a deliberately-ended episode
-- stays ended; it never tracks the parent back to life.
AND status <> 'ended';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- +goose StatementEnd
-- The SOLE input the conferral primitive reads (Decision 134): entitlement
-- set, lifecycle, visibility, and per-ladder (id, rank) with a ladder count.
-- Off-ladder products appear as one row with NULL ladder columns and
-- ladder_count 0.
CREATE VIEW core.product_conferral_shapes AS
SELECT
p.product_id,
p.entitlement_set_id,
p.lifecycle_status,
p.is_public,
t.plan_ladder_id,
t.rank,
COUNT(t.plan_ladder_id) OVER (PARTITION BY p.product_id) AS ladder_count
FROM core.products p
LEFT JOIN core.plan_ladder_tiers t ON t.product_id = p.product_id;
-- The init migration's GRANT ON ALL TABLES was point-in-time; new relations
-- need explicit grants.
GRANT SELECT ON core.product_conferral_shapes TO core_reader, core_writer, core_owner;
-- +goose Down
DROP VIEW core.product_conferral_shapes;
-- +goose StatementBegin
CREATE OR REPLACE FUNCTION core.sync_pool_provision_ladders()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.pool_id IS DISTINCT FROM OLD.pool_id
OR NEW.status IS DISTINCT FROM OLD.status
OR NEW.activated_at IS DISTINCT FROM OLD.activated_at
OR NEW.ended_at IS DISTINCT FROM OLD.ended_at THEN
UPDATE core.pool_provision_ladders
SET pool_id = NEW.pool_id,
status = NEW.status,
activated_at = NEW.activated_at,
ended_at = NEW.ended_at
WHERE provision_id = NEW.provision_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- +goose StatementEnd
ALTER TABLE core.pool_provision_transitions DROP CONSTRAINT chk_pool_provision_transitions_occupancy_named;
ALTER TABLE core.pool_provision_transitions DROP CONSTRAINT chk_pool_provision_transitions_type;
ALTER TABLE core.pool_provision_transitions
ADD CONSTRAINT chk_pool_provision_transitions_type
CHECK (transition_type IN ('initiate', 'upgrade', 'downgrade', 'end', 'extend')) NOT VALID;
ALTER TABLE core.pool_provision_transitions DROP CONSTRAINT fk_pool_provision_transitions_occupancy;
ALTER TABLE core.pool_provision_transitions DROP COLUMN provision_ladder_id;
DROP INDEX core.uq_pool_provision_ladders_live;
ALTER TABLE core.pool_provision_ladders DROP CONSTRAINT pk_pool_provision_ladders;
ALTER TABLE core.pool_provision_ladders ADD CONSTRAINT pk_pool_provision_ladders PRIMARY KEY (provision_id, plan_ladder_id);
ALTER TABLE core.pool_provision_ladders DROP COLUMN provision_ladder_id;
DROP INDEX core.uq_pool_provisions_live_subscription_product;
DROP INDEX core.uq_pool_provisions_live_purchase;
DROP INDEX core.uq_pool_provisions_live_grant;
ALTER TABLE core.pool_provisions DROP CONSTRAINT fk_pool_provisions_product_id;
ALTER TABLE core.pool_provisions DROP COLUMN product_id;
-- Data reversals are snapshot-based (design D7): the column returns empty
-- and the pre-migration reason/product values are not resurrected.
ALTER TABLE core.grants ADD COLUMN entitlement_set_id UUID;
ALTER TABLE core.grants ADD CONSTRAINT fk_grants_entitlement_set_id
FOREIGN KEY (entitlement_set_id) REFERENCES core.entitlement_sets(set_id);
ALTER TABLE core.grants ALTER COLUMN product_id DROP NOT NULL;
ALTER TABLE core.grants ADD CONSTRAINT chk_grants_entitlement_source
CHECK (entitlement_set_id IS NOT NULL OR product_id IS NOT NULL);
ALTER TABLE core.grants DROP CONSTRAINT chk_grants_default_iff_system_authored;
ALTER TABLE core.grants DROP CONSTRAINT chk_grants_reason_domain;
ALTER TABLE core.grants ADD CONSTRAINT chk_grants_system_actor
CHECK (granted_by_person_id IS NOT NULL OR grant_reason = 'default');
ALTER TABLE core.pool_provisions DROP CONSTRAINT chk_pool_provisions_quantity_positive;
ALTER TABLE core.prices DROP CONSTRAINT chk_prices_usage_pricing_requires_recurrence;
ALTER TABLE core.prices DROP CONSTRAINT chk_prices_usage_type_domain;
ALTER TABLE core.prices DROP COLUMN usage_type;