Files
member-console/internal/db/migrations/00018_entitlement_set_changes.sql
T
cgalo5758 3727ff31d8 Add entitlement set rule change ledger and preview flow
Add an append-only ledger of entitlement set rule changes with per-pool
effect rows, a preview-and-commit rule change flow, and an automatic
drain that settles deferred recomputations. Rules gain a tier reduction
policy, resource keys declare over-limit behavior, and the materializer
now lowers limits when a rule stops applying.
Add entitlement set rule change ledger and preview flow

Add an append-only ledger of entitlement set rule changes with a
preview-and-commit operator flow. Rule writes now go through an enclosed
`core.commit_rule_change` function that files an act row and one
obligation per carrying pool, with a drain workflow settling deferred
recomputations. The preview dry-runs the materializer with a rule
overlay and renders per-pool buckets, reduction-policy disclosures, and
provider over-limit consequences. Materializing transactions take a
shared advisory rendezvous that rule changes hold exclusively, enforced
by a possession assertion. Add History and Entitlement changes surfaces,
a rule-less warning on five product-selection surfaces, and a
`tier_reduction_policy` column that gates FedWiki parking.
2026-09-15 03:53:28 -05:00

609 lines
32 KiB
PL/PgSQL

-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
-- SPDX-FileCopyrightText: 2025-2026 Christian Galo
-- +goose Up
-- ---------------------------------------------------------------------------
-- Entitlement set rule changes (Decisions 142 to 148; openspec change
-- entitlement-set-changes). A rule change is committed through an enclosed
-- function that writes the rule, one act row and one obligation per carrying
-- pool in the same transaction; the console materializes the pools it can
-- inside that transaction and a drain settles the rest. Every materializing
-- transaction holds the materialization rendezvous, an advisory lock, which
-- the enclosure asserts rather than trusts.
-- ---------------------------------------------------------------------------
-- Decision 147: the rule carries how a reduction below current allocation is
-- reconciled. The model's default is defer. The existing fedwiki_sites rules
-- are set to force_reduce so the FedWiki quota sweep keeps parking excess
-- sites exactly as it does today; leaving them at the default would stop
-- parking on the day this migration lands.
ALTER TABLE core.entitlement_set_rules
ADD COLUMN tier_reduction_policy VARCHAR(20) NOT NULL DEFAULT 'defer'
CONSTRAINT chk_entitlement_set_rules_tier_reduction_policy
CHECK (tier_reduction_policy IN ('block', 'defer', 'clamp', 'force_reduce'));
UPDATE core.entitlement_set_rules SET tier_reduction_policy = 'force_reduce'
WHERE resource_key = 'fedwiki_sites';
-- Decision 147 §7.3: a provider declares what happens to usage above a
-- lowered limit. Keys nobody owns deny new use and nothing else; the values
-- for a provider's key are stamped at boot with its provider, because that
-- is when the console learns who owns the key.
ALTER TABLE core.resource_keys
ADD COLUMN over_limit_behavior VARCHAR(20) NOT NULL DEFAULT 'deny_new'
CONSTRAINT chk_resource_keys_over_limit_behavior
CHECK (over_limit_behavior IN ('deny_new', 'park', 'reclaim')),
ADD COLUMN over_limit_consequence VARCHAR(255),
ADD CONSTRAINT platform_keys_deny_new
CHECK (provider IS NOT NULL OR over_limit_behavior = 'deny_new');
-- Decision 143: the carrying population of a set is read through this index.
CREATE INDEX idx_pool_provisions_carrying_set
ON core.pool_provisions (entitlement_set_id, pool_id) WHERE status = 'active';
-- Decision 148: rule presence is a diagnostics dimension of the product
-- shape view. CREATE OR REPLACE appends the two columns; the rest is 00006's
-- definition unchanged.
CREATE OR REPLACE VIEW core.product_shape AS
SELECT
p.product_id,
p.lifecycle_status,
p.is_public,
(p.entitlement_set_id IS NOT NULL) AS set_present,
(SELECT COUNT(*) FROM core.plan_ladder_tiers t WHERE t.product_id = p.product_id) AS ladder_count,
CASE
WHEN COUNT(pr.price_id) FILTER (WHERE pr.is_active) = 0 THEN 'unpriced'
WHEN COUNT(pr.price_id) FILTER (WHERE pr.is_active AND pr.recurring_interval IS NOT NULL) > 0
AND COUNT(pr.price_id) FILTER (WHERE pr.is_active AND pr.recurring_interval IS NULL) > 0 THEN 'mixed'
WHEN COUNT(pr.price_id) FILTER (WHERE pr.is_active AND pr.recurring_interval IS NOT NULL) > 0 THEN 'recurring'
ELSE 'one_time'
END AS billing_shape,
CASE
WHEN COUNT(DISTINCT pr.usage_type) FILTER (WHERE pr.is_active AND pr.usage_type IS NOT NULL) = 0 THEN 'none'
WHEN COUNT(DISTINCT pr.usage_type) FILTER (WHERE pr.is_active AND pr.usage_type IS NOT NULL) > 1 THEN 'mixed'
WHEN COUNT(pr.price_id) FILTER (WHERE pr.is_active AND pr.usage_type = 'metered') > 0 THEN 'metered'
ELSE 'licensed'
END AS consumption_shape,
COALESCE((SELECT COUNT(*) FROM core.entitlement_set_rules r
WHERE r.set_id = p.entitlement_set_id AND r.is_active), 0) AS active_rule_count,
COALESCE((SELECT COUNT(*) FROM core.entitlement_set_rules r
WHERE r.set_id = p.entitlement_set_id AND r.is_active AND r.resource_per_unit IS TRUE), 0) AS per_unit_rule_count
FROM core.products p
LEFT JOIN core.prices pr ON pr.product_id = p.product_id
GROUP BY p.product_id, p.lifecycle_status, p.is_public, p.entitlement_set_id;
-- ---------------------------------------------------------------------------
-- Table 1: the act. One row per committed rule change, append-only. The
-- JSONB snapshots are the record; the typed columns beside them are
-- projections for the ledger's cells (Decision 145).
-- ---------------------------------------------------------------------------
CREATE TABLE core.entitlement_set_changes (
change_id UUID NOT NULL DEFAULT uuidv7(),
set_id UUID NOT NULL,
set_name VARCHAR(255) NOT NULL,
change_kind VARCHAR(20) NOT NULL,
rule_id UUID NOT NULL,
resource_key VARCHAR(100),
resource_label VARCHAR(255),
rule_before JSONB,
rule_after JSONB NOT NULL,
rule_type VARCHAR(20),
value_before BIGINT,
value_after BIGINT,
per_unit_before BOOLEAN,
per_unit_after BOOLEAN,
actor_type VARCHAR(20) NOT NULL,
actor_person_id UUID,
actor_service_account_id UUID,
note TEXT,
request_id VARCHAR(100),
sync_path VARCHAR(20) NOT NULL,
effective_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_entitlement_set_changes PRIMARY KEY (change_id),
CONSTRAINT fk_entitlement_set_changes_set_id FOREIGN KEY (set_id) REFERENCES core.entitlement_sets(set_id),
CONSTRAINT fk_entitlement_set_changes_rule_id FOREIGN KEY (rule_id) REFERENCES core.entitlement_set_rules(rule_id) ON DELETE RESTRICT,
CONSTRAINT fk_entitlement_set_changes_actor_person_id FOREIGN KEY (actor_person_id) REFERENCES core.persons(person_id),
CONSTRAINT chk_entitlement_set_changes_kind
CHECK (change_kind IN ('rule_added', 'rule_modified', 'rule_deactivated', 'rule_reactivated')),
CONSTRAINT chk_entitlement_set_changes_before_iff_not_added
CHECK ((change_kind = 'rule_added') = (rule_before IS NULL)),
CONSTRAINT chk_entitlement_set_changes_actor_type
CHECK (actor_type IN ('person', 'service_account', 'system')),
CONSTRAINT set_change_actor_coherent CHECK (
(actor_type = 'person' AND actor_person_id IS NOT NULL AND actor_service_account_id IS NULL)
OR (actor_type = 'service_account' AND actor_service_account_id IS NOT NULL AND actor_person_id IS NULL)
OR (actor_type = 'system' AND actor_person_id IS NULL AND actor_service_account_id IS NULL)),
CONSTRAINT chk_entitlement_set_changes_sync_path CHECK (sync_path IN ('atomic', 'deferred'))
);
COMMENT ON TABLE core.entitlement_set_changes IS
'Append-only ledger of committed entitlement set rule changes. Nothing updates or deletes a row.';
CREATE INDEX idx_entitlement_set_changes_set_id
ON core.entitlement_set_changes (set_id, effective_at DESC, created_at DESC, change_id DESC);
CREATE INDEX idx_entitlement_set_changes_rule_id ON core.entitlement_set_changes (rule_id);
-- ---------------------------------------------------------------------------
-- Table 2: the act's reach. A work list, deliberately mutable and not the
-- ledger: one row per pool the act enumerated, settled in the act's own
-- transaction below the synchronous cap and by the drain above it. A settled
-- obligation with no effect rows is what the console renders as unchanged.
-- ---------------------------------------------------------------------------
CREATE TABLE core.entitlement_set_change_obligations (
change_id UUID NOT NULL,
pool_id UUID NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
last_attempt_at TIMESTAMPTZ,
last_error TEXT,
settled_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_entitlement_set_change_obligations PRIMARY KEY (change_id, pool_id),
CONSTRAINT fk_entitlement_set_change_obligations_change_id FOREIGN KEY (change_id) REFERENCES core.entitlement_set_changes(change_id),
CONSTRAINT fk_entitlement_set_change_obligations_pool_id FOREIGN KEY (pool_id) REFERENCES core.resource_pools(pool_id),
CONSTRAINT chk_entitlement_set_change_obligations_status CHECK (status IN ('pending', 'settled', 'failed')),
CONSTRAINT obligation_settled_shape CHECK ((status = 'settled') = (settled_at IS NOT NULL))
);
COMMENT ON TABLE core.entitlement_set_change_obligations IS
'Work list of pools a committed rule change owes a recomputation. Not history: rows move from pending to settled or failed.';
CREATE INDEX idx_entitlement_set_change_obligations_change_status
ON core.entitlement_set_change_obligations (change_id, status);
CREATE INDEX idx_entitlement_set_change_obligations_unsettled
ON core.entitlement_set_change_obligations (status, created_at, change_id) WHERE status <> 'settled';
-- ---------------------------------------------------------------------------
-- Table 3: the effects. One row per (change, pool, resource key) whose
-- materialized value moved, plus every numeric key that ended at or over its
-- usage even when its value did not move. Append-only. The composite FK to
-- the obligation is the invariant that no effect exists for a pool the act
-- did not enumerate (Decision 145).
-- ---------------------------------------------------------------------------
CREATE TABLE core.entitlement_set_change_effects (
effect_id UUID NOT NULL DEFAULT uuidv7(),
change_id UUID NOT NULL,
pool_id UUID NOT NULL,
org_id UUID NOT NULL,
org_name VARCHAR(255) NOT NULL,
resource_key VARCHAR(100) NOT NULL,
resource_label VARCHAR(255) NOT NULL,
limit_before BIGINT,
limit_after BIGINT,
granted_before BOOLEAN,
granted_after BOOLEAN,
usage_at_effect BIGINT,
over_usage BOOLEAN NOT NULL DEFAULT FALSE,
was_over_before BOOLEAN NOT NULL DEFAULT FALSE,
reduction_policy VARCHAR(20),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_entitlement_set_change_effects PRIMARY KEY (effect_id),
CONSTRAINT fk_entitlement_set_change_effects_change_id FOREIGN KEY (change_id) REFERENCES core.entitlement_set_changes(change_id),
CONSTRAINT fk_entitlement_set_change_effects_pool_id FOREIGN KEY (pool_id) REFERENCES core.resource_pools(pool_id),
CONSTRAINT fk_entitlement_set_change_effects_org_id FOREIGN KEY (org_id) REFERENCES core.organizations(org_id),
CONSTRAINT fk_entitlement_set_change_effects_resource_key FOREIGN KEY (resource_key) REFERENCES core.resource_keys(resource_key),
CONSTRAINT effect_belongs_to_obligation FOREIGN KEY (change_id, pool_id)
REFERENCES core.entitlement_set_change_obligations(change_id, pool_id),
CONSTRAINT uq_entitlement_set_change_effects_key UNIQUE (change_id, pool_id, resource_key),
CONSTRAINT chk_entitlement_set_change_effects_reduction_policy
CHECK (reduction_policy IS NULL OR reduction_policy IN ('block', 'defer', 'clamp', 'force_reduce')),
CONSTRAINT chk_entitlement_set_change_effects_kind CHECK (
((limit_before IS NOT NULL OR limit_after IS NOT NULL)
AND granted_before IS NULL AND granted_after IS NULL)
OR ((granted_before IS NOT NULL OR granted_after IS NOT NULL)
AND limit_before IS NULL AND limit_after IS NULL AND usage_at_effect IS NULL))
);
COMMENT ON TABLE core.entitlement_set_change_effects IS
'Append-only record of what each committed rule change did to each pool and key. Nothing updates or deletes a row.';
CREATE INDEX idx_entitlement_set_change_effects_change_id ON core.entitlement_set_change_effects (change_id);
CREATE INDEX idx_entitlement_set_change_effects_org_id
ON core.entitlement_set_change_effects (org_id, created_at DESC, effect_id DESC);
-- 00001's GRANT ALL ON ALL TABLES covered only the tables that existed then;
-- the enclosed functions run as core_owner and write these three.
GRANT ALL ON core.entitlement_set_changes, core.entitlement_set_change_obligations, core.entitlement_set_change_effects
TO core_owner;
GRANT SELECT ON core.entitlement_set_changes, core.entitlement_set_change_obligations, core.entitlement_set_change_effects
TO core_reader, core_writer;
-- The work list is the one table the console updates directly: the drain
-- requeues failed rows through the Retry control.
GRANT UPDATE ON core.entitlement_set_change_obligations TO core_writer;
-- ---------------------------------------------------------------------------
-- The materialization rendezvous (Decision 144). Every transaction that
-- materializes a pool holds one transaction-scoped advisory lock in shared
-- mode; every transaction that changes a rule holds it in exclusive mode.
-- assert_rendezvous is how the enclosure checks possession: it looks for a
-- granted advisory lock on the rendezvous key held by the calling backend
-- and raises when there is none. The key is hashtextextended of the name,
-- which pg_locks records split across classid (high 32 bits) and objid
-- (low 32 bits) with objsubid = 1 for the single-key form.
-- ---------------------------------------------------------------------------
-- +goose StatementBegin
CREATE FUNCTION core.assert_rendezvous(p_exclusive BOOLEAN) RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = core, pg_temp
AS $$
DECLARE
v_key BIGINT := hashtextextended('entitlement_materialization', 0);
v_held BOOLEAN;
BEGIN
SELECT EXISTS (
SELECT 1
FROM pg_catalog.pg_locks l
WHERE l.locktype = 'advisory'
AND l.pid = pg_backend_pid()
AND l.granted
AND l.objsubid = 1
AND l.classid = ((v_key >> 32) & 4294967295)::oid
AND l.objid = (v_key & 4294967295)::oid
AND (NOT p_exclusive OR l.mode = 'ExclusiveLock')
) INTO v_held;
IF NOT v_held THEN
RAISE EXCEPTION 'materialization_rendezvous_missing'
USING ERRCODE = '55000',
DETAIL = CASE WHEN p_exclusive
THEN 'the calling transaction does not hold the exclusive materialization rendezvous'
ELSE 'the calling transaction does not hold the materialization rendezvous' END;
END IF;
END;
$$;
-- +goose StatementEnd
-- ---------------------------------------------------------------------------
-- commit_rule_change: the only write path to core.entitlement_set_rules
-- (Decision 143, on the Decision 137 enclosure pattern). It asserts the
-- exclusive rendezvous, locks the set row, validates the kind against the
-- row, applies the rule write, snapshots the rule before and after,
-- enumerates the carrying population, decides the path against the cap and
-- writes the act row and one pending obligation per pool. It never
-- materializes: the materializer is Go, which settles each obligation
-- through settle_obligation, under the cap inside this transaction and
-- above it from the drain.
--
-- p_rule carries the rule's declarative fields as JSONB: rule_type,
-- resource_key, resource_value, resource_per_unit, stacking_policy,
-- reset_period, tier_reduction_policy, credit_amount, credit_currency, and
-- optionally description. The snapshots hold the same fields plus is_active
-- and exclude description, the ids and the timestamps.
-- ---------------------------------------------------------------------------
-- +goose StatementBegin
CREATE FUNCTION core.rule_snapshot(p_rule core.entitlement_set_rules) RETURNS JSONB
LANGUAGE sql
IMMUTABLE
SET search_path = core, pg_temp
AS $$
SELECT to_jsonb(p_rule) - 'rule_id' - 'set_id' - 'description' - 'created_at' - 'updated_at';
$$;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE FUNCTION core.commit_rule_change(
p_set_id UUID,
p_change_kind VARCHAR,
p_rule_id UUID,
p_rule JSONB,
p_actor_type VARCHAR,
p_actor_person_id UUID,
p_actor_service_account_id UUID,
p_note TEXT,
p_request_id VARCHAR,
p_effective_at TIMESTAMPTZ,
p_sync_cap INTEGER
) RETURNS TABLE (
change_id UUID,
rule_id UUID,
sync_path VARCHAR,
pool_count INTEGER,
org_count INTEGER,
suspended_only_count INTEGER
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = core, pg_temp
AS $$
DECLARE
v_set_name VARCHAR(255);
v_rule core.entitlement_set_rules%ROWTYPE;
v_rule_id UUID := p_rule_id;
v_before JSONB;
v_after JSONB;
v_resource_label VARCHAR(255);
v_change_id UUID;
v_pool_count INTEGER;
v_org_count INTEGER;
v_suspended_only INTEGER;
v_sync_path VARCHAR(20);
v_updated INTEGER;
BEGIN
PERFORM core.assert_rendezvous(TRUE);
IF p_change_kind NOT IN ('rule_added', 'rule_modified', 'rule_deactivated', 'rule_reactivated') THEN
RAISE EXCEPTION 'rule_change_kind_mismatch'
USING ERRCODE = '22023', DETAIL = 'unknown change kind ' || COALESCE(p_change_kind, 'NULL');
END IF;
IF p_actor_type NOT IN ('person', 'service_account', 'system') THEN
RAISE EXCEPTION 'rule_change_actor_invalid'
USING ERRCODE = '22023', DETAIL = 'unknown actor type ' || COALESCE(p_actor_type, 'NULL');
END IF;
-- The set row, held for the transaction: a guard against a writer that
-- changes the set row without holding the rendezvous.
SELECT s.name INTO v_set_name FROM core.entitlement_sets s WHERE s.set_id = p_set_id FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'entitlement_set_not_found' USING ERRCODE = '22023';
END IF;
IF p_change_kind = 'rule_added' THEN
IF p_rule_id IS NOT NULL THEN
RAISE EXCEPTION 'rule_change_kind_mismatch'
USING ERRCODE = '22023', DETAIL = 'rule_added carries a rule id';
END IF;
INSERT INTO core.entitlement_set_rules (
set_id, rule_type, resource_key, resource_value, resource_per_unit, stacking_policy,
reset_period, tier_reduction_policy, credit_amount, credit_currency, description, is_active
) VALUES (
p_set_id,
p_rule->>'rule_type',
p_rule->>'resource_key',
(p_rule->>'resource_value')::BIGINT,
(p_rule->>'resource_per_unit')::BOOLEAN,
COALESCE(p_rule->>'stacking_policy',
CASE WHEN p_rule->>'rule_type' IN ('limit', 'quota') THEN 'additive' END),
p_rule->>'reset_period',
COALESCE(p_rule->>'tier_reduction_policy', 'defer'),
(p_rule->>'credit_amount')::INTEGER,
p_rule->>'credit_currency',
p_rule->>'description',
TRUE
) RETURNING * INTO v_rule;
v_rule_id := v_rule.rule_id;
v_before := NULL;
ELSE
IF p_rule_id IS NULL THEN
RAISE EXCEPTION 'rule_change_kind_mismatch'
USING ERRCODE = '22023', DETAIL = p_change_kind || ' names no rule';
END IF;
SELECT * INTO v_rule FROM core.entitlement_set_rules r
WHERE r.rule_id = p_rule_id AND r.set_id = p_set_id FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'rule_change_kind_mismatch'
USING ERRCODE = '22023', DETAIL = 'rule ' || p_rule_id || ' is not on set ' || p_set_id;
END IF;
v_before := core.rule_snapshot(v_rule);
IF p_change_kind = 'rule_modified' THEN
-- A rule's type and key never change (doc-45: the key is the
-- primary key); a different value is a different rule.
IF (p_rule ? 'rule_type' AND p_rule->>'rule_type' IS DISTINCT FROM v_rule.rule_type)
OR (p_rule ? 'resource_key' AND p_rule->>'resource_key' IS DISTINCT FROM v_rule.resource_key) THEN
RAISE EXCEPTION 'rule_change_kind_mismatch'
USING ERRCODE = '22023', DETAIL = 'rule_modified cannot change rule_type or resource_key';
END IF;
-- An absent key keeps the stored value; a present key sets it,
-- NULL included.
UPDATE core.entitlement_set_rules r SET
resource_value = CASE WHEN p_rule ? 'resource_value' THEN (p_rule->>'resource_value')::BIGINT ELSE r.resource_value END,
resource_per_unit = CASE WHEN p_rule ? 'resource_per_unit' THEN (p_rule->>'resource_per_unit')::BOOLEAN ELSE r.resource_per_unit END,
stacking_policy = CASE WHEN p_rule ? 'stacking_policy' THEN p_rule->>'stacking_policy' ELSE r.stacking_policy END,
reset_period = CASE WHEN p_rule ? 'reset_period' THEN p_rule->>'reset_period' ELSE r.reset_period END,
tier_reduction_policy = CASE WHEN p_rule ? 'tier_reduction_policy' THEN COALESCE(p_rule->>'tier_reduction_policy', r.tier_reduction_policy) ELSE r.tier_reduction_policy END,
credit_amount = CASE WHEN p_rule ? 'credit_amount' THEN (p_rule->>'credit_amount')::INTEGER ELSE r.credit_amount END,
credit_currency = CASE WHEN p_rule ? 'credit_currency' THEN p_rule->>'credit_currency' ELSE r.credit_currency END,
description = CASE WHEN p_rule ? 'description' THEN p_rule->>'description' ELSE r.description END,
updated_at = NOW()
WHERE r.rule_id = p_rule_id AND r.is_active
RETURNING * INTO v_rule;
GET DIAGNOSTICS v_updated = ROW_COUNT;
ELSIF p_change_kind = 'rule_deactivated' THEN
UPDATE core.entitlement_set_rules r SET is_active = FALSE, updated_at = NOW()
WHERE r.rule_id = p_rule_id AND r.is_active
RETURNING * INTO v_rule;
GET DIAGNOSTICS v_updated = ROW_COUNT;
ELSE
UPDATE core.entitlement_set_rules r SET is_active = TRUE, updated_at = NOW()
WHERE r.rule_id = p_rule_id AND NOT r.is_active
RETURNING * INTO v_rule;
GET DIAGNOSTICS v_updated = ROW_COUNT;
END IF;
IF v_updated = 0 THEN
-- The is_active guard admitted no row: the change is already applied.
RAISE EXCEPTION 'rule_change_kind_mismatch'
USING ERRCODE = '22023', DETAIL = 'already applied';
END IF;
END IF;
v_after := core.rule_snapshot(v_rule);
IF v_before IS NOT NULL AND v_before = v_after THEN
-- An edit that changes no field files no act.
RAISE EXCEPTION 'rule_change_kind_mismatch'
USING ERRCODE = '22023', DETAIL = 'already applied';
END IF;
SELECT k.display_name INTO v_resource_label FROM core.resource_keys k WHERE k.resource_key = v_rule.resource_key;
-- The carrying population: pools with an active provision of this set.
-- Stable for the transaction, because the exclusive rendezvous excludes
-- every conferral.
SELECT COUNT(DISTINCT pp.pool_id), COUNT(DISTINCT rp.org_id)
INTO v_pool_count, v_org_count
FROM core.pool_provisions pp
JOIN core.resource_pools rp ON rp.pool_id = pp.pool_id
WHERE pp.entitlement_set_id = p_set_id AND pp.status = 'active';
SELECT COUNT(*) INTO v_suspended_only FROM (
SELECT pp.pool_id
FROM core.pool_provisions pp
WHERE pp.entitlement_set_id = p_set_id
GROUP BY pp.pool_id
HAVING BOOL_OR(pp.status = 'suspended') AND NOT BOOL_OR(pp.status = 'active')
) s;
v_sync_path := CASE WHEN v_pool_count <= p_sync_cap THEN 'atomic' ELSE 'deferred' END;
INSERT INTO core.entitlement_set_changes (
set_id, set_name, change_kind, rule_id, resource_key, resource_label,
rule_before, rule_after, rule_type,
value_before, value_after, per_unit_before, per_unit_after,
actor_type, actor_person_id, actor_service_account_id,
note, request_id, sync_path, effective_at
) VALUES (
p_set_id, v_set_name, p_change_kind, v_rule_id, v_rule.resource_key, v_resource_label,
v_before, v_after, v_rule.rule_type,
(v_before->>'resource_value')::BIGINT, (v_after->>'resource_value')::BIGINT,
(v_before->>'resource_per_unit')::BOOLEAN, (v_after->>'resource_per_unit')::BOOLEAN,
p_actor_type, p_actor_person_id, p_actor_service_account_id,
p_note, p_request_id, v_sync_path, COALESCE(p_effective_at, NOW())
) RETURNING entitlement_set_changes.change_id INTO v_change_id;
INSERT INTO core.entitlement_set_change_obligations (change_id, pool_id)
SELECT DISTINCT v_change_id, pp.pool_id
FROM core.pool_provisions pp
WHERE pp.entitlement_set_id = p_set_id AND pp.status = 'active';
RETURN QUERY SELECT v_change_id, v_rule_id, v_sync_path::VARCHAR, v_pool_count, v_org_count, v_suspended_only;
END;
$$;
-- +goose StatementEnd
-- ---------------------------------------------------------------------------
-- settle_obligation: records what one pool's recomputation did. As built the
-- materializer is Go, so the caller reads the pool's per-key state before and
-- after it materializes and passes the diff as p_effects, a JSONB array of
-- objects with resource_key, limit_before, limit_after, granted_before,
-- granted_after, usage_at_effect, over_usage, was_over_before and
-- reduction_policy (doc-47 §11, accepted on condition 9). With p_failure set
-- it writes no effect, records the attempt and its error, and marks the
-- obligation failed once attempts reach p_max_attempts (NULL fails it at
-- once); below the budget the row stays pending for the drain's next pass.
-- Settling an already settled obligation is a no-op that returns 0.
-- ---------------------------------------------------------------------------
-- +goose StatementBegin
CREATE FUNCTION core.settle_obligation(
p_change_id UUID,
p_pool_id UUID,
p_effects JSONB,
p_failure TEXT DEFAULT NULL,
p_max_attempts INTEGER DEFAULT NULL
) RETURNS INTEGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = core, pg_temp
AS $$
DECLARE
v_status VARCHAR(20);
v_attempts INTEGER;
v_org_id UUID;
v_org_name VARCHAR(255);
v_written INTEGER := 0;
BEGIN
PERFORM core.assert_rendezvous(FALSE);
SELECT o.status, o.attempts INTO v_status, v_attempts
FROM core.entitlement_set_change_obligations o
WHERE o.change_id = p_change_id AND o.pool_id = p_pool_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'obligation_not_found' USING ERRCODE = '22023',
DETAIL = 'change ' || p_change_id || ' owes nothing to pool ' || p_pool_id;
END IF;
IF v_status = 'settled' THEN
RETURN 0;
END IF;
PERFORM 1 FROM core.resource_pools rp WHERE rp.pool_id = p_pool_id FOR UPDATE;
IF p_failure IS NOT NULL THEN
UPDATE core.entitlement_set_change_obligations o
SET status = CASE WHEN p_max_attempts IS NULL OR o.attempts + 1 >= p_max_attempts THEN 'failed' ELSE 'pending' END,
attempts = o.attempts + 1, last_attempt_at = NOW(), last_error = p_failure
WHERE o.change_id = p_change_id AND o.pool_id = p_pool_id;
RETURN 0;
END IF;
SELECT rp.org_id, org.name INTO v_org_id, v_org_name
FROM core.resource_pools rp
JOIN core.organizations org ON org.org_id = rp.org_id
WHERE rp.pool_id = p_pool_id;
INSERT INTO core.entitlement_set_change_effects (
change_id, pool_id, org_id, org_name, resource_key, resource_label,
limit_before, limit_after, granted_before, granted_after,
usage_at_effect, over_usage, was_over_before, reduction_policy
)
SELECT p_change_id, p_pool_id, v_org_id, v_org_name,
e->>'resource_key',
COALESCE(k.display_name, e->>'resource_key'),
(e->>'limit_before')::BIGINT, (e->>'limit_after')::BIGINT,
(e->>'granted_before')::BOOLEAN, (e->>'granted_after')::BOOLEAN,
(e->>'usage_at_effect')::BIGINT,
COALESCE((e->>'over_usage')::BOOLEAN, FALSE),
COALESCE((e->>'was_over_before')::BOOLEAN, FALSE),
e->>'reduction_policy'
FROM jsonb_array_elements(COALESCE(p_effects, '[]'::JSONB)) AS e
LEFT JOIN core.resource_keys k ON k.resource_key = e->>'resource_key';
GET DIAGNOSTICS v_written = ROW_COUNT;
UPDATE core.entitlement_set_change_obligations o
SET status = 'settled', settled_at = NOW(), attempts = o.attempts + 1, last_attempt_at = NOW(), last_error = NULL
WHERE o.change_id = p_change_id AND o.pool_id = p_pool_id;
RETURN v_written;
END;
$$;
-- +goose StatementEnd
-- The enclosure (Decision 137 pattern): functions run as core_owner,
-- core_writer keeps SELECT on the rule table but loses DML, and EXECUTE is
-- granted to core_writer alone. The pinned search_path is SECURITY DEFINER
-- hardening, not decoration.
ALTER FUNCTION core.assert_rendezvous(BOOLEAN) OWNER TO core_owner;
ALTER FUNCTION core.rule_snapshot(core.entitlement_set_rules) OWNER TO core_owner;
ALTER FUNCTION core.commit_rule_change(UUID, VARCHAR, UUID, JSONB, VARCHAR, UUID, UUID, TEXT, VARCHAR, TIMESTAMPTZ, INTEGER) OWNER TO core_owner;
ALTER FUNCTION core.settle_obligation(UUID, UUID, JSONB, TEXT, INTEGER) OWNER TO core_owner;
REVOKE EXECUTE ON FUNCTION core.assert_rendezvous(BOOLEAN) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION core.rule_snapshot(core.entitlement_set_rules) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION core.commit_rule_change(UUID, VARCHAR, UUID, JSONB, VARCHAR, UUID, UUID, TEXT, VARCHAR, TIMESTAMPTZ, INTEGER) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION core.settle_obligation(UUID, UUID, JSONB, TEXT, INTEGER) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION core.assert_rendezvous(BOOLEAN) TO core_writer;
GRANT EXECUTE ON FUNCTION core.rule_snapshot(core.entitlement_set_rules) TO core_writer;
GRANT EXECUTE ON FUNCTION core.commit_rule_change(UUID, VARCHAR, UUID, JSONB, VARCHAR, UUID, UUID, TEXT, VARCHAR, TIMESTAMPTZ, INTEGER) TO core_writer;
GRANT EXECUTE ON FUNCTION core.settle_obligation(UUID, UUID, JSONB, TEXT, INTEGER) TO core_writer;
REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON core.entitlement_set_rules FROM core_writer;
-- +goose Down
GRANT INSERT, UPDATE, DELETE, TRUNCATE ON core.entitlement_set_rules TO core_writer;
DROP FUNCTION core.settle_obligation(UUID, UUID, JSONB, TEXT, INTEGER);
DROP FUNCTION core.commit_rule_change(UUID, VARCHAR, UUID, JSONB, VARCHAR, UUID, UUID, TEXT, VARCHAR, TIMESTAMPTZ, INTEGER);
DROP FUNCTION core.rule_snapshot(core.entitlement_set_rules);
DROP FUNCTION core.assert_rendezvous(BOOLEAN);
DROP TABLE core.entitlement_set_change_effects;
DROP TABLE core.entitlement_set_change_obligations;
DROP TABLE core.entitlement_set_changes;
DROP VIEW core.product_shape;
CREATE VIEW core.product_shape AS
SELECT
p.product_id,
p.lifecycle_status,
p.is_public,
(p.entitlement_set_id IS NOT NULL) AS set_present,
(SELECT COUNT(*) FROM core.plan_ladder_tiers t WHERE t.product_id = p.product_id) AS ladder_count,
CASE
WHEN COUNT(pr.price_id) FILTER (WHERE pr.is_active) = 0 THEN 'unpriced'
WHEN COUNT(pr.price_id) FILTER (WHERE pr.is_active AND pr.recurring_interval IS NOT NULL) > 0
AND COUNT(pr.price_id) FILTER (WHERE pr.is_active AND pr.recurring_interval IS NULL) > 0 THEN 'mixed'
WHEN COUNT(pr.price_id) FILTER (WHERE pr.is_active AND pr.recurring_interval IS NOT NULL) > 0 THEN 'recurring'
ELSE 'one_time'
END AS billing_shape,
CASE
WHEN COUNT(DISTINCT pr.usage_type) FILTER (WHERE pr.is_active AND pr.usage_type IS NOT NULL) = 0 THEN 'none'
WHEN COUNT(DISTINCT pr.usage_type) FILTER (WHERE pr.is_active AND pr.usage_type IS NOT NULL) > 1 THEN 'mixed'
WHEN COUNT(pr.price_id) FILTER (WHERE pr.is_active AND pr.usage_type = 'metered') > 0 THEN 'metered'
ELSE 'licensed'
END AS consumption_shape
FROM core.products p
LEFT JOIN core.prices pr ON pr.product_id = p.product_id
GROUP BY p.product_id, p.lifecycle_status, p.is_public, p.entitlement_set_id;
GRANT SELECT ON core.product_shape TO core_reader, core_writer, core_owner;
DROP INDEX core.idx_pool_provisions_carrying_set;
ALTER TABLE core.resource_keys
DROP CONSTRAINT platform_keys_deny_new,
DROP COLUMN over_limit_consequence,
DROP COLUMN over_limit_behavior;
ALTER TABLE core.entitlement_set_rules DROP COLUMN tier_reduction_policy;