-- +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 [REDACTED]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 [REDACTED]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 [REDACTED]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 [REDACTED]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 [REDACTED]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 [REDACTED]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 [REDACTED]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 [REDACTED]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 [REDACTED]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 [REDACTED]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 [REDACTED]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 [REDACTED]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 [REDACTED]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 [REDACTED]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 [REDACTED]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;
