-- +goose Up -- Core baseline: schema-consolidation change (see openspec/changes/ -- schema-consolidation). Collapses the five former per-module schemas -- (identity, organization, billing, entitlements, integration) into a single -- `core` schema -- see design.md Decision 1/2 for why this is a fresh -- baseline rather than an ALTER ... SET SCHEMA transition path, and -- proposal.md for the rationale (no 1:1 module<->model correspondence). -- Authored clean-slate against the structural reference derived from the -- pre-consolidation migration chain (git history at bf9a38f); not a -- transcription of the old file layout. -- -- `stripe` and `fedwiki` are unchanged, integration-owned schemas with their -- own migration streams (internal/stripe/migrations, internal/fedwiki/ -- migrations) -- not part of this file. CREATE SCHEMA core; -- Required for the GiST exclusion constraint on core.pool_provision_ladders -- (equality comparison on UUID/VARCHAR columns alongside the range overlap). CREATE EXTENSION IF NOT EXISTS btree_gist; -- Shared trigger function for BEFORE UPDATE ... SET updated_at = NOW(). -- Stays in `public` (shared infrastructure, not part of the schema rename -- map): used by nearly every table below and by the stripe/fedwiki streams. -- +goose StatementBegin CREATE FUNCTION public.update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; -- +goose StatementEnd -- Entitlements-domain trigger functions. Defined here (ahead of the tables -- that use them) since their bodies reference core.pool_provision_ladders / -- core.grants, which do not exist yet at this point in the file -- safe -- because CREATE FUNCTION only parses the body, it does not resolve table -- references until the function is invoked. -- Propagates pool_id/status/activated_at/ended_at from a pool_provisions row -- to every pool_provision_ladders row attached to it, so the ladder table's -- GiST exclusion predicate always reflects the provision's current state. -- +goose StatementBegin CREATE 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 -- Grant lineage same-org enforcement: a grant with extends_grant_id set must -- share its parent grant's granted_to_org_id, and both must be non-NULL. -- Restricted to org-scoped grants; billing-account/person grant lineage is -- out of scope (entitlements/00010 in the old chain). -- +goose StatementBegin CREATE FUNCTION core.check_grant_lineage_same_org() RETURNS TRIGGER AS $$ DECLARE parent_org_id UUID; BEGIN IF NEW.extends_grant_id IS NULL THEN RETURN NEW; END IF; -- Self-reference is rejected by chk_grants_no_self_extend; defer to it -- so the CHECK-violation path fires instead of this trigger (BEFORE -- triggers run before CHECK constraints). IF NEW.extends_grant_id = NEW.grant_id THEN RETURN NEW; END IF; IF NEW.granted_to_org_id IS NULL THEN RAISE EXCEPTION 'extends_grant_id requires granted_to_org_id to be non-NULL' USING ERRCODE = 'check_violation'; END IF; SELECT granted_to_org_id INTO parent_org_id FROM core.grants WHERE grant_id = NEW.extends_grant_id; IF parent_org_id IS NULL OR parent_org_id != NEW.granted_to_org_id THEN RAISE EXCEPTION 'extends_grant_id must reference a grant for the same organization' USING ERRCODE = 'check_violation'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; -- +goose StatementEnd -- Grant lineage immutability: extends_grant_id is fixed at INSERT and may -- never change, preventing post-hoc cycle creation. -- +goose StatementBegin CREATE FUNCTION core.check_grant_lineage_immutable() RETURNS TRIGGER AS $$ BEGIN IF OLD.extends_grant_id IS DISTINCT FROM NEW.extends_grant_id THEN RAISE EXCEPTION 'extends_grant_id is immutable after insert' USING ERRCODE = 'check_violation'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; -- +goose StatementEnd -- --------------------------------------------------------------------------- -- Identity -- --------------------------------------------------------------------------- CREATE TABLE core.users ( user_id UUID NOT NULL DEFAULT uuidv7(), oidc_subject TEXT NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', last_login_at TIMESTAMPTZ, last_login_ip TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_users PRIMARY KEY (user_id), CONSTRAINT uq_users_oidc_subject UNIQUE (oidc_subject) ); CREATE TRIGGER trigger_users_updated_at BEFORE UPDATE ON core.users FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); -- Redundant with uq_users_oidc_subject; the old chain carried both, -- preserved as-is (candidate for a future documented drop). CREATE INDEX idx_users_oidc_subject ON core.users(oidc_subject); CREATE TABLE core.persons ( person_id UUID NOT NULL DEFAULT uuidv7(), user_id UUID NOT NULL, display_name VARCHAR(255) NOT NULL, primary_email VARCHAR(255) NOT NULL, primary_email_verified BOOLEAN NOT NULL DEFAULT FALSE, status VARCHAR(20) NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_persons PRIMARY KEY (person_id), CONSTRAINT uq_persons_user_id UNIQUE (user_id), CONSTRAINT fk_persons_user_id FOREIGN KEY (user_id) REFERENCES core.users(user_id) ON DELETE CASCADE ); CREATE TRIGGER trigger_persons_updated_at BEFORE UPDATE ON core.persons FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); -- Redundant with uq_persons_user_id; the old chain carried both, -- preserved as-is (candidate for a future documented drop). CREATE INDEX idx_persons_user_id ON core.persons(user_id); -- --------------------------------------------------------------------------- -- Catalog: products, prices, plan ladders -- --------------------------------------------------------------------------- -- entitlement_set_id has NO FK: entitlements.set_id FK was never actually -- added anywhere in the old migration chain despite a comment claiming -- otherwise (billing/00003) -- a genuine pre-existing gap, preserved as-is -- (see task summary blockers). CREATE TABLE core.products ( product_id UUID NOT NULL DEFAULT uuidv7(), name VARCHAR(255) NOT NULL, description TEXT, product_type VARCHAR(50), metadata JSONB, is_active BOOLEAN NOT NULL DEFAULT TRUE, is_public BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), entitlement_set_id UUID, lifecycle_status VARCHAR(20) NOT NULL DEFAULT 'draft', CONSTRAINT pk_products PRIMARY KEY (product_id), CONSTRAINT chk_products_product_type_domain CHECK (product_type IS NULL OR product_type IN ('addon', 'usage', 'one_time')), CONSTRAINT chk_products_lifecycle_status_domain CHECK (lifecycle_status IN ('draft', 'published', 'retired')) ); CREATE INDEX idx_products_is_active ON core.products(is_active); CREATE INDEX idx_products_is_public ON core.products(is_public); CREATE TRIGGER trigger_products_updated_at BEFORE UPDATE ON core.products FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); CREATE TABLE core.prices ( price_id UUID NOT NULL DEFAULT uuidv7(), product_id UUID NOT NULL, currency VARCHAR(3) NOT NULL, unit_amount INTEGER NOT NULL, recurring_interval VARCHAR(10), trial_period_days INTEGER, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), is_default BOOLEAN NOT NULL DEFAULT FALSE, CONSTRAINT pk_prices PRIMARY KEY (price_id), CONSTRAINT fk_prices_product_id FOREIGN KEY (product_id) REFERENCES core.products(product_id) ); CREATE INDEX idx_prices_product_id ON core.prices(product_id); CREATE INDEX idx_prices_is_active ON core.prices(is_active); CREATE UNIQUE INDEX uq_prices_one_default_per_product ON core.prices(product_id) WHERE is_default; CREATE TRIGGER trigger_prices_updated_at BEFORE UPDATE ON core.prices FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); CREATE TABLE core.plan_ladders ( plan_ladder_id UUID NOT NULL DEFAULT uuidv7(), ladder_key VARCHAR(100) NOT NULL, name VARCHAR(255) NOT NULL, description TEXT, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), sort_order INTEGER NOT NULL DEFAULT 0, CONSTRAINT pk_plan_ladders PRIMARY KEY (plan_ladder_id), CONSTRAINT uq_plan_ladders_ladder_key UNIQUE (ladder_key) ); CREATE TRIGGER trigger_plan_ladders_updated_at BEFORE UPDATE ON core.plan_ladders FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); -- --------------------------------------------------------------------------- -- Organizations & access control -- --------------------------------------------------------------------------- -- 'system' org_type is never seeded by any migration (created by -- app/bootstrap code); is_reserved backfill for it is therefore a -- historical no-op, not reproduced. CREATE TABLE core.org_types ( org_type VARCHAR(20) NOT NULL, display_name VARCHAR(255) NOT NULL, description TEXT, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), is_reserved BOOLEAN NOT NULL DEFAULT FALSE, default_plan_ladder_id UUID, CONSTRAINT pk_org_types PRIMARY KEY (org_type), CONSTRAINT fk_org_types_default_plan_ladder FOREIGN KEY (default_plan_ladder_id) REFERENCES core.plan_ladders(plan_ladder_id) ); CREATE TRIGGER trigger_org_types_updated_at BEFORE UPDATE ON core.org_types FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); CREATE TABLE core.organizations ( org_id UUID NOT NULL DEFAULT uuidv7(), name VARCHAR(255) NOT NULL, slug VARCHAR(100) NOT NULL, org_type VARCHAR(20) NOT NULL, owner_person_id UUID NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_organizations PRIMARY KEY (org_id), CONSTRAINT uq_organizations_slug UNIQUE (slug), CONSTRAINT fk_organizations_owner_person_id FOREIGN KEY (owner_person_id) REFERENCES core.persons(person_id), CONSTRAINT fk_organizations_org_type FOREIGN KEY (org_type) REFERENCES core.org_types(org_type) ); CREATE INDEX idx_organizations_owner ON core.organizations(owner_person_id); CREATE INDEX idx_organizations_slug ON core.organizations(slug); CREATE TRIGGER trigger_organizations_updated_at BEFORE UPDATE ON core.organizations FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); -- org_id NULL = system-wide role (seeded below: -- owner/admin/member/billing/viewer/platform_admin). CREATE TABLE core.roles ( role_id UUID NOT NULL DEFAULT uuidv7(), org_id UUID, role_name VARCHAR(100) NOT NULL, display_name VARCHAR(255) NOT NULL, description TEXT, is_system BOOLEAN NOT NULL DEFAULT FALSE, permissions TEXT[] NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_roles PRIMARY KEY (role_id), CONSTRAINT fk_roles_org_id FOREIGN KEY (org_id) REFERENCES core.organizations(org_id) ); CREATE INDEX idx_roles_org_id ON core.roles(org_id); CREATE TRIGGER trigger_roles_updated_at BEFORE UPDATE ON core.roles FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); CREATE TABLE core.org_members ( org_member_id UUID NOT NULL DEFAULT uuidv7(), org_id UUID NOT NULL, person_id UUID NOT NULL, role_id UUID NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), removed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_org_members PRIMARY KEY (org_member_id), CONSTRAINT uq_org_members_org_id_person_id UNIQUE (org_id, person_id), CONSTRAINT fk_org_members_org_id FOREIGN KEY (org_id) REFERENCES core.organizations(org_id), CONSTRAINT fk_org_members_person_id FOREIGN KEY (person_id) REFERENCES core.persons(person_id), CONSTRAINT fk_org_members_role_id FOREIGN KEY (role_id) REFERENCES core.roles(role_id) ); CREATE INDEX idx_org_members_org_id ON core.org_members(org_id); CREATE INDEX idx_org_members_person_id ON core.org_members(person_id); CREATE TRIGGER trigger_org_members_updated_at BEFORE UPDATE ON core.org_members FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); CREATE TABLE core.workspaces ( workspace_id UUID NOT NULL DEFAULT uuidv7(), org_id UUID NOT NULL, name VARCHAR(255) NOT NULL, slug VARCHAR(100) NOT NULL, description TEXT, status VARCHAR(20) NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_workspaces PRIMARY KEY (workspace_id), CONSTRAINT uq_workspaces_org_id_slug UNIQUE (org_id, slug), CONSTRAINT fk_workspaces_org_id FOREIGN KEY (org_id) REFERENCES core.organizations(org_id) ); CREATE INDEX idx_workspaces_org_id ON core.workspaces(org_id); CREATE TRIGGER trigger_workspaces_updated_at BEFORE UPDATE ON core.workspaces FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); CREATE TABLE core.role_assignments ( assignment_id UUID NOT NULL DEFAULT uuidv7(), role_id UUID NOT NULL, person_id UUID NOT NULL, org_id UUID NOT NULL, scope_type VARCHAR(20) NOT NULL, scope_id UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_role_assignments PRIMARY KEY (assignment_id), CONSTRAINT uq_role_assignments_scope UNIQUE (role_id, person_id, org_id, scope_type, scope_id), CONSTRAINT fk_role_assignments_role_id FOREIGN KEY (role_id) REFERENCES core.roles(role_id), CONSTRAINT fk_role_assignments_person_id FOREIGN KEY (person_id) REFERENCES core.persons(person_id), CONSTRAINT fk_role_assignments_org_id FOREIGN KEY (org_id) REFERENCES core.organizations(org_id) ); CREATE INDEX idx_role_assignments_person_id ON core.role_assignments(person_id); CREATE INDEX idx_role_assignments_org_id ON core.role_assignments(org_id); CREATE TRIGGER trigger_role_assignments_updated_at BEFORE UPDATE ON core.role_assignments FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); -- --------------------------------------------------------------------------- -- Billing: accounts, subscriptions, invoicing & payments -- --------------------------------------------------------------------------- CREATE TABLE core.accounts ( billing_account_id UUID NOT NULL DEFAULT uuidv7(), org_id UUID NOT NULL, name VARCHAR(255) NOT NULL, status TEXT NOT NULL DEFAULT 'active', metadata JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_accounts PRIMARY KEY (billing_account_id), CONSTRAINT fk_accounts_org_id FOREIGN KEY (org_id) REFERENCES core.organizations(org_id) ); CREATE INDEX idx_accounts_org_id ON core.accounts(org_id); CREATE TRIGGER trigger_accounts_updated_at BEFORE UPDATE ON core.accounts FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); CREATE TABLE core.subscriptions ( subscription_id UUID NOT NULL DEFAULT uuidv7(), billing_account_id UUID NOT NULL, status TEXT NOT NULL DEFAULT 'active', current_period_start TIMESTAMPTZ, current_period_end TIMESTAMPTZ, cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE, canceled_at TIMESTAMPTZ, ended_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), commitment_end TIMESTAMPTZ, commitment_renewal VARCHAR(20), early_termination_policy VARCHAR(20), CONSTRAINT pk_subscriptions PRIMARY KEY (subscription_id), CONSTRAINT fk_subscriptions_billing_account_id FOREIGN KEY (billing_account_id) REFERENCES core.accounts(billing_account_id), CONSTRAINT chk_subscriptions_commitment_renewal CHECK (commitment_renewal IS NULL OR commitment_renewal IN ('auto_renew', 'expire')), CONSTRAINT chk_subscriptions_early_termination_policy CHECK (early_termination_policy IS NULL OR early_termination_policy IN ('block', 'fee', 'allow')), CONSTRAINT chk_subscriptions_commitment_fields_coherent CHECK ((commitment_end IS NULL AND early_termination_policy IS NULL) OR (commitment_end IS NOT NULL AND early_termination_policy IS NOT NULL)) ); CREATE INDEX idx_subscriptions_billing_account_id ON core.subscriptions(billing_account_id); CREATE INDEX idx_subscriptions_status ON core.subscriptions(status); CREATE TRIGGER trigger_subscriptions_updated_at BEFORE UPDATE ON core.subscriptions FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); CREATE TABLE core.subscription_items ( subscription_item_id UUID NOT NULL DEFAULT uuidv7(), subscription_id UUID NOT NULL, product_id UUID NOT NULL, price_id UUID NOT NULL, quantity INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_subscription_items PRIMARY KEY (subscription_item_id), CONSTRAINT fk_subscription_items_subscription_id FOREIGN KEY (subscription_id) REFERENCES core.subscriptions(subscription_id), CONSTRAINT fk_subscription_items_product_id FOREIGN KEY (product_id) REFERENCES core.products(product_id), CONSTRAINT fk_subscription_items_price_id FOREIGN KEY (price_id) REFERENCES core.prices(price_id) ); CREATE INDEX idx_subscription_items_subscription_id ON core.subscription_items(subscription_id); CREATE TRIGGER trigger_subscription_items_updated_at BEFORE UPDATE ON core.subscription_items FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); -- append-only log; no updated_at column, correctly no trigger. CREATE TABLE core.subscription_changes ( change_id UUID NOT NULL DEFAULT uuidv7(), subscription_id UUID NOT NULL, previous_status TEXT, new_status TEXT NOT NULL, stripe_event_id TEXT NOT NULL, changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_subscription_changes PRIMARY KEY (change_id), CONSTRAINT fk_subscription_changes_subscription_id FOREIGN KEY (subscription_id) REFERENCES core.subscriptions(subscription_id) ); CREATE INDEX idx_subscription_changes_subscription_id ON core.subscription_changes(subscription_id); CREATE TABLE core.payment_methods ( payment_method_id UUID NOT NULL DEFAULT uuidv7(), billing_account_id UUID NOT NULL, pm_type TEXT NOT NULL, card_brand TEXT, card_last4 TEXT, card_exp_month INTEGER, card_exp_year INTEGER, funding TEXT, is_default BOOLEAN NOT NULL DEFAULT FALSE, detached_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_payment_methods PRIMARY KEY (payment_method_id), CONSTRAINT fk_payment_methods_billing_account_id FOREIGN KEY (billing_account_id) REFERENCES core.accounts(billing_account_id) ); CREATE INDEX idx_payment_methods_billing_account_id ON core.payment_methods(billing_account_id); CREATE TRIGGER trigger_payment_methods_updated_at BEFORE UPDATE ON core.payment_methods FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); CREATE TABLE core.invoices ( invoice_id UUID NOT NULL DEFAULT uuidv7(), billing_account_id UUID NOT NULL, subscription_id UUID, status TEXT NOT NULL DEFAULT 'open', amount_due INTEGER NOT NULL DEFAULT 0, amount_paid INTEGER NOT NULL DEFAULT 0, currency TEXT NOT NULL, period_start TIMESTAMPTZ, period_end TIMESTAMPTZ, due_date TIMESTAMPTZ, paid_at TIMESTAMPTZ, voided_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_invoices PRIMARY KEY (invoice_id), CONSTRAINT fk_invoices_billing_account_id FOREIGN KEY (billing_account_id) REFERENCES core.accounts(billing_account_id), CONSTRAINT fk_invoices_subscription_id FOREIGN KEY (subscription_id) REFERENCES core.subscriptions(subscription_id) ); CREATE INDEX idx_invoices_billing_account_id ON core.invoices(billing_account_id); CREATE INDEX idx_invoices_subscription_id ON core.invoices(subscription_id); CREATE INDEX idx_invoices_status ON core.invoices(status); CREATE TRIGGER trigger_invoices_updated_at BEFORE UPDATE ON core.invoices FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); CREATE TABLE core.invoice_line_items ( line_item_id UUID NOT NULL DEFAULT uuidv7(), invoice_id UUID NOT NULL, subscription_item_id UUID, price_id UUID NOT NULL, amount INTEGER NOT NULL DEFAULT 0, currency TEXT NOT NULL, description TEXT, quantity INTEGER NOT NULL DEFAULT 1, period_start TIMESTAMPTZ, period_end TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_invoice_line_items PRIMARY KEY (line_item_id), CONSTRAINT fk_invoice_line_items_invoice_id FOREIGN KEY (invoice_id) REFERENCES core.invoices(invoice_id), CONSTRAINT fk_invoice_line_items_subscription_item_id FOREIGN KEY (subscription_item_id) REFERENCES core.subscription_items(subscription_item_id), CONSTRAINT fk_invoice_line_items_price_id FOREIGN KEY (price_id) REFERENCES core.prices(price_id) ); CREATE INDEX idx_invoice_line_items_invoice_id ON core.invoice_line_items(invoice_id); CREATE TRIGGER trigger_invoice_line_items_updated_at BEFORE UPDATE ON core.invoice_line_items FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); -- distinct from the dropped legacy public.payments (db/00001/00002 in the -- old chain) -- this is the real billing ledger row. CREATE TABLE core.payments ( payment_id UUID NOT NULL DEFAULT uuidv7(), invoice_id UUID NOT NULL, billing_account_id UUID NOT NULL, payment_method_id UUID, amount INTEGER NOT NULL DEFAULT 0, currency TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', failed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_payments PRIMARY KEY (payment_id), CONSTRAINT fk_payments_invoice_id FOREIGN KEY (invoice_id) REFERENCES core.invoices(invoice_id), CONSTRAINT fk_payments_billing_account_id FOREIGN KEY (billing_account_id) REFERENCES core.accounts(billing_account_id), CONSTRAINT fk_payments_payment_method_id FOREIGN KEY (payment_method_id) REFERENCES core.payment_methods(payment_method_id) ); CREATE INDEX idx_payments_invoice_id ON core.payments(invoice_id); CREATE INDEX idx_payments_billing_account_id ON core.payments(billing_account_id); CREATE INDEX idx_payments_status ON core.payments(status); CREATE TRIGGER trigger_payments_updated_at BEFORE UPDATE ON core.payments FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); -- sole and universal representation of product-ladder ('plan') membership -- -- no label column on products (Doc 31 Amendment #3). CREATE TABLE core.plan_ladder_tiers ( plan_ladder_id UUID NOT NULL, product_id UUID NOT NULL, rank INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_plan_ladder_tiers PRIMARY KEY (plan_ladder_id, product_id), CONSTRAINT uq_plan_ladder_tiers_plan_ladder_id_rank UNIQUE (plan_ladder_id, rank), CONSTRAINT fk_plan_ladder_tiers_plan_ladder_id FOREIGN KEY (plan_ladder_id) REFERENCES core.plan_ladders(plan_ladder_id), CONSTRAINT fk_plan_ladder_tiers_product_id FOREIGN KEY (product_id) REFERENCES core.products(product_id) ); CREATE INDEX idx_plan_ladder_tiers_product_id ON core.plan_ladder_tiers(product_id); CREATE TRIGGER trigger_plan_ladder_tiers_updated_at BEFORE UPDATE ON core.plan_ladder_tiers FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); CREATE TABLE core.subscription_scheduled_changes ( scheduled_change_id UUID NOT NULL DEFAULT uuidv7(), subscription_id UUID NOT NULL, change_type VARCHAR(30) NOT NULL, target_price_id UUID, target_quantity INTEGER, effective_at TIMESTAMPTZ NOT NULL, effective_trigger VARCHAR(20) NOT NULL, credit_disposition VARCHAR(10), status VARCHAR(20) NOT NULL DEFAULT 'scheduled', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_subscription_scheduled_changes PRIMARY KEY (scheduled_change_id), CONSTRAINT fk_subscription_scheduled_changes_subscription_id FOREIGN KEY (subscription_id) REFERENCES core.subscriptions(subscription_id), CONSTRAINT fk_subscription_scheduled_changes_target_price_id FOREIGN KEY (target_price_id) REFERENCES core.prices(price_id), CONSTRAINT chk_ssc_change_type CHECK (change_type IN ('plan_switch', 'quantity_change', 'cancellation')), CONSTRAINT chk_ssc_effective_trigger CHECK (effective_trigger IN ('at_date', 'period_end', 'term_boundary')), CONSTRAINT chk_ssc_status CHECK (status IN ('scheduled', 'applied', 'superseded', 'canceled')), CONSTRAINT chk_ssc_credit_disposition CHECK (credit_disposition IS NULL OR credit_disposition IN ('ledger', 'cash')) ); CREATE INDEX idx_ssc_subscription_id ON core.subscription_scheduled_changes(subscription_id); CREATE INDEX idx_ssc_due ON core.subscription_scheduled_changes(effective_at) WHERE status = 'scheduled'; CREATE TRIGGER trigger_subscription_scheduled_changes_updated_at BEFORE UPDATE ON core.subscription_scheduled_changes FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); -- --------------------------------------------------------------------------- -- Entitlements: pools, grants & provisions -- --------------------------------------------------------------------------- CREATE TABLE core.resource_pools ( pool_id UUID NOT NULL DEFAULT uuidv7(), org_id UUID NOT NULL, name VARCHAR(255) NOT NULL, slug VARCHAR(100) NOT NULL, pool_type VARCHAR(20) NOT NULL, is_auto_managed BOOLEAN NOT NULL DEFAULT FALSE, description TEXT, status VARCHAR(20) NOT NULL DEFAULT 'active', suspended_at TIMESTAMPTZ, archived_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_resource_pools PRIMARY KEY (pool_id), CONSTRAINT uq_resource_pools_org_id_slug UNIQUE (org_id, slug), CONSTRAINT fk_resource_pools_org_id FOREIGN KEY (org_id) REFERENCES core.organizations(org_id) ); CREATE INDEX idx_resource_pools_org_id ON core.resource_pools(org_id); CREATE TRIGGER trigger_resource_pools_updated_at BEFORE UPDATE ON core.resource_pools FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); CREATE TABLE core.pool_assignments ( assignment_id UUID NOT NULL DEFAULT uuidv7(), pool_id UUID NOT NULL, workspace_id UUID NOT NULL, is_primary BOOLEAN NOT NULL DEFAULT FALSE, status VARCHAR(20) NOT NULL DEFAULT 'active', suspended_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_pool_assignments PRIMARY KEY (assignment_id), CONSTRAINT uq_pool_assignments_pool_id_workspace_id UNIQUE (pool_id, workspace_id), CONSTRAINT fk_pool_assignments_pool_id FOREIGN KEY (pool_id) REFERENCES core.resource_pools(pool_id), CONSTRAINT fk_pool_assignments_workspace_id FOREIGN KEY (workspace_id) REFERENCES core.workspaces(workspace_id) ); CREATE INDEX idx_pool_assignments_workspace_id ON core.pool_assignments(workspace_id); CREATE INDEX idx_pool_assignments_pool_id ON core.pool_assignments(pool_id); CREATE TRIGGER trigger_pool_assignments_updated_at BEFORE UPDATE ON core.pool_assignments FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); CREATE TABLE core.entitlement_sets ( set_id UUID NOT NULL DEFAULT uuidv7(), name VARCHAR(255) NOT NULL, description TEXT, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_entitlement_sets PRIMARY KEY (set_id), CONSTRAINT uq_entitlement_sets_name UNIQUE (name) ); CREATE TRIGGER trigger_entitlement_sets_updated_at BEFORE UPDATE ON core.entitlement_sets FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); -- granted_to_billing_account_id has NO FK -- heterogeneous recipient arc, -- see chk_grants_recipient. CREATE TABLE core.grants ( grant_id UUID NOT NULL DEFAULT uuidv7(), product_id UUID, granted_to_billing_account_id UUID, granted_to_org_id UUID, granted_to_person_id UUID, granted_by_person_id UUID, grant_reason VARCHAR(50) NOT NULL, description TEXT, quantity INTEGER NOT NULL DEFAULT 1, valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(), valid_until TIMESTAMPTZ, status VARCHAR(20) NOT NULL DEFAULT 'active', revoked_at TIMESTAMPTZ, revoked_by_person_id UUID, revocation_reason TEXT, metadata JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), entitlement_set_id UUID, extends_grant_id UUID, CONSTRAINT pk_grants PRIMARY KEY (grant_id), CONSTRAINT fk_grants_product_id FOREIGN KEY (product_id) REFERENCES core.products(product_id), CONSTRAINT fk_grants_granted_to_org_id FOREIGN KEY (granted_to_org_id) REFERENCES core.organizations(org_id), CONSTRAINT fk_grants_granted_to_person_id FOREIGN KEY (granted_to_person_id) REFERENCES core.persons(person_id), CONSTRAINT fk_grants_granted_by_person_id FOREIGN KEY (granted_by_person_id) REFERENCES core.persons(person_id), CONSTRAINT fk_grants_revoked_by_person_id FOREIGN KEY (revoked_by_person_id) REFERENCES core.persons(person_id), CONSTRAINT fk_grants_entitlement_set_id FOREIGN KEY (entitlement_set_id) REFERENCES core.entitlement_sets(set_id), CONSTRAINT fk_grants_extends_grant_id FOREIGN KEY (extends_grant_id) REFERENCES core.grants(grant_id), CONSTRAINT chk_grants_recipient CHECK ((granted_to_billing_account_id IS NOT NULL AND granted_to_org_id IS NULL AND granted_to_person_id IS NULL) OR (granted_to_billing_account_id IS NULL AND granted_to_org_id IS NOT NULL AND granted_to_person_id IS NULL) OR (granted_to_billing_account_id IS NULL AND granted_to_org_id IS NULL AND granted_to_person_id IS NOT NULL)), CONSTRAINT chk_grants_system_actor CHECK (granted_by_person_id IS NOT NULL OR grant_reason = 'default'), CONSTRAINT chk_grants_entitlement_source CHECK (entitlement_set_id IS NOT NULL OR product_id IS NOT NULL), CONSTRAINT chk_grants_no_self_extend CHECK (extends_grant_id IS NULL OR extends_grant_id != grant_id) ); CREATE INDEX idx_grants_org_id ON core.grants(granted_to_org_id); CREATE INDEX idx_grants_product_id ON core.grants(product_id); CREATE INDEX idx_grants_extends_grant_id ON core.grants(extends_grant_id) WHERE extends_grant_id IS NOT NULL; CREATE TRIGGER trigger_grants_updated_at BEFORE UPDATE ON core.grants FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); CREATE TRIGGER trigger_grant_lineage_same_org BEFORE INSERT OR UPDATE ON core.grants FOR EACH ROW EXECUTE FUNCTION core.check_grant_lineage_same_org(); CREATE TRIGGER trigger_grant_lineage_immutable BEFORE UPDATE ON core.grants FOR EACH ROW EXECUTE FUNCTION core.check_grant_lineage_immutable(); -- billing_account_id/subscription_id/purchase_id have NO FK -- -- heterogeneous source arc, see chk_pool_provisions_source (purchase_id is -- a loose slot; no purchases table exists). CREATE TABLE core.pool_provisions ( provision_id UUID NOT NULL DEFAULT uuidv7(), pool_id UUID NOT NULL, billing_account_id UUID, subscription_id UUID, purchase_id UUID, grant_id UUID, quantity INTEGER NOT NULL DEFAULT 1, status VARCHAR(20) NOT NULL DEFAULT 'active', activated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), suspended_at TIMESTAMPTZ, ended_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), entitlement_set_id UUID NOT NULL, CONSTRAINT pk_pool_provisions PRIMARY KEY (provision_id), CONSTRAINT fk_pool_provisions_pool_id FOREIGN KEY (pool_id) REFERENCES core.resource_pools(pool_id), CONSTRAINT fk_pool_provisions_grant_id FOREIGN KEY (grant_id) REFERENCES core.grants(grant_id), CONSTRAINT fk_pool_provisions_entitlement_set_id FOREIGN KEY (entitlement_set_id) REFERENCES core.entitlement_sets(set_id), CONSTRAINT chk_pool_provisions_source CHECK ((subscription_id IS NOT NULL AND purchase_id IS NULL AND grant_id IS NULL) OR (subscription_id IS NULL AND purchase_id IS NOT NULL AND grant_id IS NULL) OR (subscription_id IS NULL AND purchase_id IS NULL AND grant_id IS NOT NULL)) ); CREATE INDEX idx_pool_provisions_pool_id ON core.pool_provisions(pool_id); CREATE INDEX idx_pool_provisions_grant_id ON core.pool_provisions(grant_id); CREATE INDEX idx_pool_provisions_entitlement_set_id ON core.pool_provisions(entitlement_set_id); CREATE TRIGGER trigger_pool_provisions_updated_at BEFORE UPDATE ON core.pool_provisions FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); CREATE TRIGGER trigger_sync_pool_provision_ladders AFTER UPDATE ON core.pool_provisions FOR EACH ROW EXECUTE FUNCTION core.sync_pool_provision_ladders(); -- pool_id/status/activated_at/ended_at are denormalized from -- pool_provisions, kept in sync by the core.sync_pool_provision_ladders() -- AFTER UPDATE trigger on pool_provisions. The EXCLUDE constraint requires -- btree_gist (see extension near top of file); WHERE (status = 'active') -- caps it to at most one active attachment per (pool, ladder) at any -- instant. CREATE TABLE core.pool_provision_ladders ( provision_id UUID NOT NULL, plan_ladder_id UUID NOT NULL, pool_id UUID NOT NULL, status VARCHAR(20) NOT NULL, activated_at TIMESTAMPTZ NOT NULL, ended_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), product_id UUID NOT NULL, CONSTRAINT pk_pool_provision_ladders PRIMARY KEY (provision_id, plan_ladder_id), CONSTRAINT fk_pool_provision_ladders_provision_id FOREIGN KEY (provision_id) REFERENCES core.pool_provisions(provision_id), CONSTRAINT fk_pool_provision_ladders_plan_ladder_id FOREIGN KEY (plan_ladder_id) REFERENCES core.plan_ladders(plan_ladder_id), CONSTRAINT fk_pool_provision_ladders_pool_id FOREIGN KEY (pool_id) REFERENCES core.resource_pools(pool_id), CONSTRAINT fk_pool_provision_ladders_product_id FOREIGN KEY (product_id) REFERENCES core.products(product_id), CONSTRAINT excl_pool_provision_ladders_active_overlap EXCLUDE USING gist ( pool_id WITH =, plan_ladder_id WITH =, tstzrange(activated_at, ended_at, '[)') WITH && ) WHERE (status = 'active') ); CREATE INDEX idx_pool_provision_ladders_pool_ladder ON core.pool_provision_ladders(pool_id, plan_ladder_id); CREATE INDEX idx_pool_provision_ladders_plan_ladder ON core.pool_provision_ladders(plan_ladder_id); CREATE INDEX idx_pool_provision_ladders_product_id ON core.pool_provision_ladders(product_id); CREATE TRIGGER trigger_pool_provision_ladders_updated_at BEFORE UPDATE ON core.pool_provision_ladders FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); -- append-only audit log; no updated_at column, correctly no trigger. CREATE TABLE core.pool_provision_transitions ( transition_id UUID NOT NULL DEFAULT uuidv7(), pool_id UUID NOT NULL, provision_id UUID, plan_ladder_id UUID NOT NULL, from_rank INTEGER, to_rank INTEGER, transition_type VARCHAR(20) NOT NULL, actor_type VARCHAR(20) NOT NULL, actor_id UUID, reason TEXT, effective_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_pool_provision_transitions PRIMARY KEY (transition_id), CONSTRAINT fk_pool_provision_transitions_pool_id FOREIGN KEY (pool_id) REFERENCES core.resource_pools(pool_id), CONSTRAINT fk_pool_provision_transitions_provision_id FOREIGN KEY (provision_id) REFERENCES core.pool_provisions(provision_id), CONSTRAINT fk_pool_provision_transitions_plan_ladder_id FOREIGN KEY (plan_ladder_id) REFERENCES core.plan_ladders(plan_ladder_id), CONSTRAINT chk_pool_provision_transitions_type CHECK (transition_type IN ('initiate', 'upgrade', 'downgrade', 'end', 'extend')), CONSTRAINT chk_pool_provision_transitions_actor_type CHECK (actor_type IN ('operator', 'system', 'webhook')), CONSTRAINT chk_pool_provision_transitions_operator_actor CHECK (actor_type != 'operator' OR actor_id IS NOT NULL) ); CREATE INDEX idx_pool_provision_transitions_pool_id ON core.pool_provision_transitions(pool_id); CREATE INDEX idx_pool_provision_transitions_provision_id ON core.pool_provision_transitions(provision_id); CREATE INDEX idx_pool_provision_transitions_plan_ladder_id ON core.pool_provision_transitions(plan_ladder_id); CREATE INDEX idx_pool_provision_transitions_effective_at ON core.pool_provision_transitions(effective_at); -- --------------------------------------------------------------------------- -- Provider registry & numeric entitlements -- --------------------------------------------------------------------------- -- has updated_at but no maintaining trigger anywhere in the old migration -- chain -- pre-existing gap, not fixed here. CREATE TABLE core.providers ( slug TEXT NOT NULL, provider_kind TEXT NOT NULL, display_name TEXT NOT NULL, operator_surface_path TEXT, status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_providers PRIMARY KEY (slug), CONSTRAINT chk_providers_slug_hygiene CHECK (slug ~ '^[a-z0-9]+$'), CONSTRAINT chk_providers_kind_valid CHECK (provider_kind IN ('payment', 'provisioning', 'notification', 'tax')) ); -- no updated_at column, correctly no trigger. provider NULL = platform- -- owned/pooled. CREATE TABLE core.resource_keys ( resource_key VARCHAR(100) NOT NULL, display_name VARCHAR(255) NOT NULL, description TEXT, unit VARCHAR(50) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), provider TEXT, CONSTRAINT pk_resource_keys PRIMARY KEY (resource_key), CONSTRAINT fk_resource_keys_provider FOREIGN KEY (provider) REFERENCES core.providers(slug) ); CREATE TABLE core.numeric_entitlements ( entitlement_id UUID NOT NULL DEFAULT uuidv7(), pool_id UUID NOT NULL, resource_key VARCHAR(100) NOT NULL, entitlement_type VARCHAR(20) NOT NULL, resource_limit BIGINT NOT NULL DEFAULT 0, reset_period VARCHAR(20), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_numeric_entitlements PRIMARY KEY (entitlement_id), CONSTRAINT uq_numeric_entitlements_pool_id_resource_key UNIQUE (pool_id, resource_key), CONSTRAINT fk_numeric_entitlements_pool_id FOREIGN KEY (pool_id) REFERENCES core.resource_pools(pool_id), CONSTRAINT fk_numeric_entitlements_resource_key FOREIGN KEY (resource_key) REFERENCES core.resource_keys(resource_key) ); CREATE INDEX idx_numeric_entitlements_pool_id ON core.numeric_entitlements(pool_id); CREATE TRIGGER trigger_numeric_entitlements_updated_at BEFORE UPDATE ON core.numeric_entitlements FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); CREATE TABLE core.numeric_entitlement_contributions ( contribution_id UUID NOT NULL DEFAULT uuidv7(), entitlement_id UUID NOT NULL, provision_id UUID NOT NULL, contributed_value BIGINT NOT NULL, stacking_policy VARCHAR(20) NOT NULL DEFAULT 'additive', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_numeric_entitlement_contributions PRIMARY KEY (contribution_id), CONSTRAINT uq_numeric_entitlement_contributions_scope UNIQUE (entitlement_id, provision_id), CONSTRAINT fk_numeric_entitlement_contributions_entitlement_id FOREIGN KEY (entitlement_id) REFERENCES core.numeric_entitlements(entitlement_id), CONSTRAINT fk_numeric_entitlement_contributions_provision_id FOREIGN KEY (provision_id) REFERENCES core.pool_provisions(provision_id) ); CREATE INDEX idx_numeric_entitlement_contributions_entitlement_id ON core.numeric_entitlement_contributions(entitlement_id); CREATE INDEX idx_numeric_entitlement_contributions_provision_id ON core.numeric_entitlement_contributions(provision_id); CREATE TRIGGER trigger_numeric_entitlement_contributions_updated_at BEFORE UPDATE ON core.numeric_entitlement_contributions FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); -- resource_key is a denormalized copy, no FK of its own. hot-path composite -- index for entitlement checks (D11). CREATE TABLE core.numeric_entitlement_usage ( usage_id UUID NOT NULL DEFAULT uuidv7(), entitlement_id UUID NOT NULL, pool_id UUID NOT NULL, resource_key VARCHAR(100) NOT NULL, current_usage BIGINT NOT NULL DEFAULT 0, current_period_start TIMESTAMPTZ, current_period_end TIMESTAMPTZ, last_reset_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_numeric_entitlement_usage PRIMARY KEY (usage_id), CONSTRAINT uq_numeric_entitlement_usage_entitlement_id UNIQUE (entitlement_id), CONSTRAINT fk_numeric_entitlement_usage_entitlement_id FOREIGN KEY (entitlement_id) REFERENCES core.numeric_entitlements(entitlement_id), CONSTRAINT fk_numeric_entitlement_usage_pool_id FOREIGN KEY (pool_id) REFERENCES core.resource_pools(pool_id) ); CREATE INDEX idx_numeric_entitlement_usage_pool_resource ON core.numeric_entitlement_usage(pool_id, resource_key); CREATE TRIGGER trigger_numeric_entitlement_usage_updated_at BEFORE UPDATE ON core.numeric_entitlement_usage FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); -- credit rules (NULL resource_key) and deactivated history are -- unconstrained by design. CREATE TABLE core.entitlement_set_rules ( rule_id UUID NOT NULL DEFAULT uuidv7(), set_id UUID NOT NULL, rule_type VARCHAR(20) NOT NULL, resource_key VARCHAR(100), resource_value BIGINT, resource_per_unit BOOLEAN, stacking_policy VARCHAR(20) DEFAULT 'additive', reset_period VARCHAR(20), credit_amount INTEGER, credit_currency VARCHAR(3), description TEXT, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_entitlement_set_rules PRIMARY KEY (rule_id), CONSTRAINT fk_entitlement_set_rules_set_id FOREIGN KEY (set_id) REFERENCES core.entitlement_sets(set_id), CONSTRAINT fk_entitlement_set_rules_resource_key FOREIGN KEY (resource_key) REFERENCES core.resource_keys(resource_key), CONSTRAINT chk_entitlement_set_rules_type CHECK ((rule_type = 'boolean' AND resource_key IS NOT NULL AND resource_value IS NULL AND stacking_policy IS NULL AND reset_period IS NULL AND resource_per_unit IS NULL AND credit_amount IS NULL AND credit_currency IS NULL) OR (rule_type = 'limit' AND resource_key IS NOT NULL AND resource_value IS NOT NULL AND reset_period IS NULL AND credit_amount IS NULL AND credit_currency IS NULL) OR (rule_type = 'quota' AND resource_key IS NOT NULL AND resource_value IS NOT NULL AND reset_period IS NOT NULL AND credit_amount IS NULL AND credit_currency IS NULL) OR (rule_type = 'credit' AND credit_amount IS NOT NULL AND credit_currency IS NOT NULL AND resource_key IS NULL AND resource_value IS NULL AND stacking_policy IS NULL AND reset_period IS NULL AND resource_per_unit IS NULL)) ); CREATE INDEX idx_entitlement_set_rules_set_id ON core.entitlement_set_rules(set_id); CREATE UNIQUE INDEX uq_entitlement_set_rules_active_resource_key ON core.entitlement_set_rules(set_id, resource_key) WHERE is_active = TRUE AND resource_key IS NOT NULL; CREATE TRIGGER trigger_entitlement_set_rules_updated_at BEFORE UPDATE ON core.entitlement_set_rules FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); CREATE TABLE core.provider_operations ( provider TEXT NOT NULL, operation TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_provider_operations PRIMARY KEY (provider, operation), CONSTRAINT fk_provider_operations_provider FOREIGN KEY (provider) REFERENCES core.providers(slug) ON DELETE CASCADE, CONSTRAINT chk_provider_operations_verb_valid CHECK (operation IN ('create', 'set_status', 'delete', 'list', 'describe')) ); -- hygiene format, not a closed enum -- per-provider vocabulary. CREATE TABLE core.provider_states ( provider TEXT NOT NULL, state TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_provider_states PRIMARY KEY (provider, state), CONSTRAINT fk_provider_states_provider FOREIGN KEY (provider) REFERENCES core.providers(slug) ON DELETE CASCADE, CONSTRAINT chk_provider_states_value_hygiene CHECK (state ~ '^[a-z][a-z0-9_]*$') ); -- --------------------------------------------------------------------------- -- Integration infrastructure: webhooks & outbox -- --------------------------------------------------------------------------- -- Idempotent inbound webhook capture, partitioned monthly. Processors poll by -- status; partitioning keeps old data manageable. CREATE TABLE core.webhook_events ( id BIGINT GENERATED ALWAYS AS IDENTITY, provider TEXT NOT NULL, provider_event_id TEXT NOT NULL, event_type TEXT NOT NULL, payload JSONB NOT NULL DEFAULT '{}', status TEXT NOT NULL DEFAULT 'received', retry_count INT NOT NULL DEFAULT 0, error_message TEXT, received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), processed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_webhook_events PRIMARY KEY (id, received_at), CONSTRAINT uq_webhook_events_provider_event UNIQUE (provider, provider_event_id, received_at) ) PARTITION BY RANGE (received_at); -- +goose StatementBegin -- Create initial partitions: current month + next 2 months. Future -- partitions are created by application boot or a scheduler. DO $$ DECLARE m_start DATE; m_end DATE; part TEXT; BEGIN FOR i IN 0..2 LOOP m_start := DATE_TRUNC('month', NOW()) + (i || ' months')::INTERVAL; m_end := m_start + '1 month'::INTERVAL; part := 'core.webhook_events_' || TO_CHAR(m_start, 'YYYY_MM'); EXECUTE FORMAT( 'CREATE TABLE IF NOT EXISTS %s PARTITION OF core.webhook_events FOR VALUES FROM (%L) TO (%L)', part, m_start, m_end ); END LOOP; END $$; -- +goose StatementEnd CREATE INDEX idx_webhook_events_status_received ON core.webhook_events (status, received_at) WHERE status IN ('received', 'failed'); CREATE INDEX idx_webhook_events_provider_event ON core.webhook_events (provider, provider_event_id); -- has updated_at but no maintaining trigger anywhere in the old migration -- chain -- pre-existing gap, not fixed here (same gap as core.providers). -- Transactional outbound actions with retry + dead-letter. CREATE TABLE core.outbox ( id BIGINT GENERATED ALWAYS AS IDENTITY, provider TEXT NOT NULL, action_type TEXT NOT NULL, payload JSONB NOT NULL DEFAULT '{}', status TEXT NOT NULL DEFAULT 'pending', attempts INT NOT NULL DEFAULT 0, max_attempts INT NOT NULL DEFAULT 5, next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), error_message TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_outbox PRIMARY KEY (id) ); CREATE INDEX idx_outbox_pending ON core.outbox (status, next_attempt_at) WHERE status IN ('pending', 'failed'); -- has updated_at but no maintaining trigger (same gap). -- --------------------------------------------------------------------------- -- Derived views -- --------------------------------------------------------------------------- -- Derived-kind view: consumers asking "is this a plan product?" read this -- instead of products.product_type, which has nothing to say for plans -- (Doc 31 Amendment #3 -- plan-ness is structural, via plan_ladder_tiers -- membership, not a label column). CREATE VIEW core.product_kinds AS SELECT p.product_id, (CASE WHEN EXISTS ( SELECT 1 FROM core.plan_ladder_tiers t WHERE t.product_id = p.product_id ) THEN 'plan' ELSE p.product_type END)::VARCHAR AS product_kind FROM core.products p; -- --------------------------------------------------------------------------- -- Roles (Decision 3): the core role triple only. Integration streams -- (fedwiki, stripe) each create and grant their own {slug}_owner/writer/ -- reader roles in their own migration stream's 00001 instead of here -- (Decision 7 -- self-contained ownership; core always runs first (core -> -- fedwiki -> stripe, per internal/migrate/sources.go), so core_reader -- exists by the time each integration stream's cross-schema reader grant -- runs). -- -- Roles are cluster-global, not database-scoped, so creation is guarded: -- a second database in the same cluster must not abort this stream. -- --------------------------------------------------------------------------- -- +goose StatementBegin DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'core_owner') THEN CREATE ROLE core_owner NOLOGIN; END IF; IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'core_writer') THEN CREATE ROLE core_writer NOLOGIN; END IF; IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'core_reader') THEN CREATE ROLE core_reader NOLOGIN; END IF; END $$; -- +goose StatementEnd -- Schema-level access. GRANT USAGE ON SCHEMA core TO core_reader, core_writer, core_owner; GRANT CREATE ON SCHEMA core TO core_owner; -- Table-level privileges (also covers core.product_kinds: GRANT ON ALL -- TABLES IN SCHEMA includes views, and the view already exists above). GRANT ALL ON ALL TABLES IN SCHEMA core TO core_owner; GRANT ALL ON ALL TABLES IN SCHEMA core TO core_writer; GRANT SELECT ON ALL TABLES IN SCHEMA core TO core_reader; -- Cross-schema reader inheritance: now that identity/organization/billing/ -- entitlements/integration are one schema, the five intra-core edges from -- the old per-module reader-inheritance pattern dissolve into a no-op and -- are not reproduced here. Integration streams' equivalent grants -- (`GRANT core_reader TO {slug}_writer` for fedwiki and stripe) live in -- their own stream's 00001 (Decision 7). -- member_console (the DSN login role, created by Postgres initdb via -- POSTGRES_USER) previously acquired privileges only by being a cluster -- superuser -- undocumented in-repo (design.md Decision 3 gap). State its -- application-level privileges explicitly; owner roles stay reserved for -- migrations. Each integration stream's own `GRANT {slug}_writer TO -- member_console` lives in its own stream's 00001 (Decision 7). GRANT core_writer TO member_console; -- +goose Down -- +goose StatementBegin REVOKE core_writer FROM member_console; REVOKE SELECT ON ALL TABLES IN SCHEMA core FROM core_reader; REVOKE ALL ON ALL TABLES IN SCHEMA core FROM core_writer; REVOKE ALL ON ALL TABLES IN SCHEMA core FROM core_owner; REVOKE CREATE ON SCHEMA core FROM core_owner; REVOKE USAGE ON SCHEMA core FROM core_reader, core_writer, core_owner; -- Roles are cluster-global: another database in this cluster may still own -- objects or hold grants under them, in which case DROP ROLE raises -- dependent_objects_still_exist. Leave such a role standing rather than -- failing this database's rollback over another database's state. DO $$ DECLARE role_name TEXT; BEGIN FOREACH role_name IN ARRAY ARRAY['core_reader', 'core_writer', 'core_owner'] LOOP BEGIN EXECUTE format('DROP ROLE IF EXISTS %I', role_name); EXCEPTION WHEN dependent_objects_still_exist THEN RAISE NOTICE 'role % is still in use elsewhere in this cluster; left in place', role_name; END; END LOOP; END $$; DROP VIEW core.product_kinds; -- +goose StatementEnd -- +goose StatementBegin DROP TABLE IF EXISTS core.outbox CASCADE; DROP TABLE IF EXISTS core.webhook_events CASCADE; DROP TABLE IF EXISTS core.provider_states CASCADE; DROP TABLE IF EXISTS core.provider_operations CASCADE; DROP TABLE IF EXISTS core.entitlement_set_rules CASCADE; DROP TABLE IF EXISTS core.numeric_entitlement_usage CASCADE; DROP TABLE IF EXISTS core.numeric_entitlement_contributions CASCADE; DROP TABLE IF EXISTS core.numeric_entitlements CASCADE; DROP TABLE IF EXISTS core.resource_keys CASCADE; DROP TABLE IF EXISTS core.providers CASCADE; DROP TABLE IF EXISTS core.pool_provision_transitions CASCADE; DROP TABLE IF EXISTS core.pool_provision_ladders CASCADE; DROP TABLE IF EXISTS core.pool_provisions CASCADE; DROP TABLE IF EXISTS core.grants CASCADE; DROP TABLE IF EXISTS core.entitlement_sets CASCADE; DROP TABLE IF EXISTS core.pool_assignments CASCADE; DROP TABLE IF EXISTS core.resource_pools CASCADE; DROP TABLE IF EXISTS core.subscription_scheduled_changes CASCADE; DROP TABLE IF EXISTS core.plan_ladder_tiers CASCADE; DROP TABLE IF EXISTS core.payments CASCADE; DROP TABLE IF EXISTS core.invoice_line_items CASCADE; DROP TABLE IF EXISTS core.invoices CASCADE; DROP TABLE IF EXISTS core.payment_methods CASCADE; DROP TABLE IF EXISTS core.subscription_changes CASCADE; DROP TABLE IF EXISTS core.subscription_items CASCADE; DROP TABLE IF EXISTS core.subscriptions CASCADE; DROP TABLE IF EXISTS core.accounts CASCADE; DROP TABLE IF EXISTS core.role_assignments CASCADE; DROP TABLE IF EXISTS core.workspaces CASCADE; DROP TABLE IF EXISTS core.org_members CASCADE; DROP TABLE IF EXISTS core.roles CASCADE; DROP TABLE IF EXISTS core.organizations CASCADE; DROP TABLE IF EXISTS core.org_types CASCADE; DROP TABLE IF EXISTS core.plan_ladders CASCADE; DROP TABLE IF EXISTS core.prices CASCADE; DROP TABLE IF EXISTS core.products CASCADE; DROP TABLE IF EXISTS core.persons CASCADE; DROP TABLE IF EXISTS core.users CASCADE; -- +goose StatementEnd -- +goose StatementBegin DROP FUNCTION IF EXISTS core.check_grant_lineage_immutable(); DROP FUNCTION IF EXISTS core.check_grant_lineage_same_org(); DROP FUNCTION IF EXISTS core.sync_pool_provision_ladders(); DROP FUNCTION IF EXISTS public.update_updated_at_column(); -- +goose StatementEnd -- btree_gist is left in place (harmless, potentially shared with other -- extensions/roles) -- mirrors the old chain, which never dropped it either. DROP SCHEMA IF EXISTS core;