Files
member-console/internal/db
cgalo5758 701ab7e5b9 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.
2026-10-26 16:54:41 -05:00
..