Refuse in the database a plan tier whose product has no entitlement set

Second section of #153's change. Migration 00035 adds two trigger
functions and two triggers on core:

- On a plan ladder tier insert, or an update of its product, the tier's
  product is read FOR SHARE and the row is refused with
  chk_plan_ladder_tiers_product_has_set when the product has no
  entitlement set.
- On an update that clears a product's entitlement_set_id (from a set to
  NULL; a product already without one is left alone), the update is
  refused with chk_products_tier_keeps_set when any ladder tier names the
  product.

The product row taken FOR SHARE by the tier trigger is a new lock; a tier
add and a set clear on one product cannot both commit. That holds under
READ COMMITTED with the trigger functions VOLATILE, which the migration
header and docs/database-locks.md now state; the product edit's write
runs at the server's default isolation today, and item 4.6 opens it
explicitly READ COMMITTED in section 4. docs/database-locks.md gains the
product row, the tier add's order and the second FOR SHARE exception;
docs/models/product-catalog.md invariant 13 and
docs/models/identity-organization-workspace.md invariant 7 state the
rule, with the set id that names no set (#99) as the case it does not
cover; docs/models/plan-ladders-transitions.md names it on the tiers
row.

Tests (internal/db/tier_products_keep_a_set_test.go):
TestATierOfAProductWithNoEntitlementSetIsRefused,
TestClearingTheSetOfATierProductIsRefused,
TestATierAddAndAClearedSetCannotBothCommit (both orders),
TestTierProductsKeepASetMigrationUpDownUp.

Race run (2.3): `go test -race -p 1 -count=1 -run
'^TestATierAddAndAClearedSetCannotBothCommit$' ./internal/db` after
test/reset-test-db.sh: ok (1.126 s).

The review's blocker: T2 renamed a product whose set was already empty
with SET name alone, which the trigger's UPDATE OF entitlement_set_id
never sees, so the break on the trigger's WHEN passed. The statement now
names the column too, and the break is caught.

Breaks (scripts/mutation-check, command records on the migration, each
preceded by a test-database reset; 14 of 14 CAUGHT):
- tier check never raises (T1)
- tier check never raises (T3)
- tier check raises for every product
- tier trigger on inserts only
- tier check constraint name changed
- tier lock removed (set clear returns)
- tier lock removed (tier insert returns)
- WHEN refuses a replacement
- WHEN refuses the state
- product check ignores the tiers
- product trigger not created (T2)
- product trigger not created (T3)
- product check constraint name changed
- Down leaves the product trigger and function

The migration adds no relation or column, so scripts/db-reader-role.sql
needs no re-run. make lint passes; openspec validate --strict passes.
The suite on reset databases: 4699 pass, 14 skip; two failures, both
outside this section: TestEveryRegisteredFormWasCaptured read the
capture list from before the previous commit's capture and passes after
it, and TestPlanLaddersWalkthrough timed out once on its create toast
(#222) and passed twice alone. make screens: no change from this
section.
This commit is contained in:
2026-10-26 16:54:41 -05:00
parent 710591aad7
commit 701ab7e5b9
9 changed files with 344 additions and 12 deletions
+5 -2
View File
@@ -19,7 +19,8 @@ Every advisory lock is transaction-scoped (`pg_advisory_xact_lock`), so
Postgres releases it at commit or rollback. No code takes a session-level
advisory lock, `LOCK TABLE`, or an explicit `FOR SHARE`, except that a plan
act's listing batch takes an explicit `FOR KEY SHARE` on the pool rows it
lists (see the pool row below).
lists (see the pool row below) and that the trigger on a tier insert takes the
product row `FOR SHARE` (see the product row below).
## The locks
@@ -44,6 +45,7 @@ lists (see the pool row below).
| Entitlement set row and rule row | `core.entitlement_sets` row and `core.entitlement_set_rules` row | `FOR UPDATE` | `core.commit_rule_change` (the set row also by the `LockEntitlementSet` query, once per batch) | A rule change against a writer that changes the set or the rule without holding the rendezvous. |
| Change obligation row | `core.entitlement_set_change_obligations` row of one (change, pool) | `FOR UPDATE` | `core.settle_obligation` | Two passes settling one owed pool, so each effect is written once. |
| Plan act obligation row | `core.plan_act_obligations` row of one (act, pool) | `FOR UPDATE` by a move, a listing batch's supersession and Retry's requeue; `UPDATE` by the failure recorder | the `LockPlanActObligation` query (a move, after the pool row), which calls `core.lock_plan_act_obligation`; the function takes the row `FOR UPDATE`, `core.write_plan_act_obligations` (the open obligations of earlier acts on the batch's pools), `core.requeue_plan_act_obligations` (the retried act's failed obligations of the types it still owns) and `core.record_plan_act_obligation_failure` | Two passes settling one obligation, and a settlement against a supersession or a requeue. A batch and a Retry take several rows in ascending `pool_id` order and touch disjoint rows: a batch supersedes the open rows of earlier acts on the types its act owns, a Retry requeues the failed rows of the types its act still owns, and both hold the shared rendezvous, so no default change can move the ownership while either runs. |
| Product row | `core.products` row of one product | `FOR SHARE` by the tier trigger; `FOR NO KEY UPDATE` by the product edit's `UPDATE` | `core.refuse_tier_product_without_set`, the trigger on every insert into `core.plan_ladder_tiers` and every change of a tier's product; `UpdateProduct` | A tier insert against a clear of the product's set, so the two cannot both commit at READ COMMITTED, the isolation both writers run at (product-catalog invariant 13). The writer holds `UPDATE` on the table, so the trigger takes the lock with the caller's rights. The tier add takes the row after its pool rows; the product edit takes it alone. |
| Org type row | `core.org_types` row of one type | `FOR NO KEY UPDATE` | the `LockOrgType` query, run by the default change's T0 | The T0 against the demonstration seed and an edit of the type's other columns, which write the row without the rendezvous. Every other writer of the default already waits on the exclusive rendezvous. `FOR NO KEY UPDATE` does not conflict with the `FOR KEY SHARE` an organization insert takes on its type's row. |
| Grant lineage head | the `core.grants` row no other grant extends | `FOR UPDATE OF g` | the `GetGrantLineageHeadForUpdate` query, run by `RevokeGrantTx`, `ExtendWaitingGrantTx` and `ManageGrantTx` | Acts on one lineage, so an extension that committed while another act waited is visible to it. |
| Usage row | `core.numeric_entitlement_usage` row of one (pool, resource key) | `FOR UPDATE` | the `LockNumericUsage` query, run by `usage.Recount` and by the slot gate, after the site rows the transaction writes and before the request rows it finishes (the hold rows have no place in the order, see rule 5); the count is a statement of its own after the lock | Two recounts of one pool, so each count reads every change committed before its lock was granted; two slot gates for one pool, so the second counts the first's hold and two creates cannot both take the last slot. |
@@ -194,7 +196,8 @@ transaction timeout before the rendezvous statement.
of the product through `LockPools`, which sorts and deduplicates them, so
they lock in ascending `pool_id` order. The provisions are read again under
the locks, then the ladder's name, the act row and its detail row
(`core.record_plan_act`), the tier insert, and the collision read
(`core.record_plan_act`), the tier insert (its trigger takes the product row
`FOR SHARE` and holds it to the commit), and the collision read
(`ListTierAddCollisions`), which refuses a pool that already holds a live
position on the ladder or holds two live provisions of the product before
any alignment. Then, for each pool in the same order,
@@ -57,7 +57,7 @@ An invariant is a rule the model guarantees everywhere; code that would break on
4. **[db]** Organizations and workspaces carry a nullable `key`, and no URL-form identifier at all. A key is the row's declarative address: a unique string a seed, a configuration file, a test, a script, or an API client uses to reach the row without knowing the id the database generated for it (`00012_entity_keys.sql`, which renamed the old `slug` columns; the rule is in `docs/identifiers.md` and the `ui-vocabulary` spec). It is `TEXT NULL`, matches `^[a-z][a-z0-9_]*$` with at most 64 characters, and is unique in the smallest namespace the entity inhabits: `uq_organizations_key` across the table, `uq_workspaces_org_id_key` within the organization, so `acme/main` is a workspace's full address. NULLs are distinct in Postgres, so the many keyless rows never collide. No create form collects a key, and the console never derives one from a person's data; the only key it writes by design is `system` on the System organization, which is why a personal organization and its workspace carry none. A detail surface renders a key read-only when it is set and renders nothing when it is NULL. Identity is still the UUID and presentation is still the name: no route, query, or surface addresses an organization or workspace by anything else, and the console resolves the System tenant by `org_type = 'system'`, never by its key. Organization names are deliberately not unique; two members named Dara each get a "Dara's Organization". Workspace names are unique within their organization, compared without regard to case, among workspaces whose status is not `deleted` (`uq_workspaces_org_id_name_ci`): the name is what a member reads a workspace by, so it must be unambiguous where they read it, and a deleted workspace hands its name back. The member sees a refusal as a field-level message on the name, never a constraint name.
5. **[db]** Exactly one organization carries `org_type = 'system'` — the System tenant. A partial unique index on `org_type` where `org_type = 'system'` (`uq_organizations_one_system`) makes a second one impossible, which is also what makes the boot-time ensure idempotent: it inserts on conflict with that index and reads the row back by type.
6. **[db]** Every organization's type must exist in the types table — that is the schema's whole guarantee. That only two types exist (`personal` seeded, `system` boot-ensured) is an app-layer fact: nothing blocks inserting more, no surface creates them, and the designed team/enterprise types (`design/data-model.md`) remain unbuilt.
7. **[db]** A type's default ladder must be a real ladder — the foreign key blocks deleting a ladder any type defaults to. Nothing in the schema guarantees that ladder still has a rank-0 tier; the application guards it instead: deleting the last tier of a ladder any type defaults to is refused, and a signup that finds no rank-0 tier falls back and raises an error alarm rather than failing the login.
7. **[db]** A type's default ladder must be a real ladder — the foreign key blocks deleting a ladder any type defaults to. Nothing in the schema guarantees that ladder still has a rank-0 tier; the application guards it instead: deleting the last tier of a ladder any type defaults to is refused, and a signup that finds no rank-0 tier falls back and raises an error alarm rather than failing the login. The default ladder's rank-0 product always has an entitlement set, which the database keeps (**[db-code]**, product-catalog invariant 13), so the default can be conferred unless that product is draft or retired or its set id names no set (#99).
8. **[db]** A person belongs to an organization at most once, with exactly one role on the membership row.
9. **[app]** First login is all-or-nothing: the entire provisioning chain runs in one database transaction, and any failure rolls all of it back — leaving nothing behind, so the next login attempt simply retries the whole chain.
10. **[app]** The signup default grant fires exactly when the organization type has a default ladder configured, resolves that ladder's rank-0 tier, and goes through the standard conferral path (a system-authored grant with reason "default") — no bespoke provisioning writes exist.
+1 -1
View File
@@ -25,7 +25,7 @@ Database objects sit in the `core` schema; the baseline DDL is `internal/db/migr
| Piece | What it is | Where |
|---|---|---|
| `core.plan_ladders` | The ladder catalog: the ladder id, a unique display name, a nullable unique `key`, an `is_active` flag, and a presentation-only sort order | `00001_init.sql`; `00012_entity_keys.sql` |
| `core.plan_ladder_tiers` | Ladder membership: one row places one product on one ladder at one rank; ranks are unique per ladder | `00001_init.sql` |
| `core.plan_ladder_tiers` | Ladder membership: one row places one product on one ladder at one rank; ranks are unique per ladder; a tier's product has an entitlement set (product-catalog invariant 13) | `00001_init.sql`; `00035_tier_products_keep_a_set.sql` |
| `core.pool_provisions` | One row per funding source per delivery: what the pool holds, its quantity and time bounds, and a status of `active`, `suspended`, or `ended` (full column story in the entitlements model) | `00001_init.sql`; `00004_doc41_schema_and_data.sql` |
| `core.pool_provision_ladders` | Occupancy: which provision holds which rung now. Each occupancy episode is its own row with a permanent id — a rung lost and regained is a fresh row, never a reopened one | `00001_init.sql`; `00004_doc41_schema_and_data.sql` |
| `core.pool_provision_transitions` | The append-only audit: one row per rung per change — initiate, upgrade, downgrade, end, or transfer — with actor attribution and a business-time `effective_at` | `00001_init.sql`; `00004_doc41_schema_and_data.sql` |
+2
View File
@@ -26,6 +26,7 @@ Database objects sit in the `core` schema unless noted; each row names the migra
| `core.entitlement_sets` | The capability bundles products point at; owned by the [Entitlements](entitlements.md) model | `00001_init.sql` |
| `core.grants` | An entitlement to a product issued without a purchase — by an operator, or by the system during signup | `00001_init.sql` |
| `core.pool_provisions` | The provision rows conferral writes: what each organization currently holds, and from which product | `00001_init.sql` |
| `core.refuse_tier_product_without_set`, `core.refuse_tier_set_clear` | The two trigger functions that keep a plan tier's product on an entitlement set, and their triggers on `core.plan_ladder_tiers` and `core.products` | `00035_tier_products_keep_a_set.sql` |
| `core.product_shape` | A read-only view deriving each product's billing shape and consumption shape (defined under Dimensions) from its active prices | `00006_doc41_sweep_and_catalog.sql` |
| `core.product_conferral_shapes` | A read-only view assembling the product facts conferral reads: entitlement set, lifecycle, member visibility, and ladder positions | `00004_doc41_schema_and_data.sql` |
| The five conferral functions | `core.confer`, `core.end_conferral`, `core.sync_source_status`, `core.align_conferral_shape`, `core.update_conferral_bounds` | `00005_doc41_conferral_functions.sql` |
@@ -53,6 +54,7 @@ An invariant is a rule the model guarantees everywhere; code that would break on
10. **[db-code]** What a provision delivers is fixed at conferral time: `core.confer` writes the product and the entitlement set onto the provision row as they are at purchase or grant, and nothing later rewrites live provisions when a product is edited.
11. **[app]** Member surfaces offer only published products. The public-catalog queries return only rows with `lifecycle_status = 'published'`, and checkout independently rejects any price whose product is not published, active, and public before contacting Stripe — so a draft or retired product cannot be bought even through a crafted or stale request. One exception: an organization already enrolled on a ladder tier keeps seeing that rung, marked current with no move control, after its product leaves publication.
12. **[db]** A retired product can still be conferred only as continuity, never as a fresh conferral: `core.confer` refuses a draft product always, and a retired one unless the pool has already held a provision of that product from the same standing source. For a `default` grant the source is the class (any earlier default grant's provision of the product in the pool counts, since the floor mints a fresh grant row each time); for a subscription or a purchase it is the same row; for any other grant it is the grant or any grant it extends, found by walking `extends_grant_id` upward (`00023_grant_resumption.sql`), so a successor written for a grant that waits to resume can resume a product retired after it was written. A fresh grant extends nothing and meets only its own row, so a retired product stays withdrawn from new issuance. The operator's Extend action refuses a retired product before it writes anything, on both of its paths.
13. **[db-code]** Every plan tier's product has an entitlement set. A tier row naming a product whose set is NULL is refused (`chk_plan_ladder_tiers_product_has_set`), and so is an update that clears the set of a product that is a tier (`chk_products_tier_keeps_set`). The tier check takes the product row `FOR SHARE`, so the two writes cannot both commit at READ COMMITTED, the isolation both writers run at. A set with no active rule is allowed and warned about. Rows written before `00035` are not changed. Because every default ladder starts on a tier, its rank-0 product can always be conferred unless it is draft or retired or its set id names no set (#99; invariant 12).
## Dimensions
@@ -0,0 +1,97 @@
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
-- SPDX-FileCopyrightText: 2025-2026 Christian Galo
-- +goose Up
-- ---------------------------------------------------------------------------
-- No plan ladder tier names a product with no entitlement set. core.confer
-- refuses such a product for every source (grant, default, subscription), so
-- the tier can never be delivered, and every default ladder starts on a tier:
-- a default ladder whose rank 0 has no set fails every restoration of that
-- type's default, including a new person's first sign-in.
--
-- Two writes are refused, each with SQLSTATE 23514 and a constraint name the
-- handlers map to a sentence:
-- * a tier row (insert, or a change of its product) whose product has no
-- set: chk_plan_ladder_tiers_product_has_set;
-- * an update that clears the set of a product that is a tier of any
-- ladder: chk_products_tier_keeps_set.
--
-- The tier check takes the product row FOR SHARE. A product edit takes the
-- same row FOR NO KEY UPDATE, so a tier insert and a set clear on one product
-- cannot both commit: the second writer waits for the first and then sees its
-- commit. This holds at READ COMMITTED, the default both writers run at.
-- The product trigger's function is VOLATILE, so its query takes a snapshot
-- after the row lock is granted and sees the committed tier; the functions
-- must stay VOLATILE and the writers run READ COMMITTED.
--
-- The WHEN of the product trigger refuses the change, not the state: an
-- update that leaves a NULL set NULL, or gives it a value, does not fire the
-- function, so rows from before this migration stay and can be fixed.
--
-- Both triggers are immediate: the rule reads no rank, so no transaction
-- passes through a state it refuses. Both functions run with the caller's
-- rights, which suffices because the writer holds UPDATE on core.products
-- (docs/database-locks.md, rule 9).
-- ---------------------------------------------------------------------------
-- +goose StatementBegin
CREATE FUNCTION core.refuse_tier_product_without_set() RETURNS TRIGGER
LANGUAGE plpgsql AS $$
DECLARE
v_set_id UUID;
BEGIN
SELECT p.entitlement_set_id INTO v_set_id
FROM core.products p
WHERE p.product_id = NEW.product_id
FOR SHARE;
IF FOUND AND v_set_id IS NULL THEN
RAISE EXCEPTION 'tier: product % has no entitlement set', NEW.product_id
USING ERRCODE = 'check_violation',
CONSTRAINT = 'chk_plan_ladder_tiers_product_has_set',
TABLE = TG_TABLE_NAME;
END IF;
RETURN NEW;
END;
$$;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE TRIGGER trigger_plan_ladder_tiers_refuse_product_without_set
BEFORE INSERT OR UPDATE OF product_id ON core.plan_ladder_tiers
FOR EACH ROW EXECUTE FUNCTION core.refuse_tier_product_without_set();
-- +goose StatementEnd
-- +goose StatementBegin
CREATE FUNCTION core.refuse_tier_set_clear() RETURNS TRIGGER
LANGUAGE plpgsql AS $$
BEGIN
IF EXISTS (SELECT 1 FROM core.plan_ladder_tiers t WHERE t.product_id = NEW.product_id) THEN
RAISE EXCEPTION 'product % is a plan tier and keeps its entitlement set', NEW.product_id
USING ERRCODE = 'check_violation',
CONSTRAINT = 'chk_products_tier_keeps_set',
TABLE = TG_TABLE_NAME,
COLUMN = 'entitlement_set_id';
END IF;
RETURN NEW;
END;
$$;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE TRIGGER trigger_products_refuse_tier_set_clear
BEFORE UPDATE OF entitlement_set_id ON core.products
FOR EACH ROW
WHEN (OLD.entitlement_set_id IS NOT NULL AND NEW.entitlement_set_id IS NULL)
EXECUTE FUNCTION core.refuse_tier_set_clear();
GRANT EXECUTE ON FUNCTION core.refuse_tier_product_without_set() TO core_reader, core_writer, core_owner;
GRANT EXECUTE ON FUNCTION core.refuse_tier_set_clear() TO core_reader, core_writer, core_owner;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TRIGGER trigger_products_refuse_tier_set_clear ON core.products;
DROP TRIGGER trigger_plan_ladder_tiers_refuse_product_without_set ON core.plan_ladder_tiers;
DROP FUNCTION core.refuse_tier_set_clear();
DROP FUNCTION core.refuse_tier_product_without_set();
-- +goose StatementEnd
@@ -0,0 +1,229 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package db_test
import (
"context"
"database/sql"
"fmt"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
)
const (
tierSetVersion = 35
tierSetPrevious = 34
tierNeedsSet = "chk_plan_ladder_tiers_product_has_set"
tierKeepsSet = "chk_products_tier_keeps_set"
)
// productWithoutSet is a product a bare insert leaves with no entitlement set.
func productWithoutSet(q *dbtest.Q, prefix string) string {
return q.ID(`INSERT INTO core.products (name) VALUES ($1) RETURNING product_id`, dbtest.Name(prefix))
}
func newTierLadder(q *dbtest.Q) string {
return q.ID(`INSERT INTO core.plan_ladders (name) VALUES ($1) RETURNING plan_ladder_id`, dbtest.Name("tier set ladder "))
}
func TestATierOfAProductWithNoEntitlementSetIsRefused(t *testing.T) {
q := dbtest.Tx(t, dbtest.Open(t))
ladder := newTierLadder(q)
bare := productWithoutSet(q, "tier set bare ")
withSet := productWithSet(q, "tier set held ")
asRole(t, q.Tx(), db.RuntimeGroupRole, func() {
const insert = `INSERT INTO core.plan_ladder_tiers (plan_ladder_id, product_id, rank) VALUES ($1, $2, $3)`
err := q.Attempt(insert, ladder, bare, 0)
if err == nil {
t.Error("a tier insert of a product with no entitlement set was accepted")
} else {
dbtest.WantPgError(t, err, dbtest.SQLStateCheck, tierNeedsSet)
}
if n := q.Int(`SELECT count(*) FROM core.plan_ladder_tiers WHERE plan_ladder_id = $1`, ladder); n != 0 {
t.Errorf("the refused insert left %d tier rows, want 0", n)
}
if err := q.Attempt(insert, ladder, withSet, 0); err != nil {
t.Errorf("a tier of a product with a set was refused: %v", err)
}
err = q.Attempt(`UPDATE core.plan_ladder_tiers SET product_id = $2 WHERE plan_ladder_id = $1`, ladder, bare)
if err == nil {
t.Error("a tier's product changed to one with no entitlement set was accepted")
} else {
dbtest.WantPgError(t, err, dbtest.SQLStateCheck, tierNeedsSet)
}
})
}
func TestClearingTheSetOfATierProductIsRefused(t *testing.T) {
q := dbtest.Tx(t, dbtest.Open(t))
ladder := newTierLadder(q)
tierProduct := productWithSet(q, "tier set kept ")
q.Exec(`INSERT INTO core.plan_ladder_tiers (plan_ladder_id, product_id, rank) VALUES ($1, $2, 0)`, ladder, tierProduct)
otherSet := q.ID(`INSERT INTO core.entitlement_sets (name) VALUES ($1) RETURNING set_id`, dbtest.Name("tier set other "))
offLadder := productWithSet(q, "tier set free ")
asRole(t, q.Tx(), db.RuntimeGroupRole, func() {
err := q.Attempt(`UPDATE core.products SET entitlement_set_id = NULL WHERE product_id = $1`, tierProduct)
if err == nil {
t.Error("clearing the set of a tier product was accepted")
} else {
dbtest.WantPgError(t, err, dbtest.SQLStateCheck, tierKeepsSet)
}
if err := q.Attempt(`UPDATE core.products SET entitlement_set_id = $2 WHERE product_id = $1`, tierProduct, otherSet); err != nil {
t.Errorf("replacing a tier product's set was refused: %v", err)
}
if err := q.Attempt(`UPDATE core.products SET name = name, entitlement_set_id = entitlement_set_id WHERE product_id = $1`, tierProduct); err != nil {
t.Errorf("an update that writes a tier product's own set back was refused: %v", err)
}
if err := q.Attempt(`UPDATE core.products SET entitlement_set_id = NULL WHERE product_id = $1`, offLadder); err != nil {
t.Errorf("clearing the set of a product on no ladder was refused: %v", err)
}
})
// A tier row from before the migration: the tier trigger is switched off
// while the row is written, as the generation guard is in
// integration_delivery_schema_test.go.
legacy := productWithoutSet(q, "tier set legacy ")
q.Exec(`ALTER TABLE core.plan_ladder_tiers DISABLE TRIGGER trigger_plan_ladder_tiers_refuse_product_without_set`)
q.Exec(`INSERT INTO core.plan_ladder_tiers (plan_ladder_id, product_id, rank) VALUES ($1, $2, 1)`, ladder, legacy)
q.Exec(`ALTER TABLE core.plan_ladder_tiers ENABLE TRIGGER trigger_plan_ladder_tiers_refuse_product_without_set`)
asRole(t, q.Tx(), db.RuntimeGroupRole, func() {
if err := q.Attempt(`UPDATE core.products SET name = $2, entitlement_set_id = NULL WHERE product_id = $1`, legacy, dbtest.Name("tier set renamed ")); err != nil {
t.Errorf("an update that leaves a tier product's NULL set NULL was refused: %v", err)
}
if err := q.Attempt(`UPDATE core.products SET entitlement_set_id = $2 WHERE product_id = $1`, legacy, otherSet); err != nil {
t.Errorf("giving a set to a tier product with none was refused: %v", err)
}
})
}
// tierRaceRow is one order of the two writes the product row lock serializes.
type tierRaceRow struct {
name string
constraint string
// first is the write the side transaction makes and holds.
first func(tx *sql.Tx, ladder, product string) error
// second is the write that has to wait for it.
second func(tx *sql.Tx, ladder, product string) error
// abort is the Abort failure when the second write returns without waiting.
abort string
// verify reads the rows once both transactions are done.
verify func(t *testing.T, q *dbtest.Q, ladder, product string)
}
func tierInsert(tx *sql.Tx, ladder, product string) error {
_, err := tx.Exec(`INSERT INTO core.plan_ladder_tiers (plan_ladder_id, product_id, rank) VALUES ($1, $2, 0)`, ladder, product)
return err
}
func setClear(tx *sql.Tx, _, product string) error {
_, err := tx.Exec(`UPDATE core.products SET entitlement_set_id = NULL WHERE product_id = $1`, product)
return err
}
var tierRaceRows = []tierRaceRow{
{
name: "the tier insert first",
constraint: tierKeepsSet,
first: tierInsert,
second: setClear,
abort: "the set clear returned without waiting for the tier insert: %v",
verify: func(t *testing.T, q *dbtest.Q, _, product string) {
if q.Int(`SELECT count(*) FROM core.products WHERE product_id = $1 AND entitlement_set_id IS NOT NULL`, product) != 1 {
t.Error("the product lost its set although it is a tier")
}
},
},
{
name: "the set clear first",
constraint: tierNeedsSet,
first: setClear,
second: tierInsert,
abort: "the tier insert returned without waiting for the set clear: %v",
verify: func(t *testing.T, q *dbtest.Q, ladder, _ string) {
if n := q.Int(`SELECT count(*) FROM core.plan_ladder_tiers WHERE plan_ladder_id = $1`, ladder); n != 0 {
t.Errorf("the ladder holds %d tiers of a product with no set, want 0", n)
}
},
},
}
func TestATierAddAndAClearedSetCannotBothCommit(t *testing.T) {
database := dbtest.Open(t)
for _, row := range tierRaceRows {
t.Run(row.name, func(t *testing.T) { runTierRace(t, database, row) })
}
}
func runTierRace(t *testing.T, database *sql.DB, row tierRaceRow) {
ctx := t.Context()
setup, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin the setup: %v", err)
}
defer setup.Rollback()
sq := dbtest.On(t, setup)
ladder := newTierLadder(sq)
product := productWithSet(sq, "tier set race ")
if err := setup.Commit(); err != nil {
t.Fatalf("commit the setup: %v", err)
}
side, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin the side transaction: %v", err)
}
defer side.Rollback()
if err := row.first(side, ladder, product); err != nil {
t.Fatalf("the side transaction's write: %v", err)
}
sidePID := int(dbtest.On(t, side).Int(`SELECT pg_backend_pid()`))
answer := make(chan error, 1)
go func() { answer <- runSecondWrite(ctx, database, row, ladder, product) }()
dbtest.AwaitLockWait(t, database, dbtest.Backend{BlockedBy: sidePID, Abort: func() error {
select {
case err := <-answer:
return fmt.Errorf("%s: %v", row.abort, err)
default:
return nil
}
}})
if err := side.Commit(); err != nil {
t.Fatalf("commit the side transaction: %v", err)
}
if err := <-answer; err == nil {
t.Errorf("%s: the second write committed, want %s", row.name, row.constraint)
} else {
dbtest.WantPgError(t, err, dbtest.SQLStateCheck, row.constraint)
}
row.verify(t, dbtest.On(t, database), ladder, product)
}
// runSecondWrite makes the write that has to wait, in a transaction of its
// own, and returns its error. It runs on a goroutine, so it never touches t.
func runSecondWrite(ctx context.Context, database *sql.DB, row tierRaceRow, ladder, product string) error {
tx, err := database.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if err := row.second(tx, ladder, product); err != nil {
return err
}
return tx.Commit()
}
func TestTierProductsKeepASetMigrationUpDownUp(t *testing.T) {
requireMigrationUpDownUp(t, tierSetPrevious, tierSetVersion)
}
@@ -107,7 +107,7 @@ CREATE TRIGGER trigger_products_refuse_tier_set_clear
- **The raise.** Each raise uses SQLSTATE 23514 (`check_violation`) with a `CONSTRAINT` name, as `core.guard_desired_state_generation` does (`00024_integration_delivery_core.sql:342-346`), so `web.FieldErrorsFromDB` maps it by name (`internal/web/dberrors.go:34-44`) and a name it does not know falls to the generic class message. The database texts (Copy, L1 and L2) reach logs only.
- **A product no row names.** The tier trigger raises only when it finds the product and its set is NULL. When it finds no row, it returns the tier row and lets the foreign key refuse it with its own error. The console's tier add reads the product first and answers "Product not found." before any insert (`ladder_acts.go:505-508`).
- **The `WHEN` refuses the change, not the state.** An update that leaves a NULL set NULL, or gives it a value, does not fire the function. So a product that already holds the bad state (D5) can still be edited, and fixed. `UpdateProduct` lists `entitlement_set_id` in every update, so the trigger is considered on every product edit, and the `WHEN` decides.
- **The race.** `FOR SHARE` conflicts with the `FOR NO KEY UPDATE` that the product `UPDATE` takes, and in either order the second writer sees the first one's commit. If the tier insert locks first, the product update waits for the row, and its `BEFORE` trigger runs after the lock is granted, in a new statement whose snapshot holds the committed tier row. If the product update locks first, the tier trigger's locking read waits, then re-reads the updated row and finds the set NULL. Without `FOR SHARE` the tier insert takes only the foreign key's `FOR KEY SHARE`, which does not conflict with `FOR NO KEY UPDATE`, so both could commit (T3's break). Both writers run at READ COMMITTED, the server default: the tier add runs in `entitlements.RuleChange` (`internal/entitlements/tx.go:58-66`), a `db.FirstRuns` kind, which "opens with the server's default options (READ COMMITTED)" (`internal/db/tx.go:44-49`), nothing in the code or the connection setup changes `default_transaction_isolation`, and the product edit is one autocommit `UPDATE`. Under REPEATABLE READ the first order would let both commit, because the clear's statement snapshot predates the tier and a tuple that was only locked raises no serialization error. The rule holds only at READ COMMITTED, as the materialization rendezvous already does (`internal/entitlements/tx.go:40-43`).
- **The race.** `FOR SHARE` conflicts with the `FOR NO KEY UPDATE` that the product `UPDATE` takes, and in either order the second writer sees the first one's commit. If the tier insert locks first, the product update waits for the row, and its `BEFORE` trigger runs after the lock is granted, in a new statement whose snapshot holds the committed tier row. If the product update locks first, the tier trigger's locking read waits, then re-reads the updated row and finds the set NULL. Without `FOR SHARE` the tier insert takes only the foreign key's `FOR KEY SHARE`, which does not conflict with `FOR NO KEY UPDATE`, so both could commit (T3's break). Both writers run at READ COMMITTED, the server default: the tier add runs in `entitlements.RuleChange` (`internal/entitlements/tx.go:58-66`), a `db.FirstRuns` kind, which "opens with the server's default options (READ COMMITTED)" (`internal/db/tx.go:44-49`), nothing in the code or the connection setup changes `default_transaction_isolation`, and the product edit is one autocommit `UPDATE`. Under REPEATABLE READ the first order would let both commit, because the clear's statement snapshot predates the tier and a tuple that was only locked raises no serialization error. The rule holds only at READ COMMITTED, as the materialization rendezvous already does (`internal/entitlements/tx.go:40-43`). The product edit's write opens in an explicit `db.ReadCommitted` transaction (item 4.6), so the guarantee does not rest on the server's default isolation.
- **Immediate, not deferred.** Neither trigger joins ranks or org types, so no transaction passes through a state the rule refuses.
- **Privileges.** Both functions run with the caller's rights (no `SECURITY DEFINER`), so they are owned by the migration user like every other invoker-rights object (`openspec/specs/per-schema-roles/spec.md`, "Owner role grants full DDL and data access"). Each gets `GRANT EXECUTE ... TO core_reader, core_writer, core_owner`, as `00026_outgoing_default_rule.sql:207-212` grants its functions. `FOR SHARE` needs `UPDATE` on some column of `core.products`, and `console_runtime` holds it through `core_writer`, because the product edit updates the row as the runtime user. That is why rule 9 of `docs/database-locks.md` (a lock on a table where the writer holds no `UPDATE` goes through a definer function) does not apply. T1 and T2 run their statements as `console_runtime`, and `TestRuntimeRolePrivileges` must pass unedited.
@@ -361,7 +361,7 @@ console, the demonstration seed, a test fixture and SQL run by hand. The rule
exists because `core.confer` refuses a product with no entitlement set for every
source, so such a tier can never be delivered, and every org type's default
ladder starts on a tier, so its rank-0 product can always be conferred unless it
is draft or retired.
is draft or retired or its set id names no set (#99).
- A tier insert, and a change of a tier's product, SHALL be refused with SQLSTATE
23514 and the constraint name `chk_plan_ladder_tiers_product_has_set` when the
@@ -62,19 +62,19 @@ Sections 1 to 4 are one commit each, and their items, item 1.0 aside, are that c
## 2. The database refuses a tier whose product has no set
- [ ] 2.1 `internal/db/migrations/00035_tier_products_keep_a_set.sql` (design D2, Migration plan): a header comment in the style of `00026_outgoing_default_rule.sql` saying what the rule is (no plan ladder tier names a product with no entitlement set), why (`core.confer` refuses such a product for every source, so the tier can never be delivered, and every default ladder starts on a tier), the two writes it refuses, the product row lock and what it serializes, that the `WHEN` refuses the change and not the state so rows from before the migration stay and can be fixed, that both triggers are immediate because the rule reads no rank, and that both functions run with the caller's rights, which suffices because the writer holds `UPDATE` on `core.products` (`docs/database-locks.md`, rule 9). The two functions and two triggers as the design writes them, each statement block between `StatementBegin` and `StatementEnd` (`docs/database-management.md`); `GRANT EXECUTE ON FUNCTION core.refuse_tier_product_without_set() TO core_reader, core_writer, core_owner;` and the same for `core.refuse_tier_set_clear()`, as `00026_outgoing_default_rule.sql:207-212` grants its functions. Down: `DROP TRIGGER trigger_products_refuse_tier_set_clear ON core.products;`, `DROP TRIGGER trigger_plan_ladder_tiers_refuse_product_without_set ON core.plan_ladder_tiers;`, `DROP FUNCTION core.refuse_tier_set_clear();`, `DROP FUNCTION core.refuse_tier_product_without_set();`.
- [ ] 2.2 Tests, a new `internal/db/tier_products_keep_a_set_test.go` (package `db_test`), each product made by `productWithSet` (item 1.1) or, for a product with no set, by a bare `INSERT INTO core.products (name)`:
- [x] 2.1 `internal/db/migrations/00035_tier_products_keep_a_set.sql` (design D2, Migration plan): a header comment in the style of `00026_outgoing_default_rule.sql` saying what the rule is (no plan ladder tier names a product with no entitlement set), why (`core.confer` refuses such a product for every source, so the tier can never be delivered, and every default ladder starts on a tier), the two writes it refuses, the product row lock and what it serializes, that the `WHEN` refuses the change and not the state so rows from before the migration stay and can be fixed, that both triggers are immediate because the rule reads no rank, and that both functions run with the caller's rights, which suffices because the writer holds `UPDATE` on `core.products` (`docs/database-locks.md`, rule 9). The two functions and two triggers as the design writes them, each statement block between `StatementBegin` and `StatementEnd` (`docs/database-management.md`); `GRANT EXECUTE ON FUNCTION core.refuse_tier_product_without_set() TO core_reader, core_writer, core_owner;` and the same for `core.refuse_tier_set_clear()`, as `00026_outgoing_default_rule.sql:207-212` grants its functions. Down: `DROP TRIGGER trigger_products_refuse_tier_set_clear ON core.products;`, `DROP TRIGGER trigger_plan_ladder_tiers_refuse_product_without_set ON core.plan_ladder_tiers;`, `DROP FUNCTION core.refuse_tier_set_clear();`, `DROP FUNCTION core.refuse_tier_product_without_set();`.
- [x] 2.2 Tests, a new `internal/db/tier_products_keep_a_set_test.go` (package `db_test`), each product made by `productWithSet` (item 1.1) or, for a product with no set, by a bare `INSERT INTO core.products (name)`:
- T1 `TestATierOfAProductWithNoEntitlementSetIsRefused`, in `dbtest.Tx`, the ladder and the products made as the test's own role and the writes inside `asRole(t, q.Tx(), db.RuntimeGroupRole, …)` (`internal/db/integration_delivery_fixtures_test.go:218`); every statement that may be refused runs through `q.Attempt` (`internal/testkit/dbtest/query.go:205-218`), a savepoint, so a refusal does not abort the transaction the test goes on in: a tier insert of the product with no set fails (`a tier insert of a product with no entitlement set was accepted` when it does not) with `dbtest.WantPgError(t, err, dbtest.SQLStateCheck, "chk_plan_ladder_tiers_product_has_set")`, and the ladder holds no tier (`the refused insert left %d tier rows, want 0`); a tier insert of the product with a set passes (`a tier of a product with a set was refused: %v`); and changing that tier's `product_id` to the product with no set fails the same way (`a tier's product changed to one with no entitlement set was accepted`).
- T2 `TestClearingTheSetOfATierProductIsRefused`, in `dbtest.Tx`, the writes as `console_runtime`, every statement that may be refused through `q.Attempt` (`internal/testkit/dbtest/query.go:205-218`): setting a tier product's set to NULL fails (`clearing the set of a tier product was accepted`) with `chk_products_tier_keeps_set`; replacing it with another set passes (`replacing a tier product's set was refused: %v`); `UPDATE core.products SET name = name, entitlement_set_id = entitlement_set_id`, the shape of the product edit's statement, passes on the tier product (`an update that writes a tier product's own set back was refused: %v`); clearing the set of a product on no ladder passes (`clearing the set of a product on no ladder was refused: %v`). Then, as the test's own role, `ALTER TABLE core.plan_ladder_tiers DISABLE TRIGGER trigger_plan_ladder_tiers_refuse_product_without_set`, a tier insert of a product with no set, and `ENABLE TRIGGER` (the state a row from before the migration holds, written as `internal/db/integration_delivery_schema_test.go:422` bypasses a guard, inside the rolled-back transaction); as `console_runtime`, an update of that product that renames it and leaves its set NULL passes (`an update that leaves a tier product's NULL set NULL was refused: %v`), and one that gives it a set passes (`giving a set to a tier product with none was refused: %v`).
- T3 `TestATierAddAndAClearedSetCannotBothCommit`, rows `the tier insert first` and `the set clear first` through a named function, each row running under `t.Run(row.name, …)`. Each row commits its fixture first (a ladder and a product with a set, names from `dbtest.Name`), as `TestListingWritesEachOrganizationOnce` does (`internal/db/plan_act_obligations_test.go:628-640`). A side transaction makes the row's first write and holds; its backend id is read. The second write runs in its own transaction on a goroutine that reports its error on a channel. `dbtest.AwaitLockWait(t, database, dbtest.Backend{BlockedBy: sidePID, Abort: …})` waits for it, the `Abort` returning `the set clear returned without waiting for the tier insert: %v` (first row) or `the tier insert returned without waiting for the set clear: %v` (second row) when the channel already holds an answer. The side commits. The second write's error is checked so that a nil error does not stop the row: `if err == nil { t.Errorf("%s: the second write committed, want %s", row.name, row.constraint) } else { dbtest.WantPgError(t, err, dbtest.SQLStateCheck, row.constraint) }` (`WantPgError` calls `Fatalf` on a nil error, `internal/testkit/dbtest/query.go:229-234`, and the reads below would never run), the constraint being `chk_products_tier_keeps_set` in the first row and `chk_plan_ladder_tiers_product_has_set` in the second. The row then always runs its reads on the database: in the first row the product keeps its set (`the product lost its set although it is a tier`), in the second the ladder holds no tier (`the ladder holds %d tiers of a product with no set, want 0`).
- T9 `TestTierProductsKeepASetMigrationUpDownUp`: `requireMigrationUpDownUp(t, tierSetPrevious, tierSetVersion)` (`internal/db/outgoing_default_rule_test.go:159-187`), the constants 34 and 35 beside the test as `outgoing_default_rule_test.go:28-29` declares its own.
- [ ] 2.3 `go test -race -p 1 -count=1 -run '^TestATierAddAndAClearedSetCannotBothCommit$' ./internal/db` passes after `test/reset-test-db.sh`; the commit message records it.
- [ ] 2.4 The runtime role's checks pass unedited with the migration in place: `TestRuntimeRolePrivileges` (`internal/db/runtime_role_privileges_test.go:235`; its function list holds definer functions only, `:218-223`, and the two trigger functions are not), `TestConsoleReaderColumns` with `internal/migrate/testdata/console-reader-columns.txt` unchanged (the migration adds no relation or column), and T1 and T2, whose writes run as `console_runtime`. `scripts/db-reader-role.sql` needs no re-run; the commit message says so.
- [ ] 2.5 Docs (`CONTRIBUTING.md`, "Docs move with the code"):
- [x] 2.3 `go test -race -p 1 -count=1 -run '^TestATierAddAndAClearedSetCannotBothCommit$' ./internal/db` passes after `test/reset-test-db.sh`; the commit message records it.
- [x] 2.4 The runtime role's checks pass unedited with the migration in place: `TestRuntimeRolePrivileges` (`internal/db/runtime_role_privileges_test.go:235`; its function list holds definer functions only, `:218-223`, and the two trigger functions are not), `TestConsoleReaderColumns` with `internal/migrate/testdata/console-reader-columns.txt` unchanged (the migration adds no relation or column), and T1 and T2, whose writes run as `console_runtime`. `scripts/db-reader-role.sql` needs no re-run; the commit message says so.
- [x] 2.5 Docs (`CONTRIBUTING.md`, "Docs move with the code"):
- `docs/database-locks.md`: the opening paragraph's "No code takes a session-level advisory lock, `LOCK TABLE`, or an explicit `FOR SHARE`, except that a plan act's listing batch takes an explicit `FOR KEY SHARE` on the pool rows it lists" gains a second exception: the trigger on a tier insert takes the product row `FOR SHARE` (see the product row below). The row-lock table gains the "Product row" row as the design writes it (Locks and transactions), citing product-catalog invariant 13. The "Tier add" bullet's "the act row and its detail row (`core.record_plan_act`), the tier insert, and the collision read" names the tier insert's trigger, which takes the product row `FOR SHARE` and holds it to the commit. The other two changes edit other parts of this page (design D10); a conflict there is merged, never resolved by dropping either text.
- `docs/models/product-catalog.md`: invariant 13, **[db-code]**: every plan tier's product has an entitlement set; a tier row naming a product whose set is NULL is refused (`chk_plan_ladder_tiers_product_has_set`), and so is an update that clears the set of a product that is a tier (`chk_products_tier_keeps_set`); the tier check takes the product row `FOR SHARE`, so the two writes cannot both commit; a set with no active rule is allowed and warned about; rows written before `00035` are not changed; because every default ladder starts on a tier, its rank-0 product can always be conferred unless it is draft or retired (invariant 12). A "Where this lives" row: the two functions and their triggers on `core.plan_ladder_tiers` and `core.products`, `00035_tier_products_keep_a_set.sql`.
- `docs/models/identity-organization-workspace.md`, invariant 7: a sentence that the default ladder's rank-0 product always has an entitlement set, which the database keeps (**[db-code]**, product-catalog invariant 13), so the default can be conferred unless that product is draft or retired.
- [ ] 2.6 Breaks (command records on `internal/db/migrations/00035_tier_products_keep_a_set.sql` unless named otherwise):
- [x] 2.6 Breaks (command records on `internal/db/migrations/00035_tier_products_keep_a_set.sql` unless named otherwise):
- the tier check never raising (`IF FOUND AND v_set_id IS NULL THEN` replaced by `IF FALSE THEN`), one edit and two records: caught by T1, `want_new` `a tier insert of a product with no entitlement set was accepted`, and by T3, `want_new` `the ladder holds 1 tiers of a product with no set, want 0`;
- the tier check raising for every product (`IF FOUND AND v_set_id IS NULL THEN` replaced by `IF FOUND THEN`): caught by T1, `want_new` `a tier of a product with a set was refused`;
- the tier trigger on inserts only (`BEFORE INSERT OR UPDATE OF product_id ON core.plan_ladder_tiers` replaced by `BEFORE INSERT ON core.plan_ladder_tiers`): caught by T1, `want_new` `a tier's product changed to one with no entitlement set was accepted`;
@@ -140,6 +140,7 @@ Sections 1 to 4 are one commit each, and their items, item 1.0 aside, are that c
- the database's refusal unmapped (the `chk_products_tier_keeps_set` branch of `UpdateProduct` deleted, so `web.FieldErrorsFromDB` answers its class message in the form-level slot): caught by T6b, `want_new` `the Entitlement set field carries no error`;
- the refusal named over a failed second read (the branch that answers the failure sentence when `loadProductTier` finds no ladder deleted, so R2 renders over no names): caught by T6b, `want_new` `the form-level slot = ""`;
- the join reduced to the first name (`joinWithAnd(ladders)` replaced by `ladders[0]` in `tierNeedsSetSentence`): caught by `TestTierNeedsSetSentence`, `want_new` `tierNeedsSetSentence(two ladders) =`.
- [ ] 4.6 `internal/server/operator_products.go` (design D2): the product edit's write in `UpdateProduct` (`h.BillingQ.UpdateProduct`, one autocommit statement at the server's default isolation) runs inside `db.InTx(ctx, h.Database, db.ReadCommitted, ...)` with the querier bound to the transaction, and the handler's doc comment says why (`docs/go-conventions.md`, "Transactions and pool locks"): the tier trigger's guarantee that a tier add and a set clear cannot both commit holds only at READ COMMITTED. A test in `internal/server` over a database opened with `default_transaction_isolation=repeatable read` in its connection options: it runs the first order of T3 (a side transaction inserts the tier and holds it) with the product edit clearing the set through `UpdateProduct`, commits the side transaction, and expects the edit refused with R2; it fails if the write runs at the server's default. If the test kit cannot open a database with that option plainly, the item records the reason and leaves the test out.
## 5. Landing