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.
609 lines
32 KiB
PL/PgSQL
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;
|