Files
member-console/internal/db/migrations/00004_doc41_schema_and_data.sql
T
cgalo5758 1a19ebe971 Implement uniform conferral semantics
Replace product-kind branching and direct position writes with enclosed
database functions driven by structural product shape.

Migrate grant and provision data, unify operator issuance, update
subscription and expiry flows, and add migration and integration proofs.
2026-07-11 13:05:53 -05:00

431 lines
20 KiB
PL/PgSQL

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