Stop tiering products with no entitlement set in tests, and leave them out of the tier picker
First section of the change that makes a product with no entitlement set unable to be a plan tier (#153). The change's proposal, design, tasks and spec deltas come in with it. Fixtures: seven tests that made a product with no set a ladder tier give it one (productWithSet in internal/db, mkTierProduct in internal/server, tierProduct in the merchant-disclosure sheet). The sheet is a capture make test does not run; its edit was checked by reading and go vet. The test kit's WithoutSet comment says that from migration 00035 on a tier's set cannot be cleared. The failure the plan-act goldens inject moves from a product with no set to a product made draft after it is laddered, which core.confer refuses at the same statement and step. Four goldens under internal/server/testdata/plan_acts change only there: the product reads Drafted instead of NoSet, and the driver text reads "is draft and not conferrable". TestResumption_ARefusedConferralIsRolledBackAlone drafts Plus the same way. The tier picker: tierCandidates keeps a product that is published, has an entitlement set and is not on the ladder, in the order given; availableTierProducts calls it. The Add tier panel's empty state keeps its first sentence and says "A tier needs a published product with an entitlement set that is not on this ladder yet." A posted product the list left out is refused with "Choose a product from the list." make screens: operator-plan-ladder-detail's open and invalid captures change at both widths. The demo's three products on no ladder have no set, so the demo ladder has nothing to add and the open panel shows the empty state. The other changed screens are the known data-only differences and the host's time zone (#274). Read-only counts on the test stack's app database before any reset: no default ladder starts on a product with no set, no plan act waits for one, and the two tiers with no set sat on a ladder the browser walkthroughs had left behind. Breaks (scripts/mutation-check, 11 of 11 CAUGHT): - default-initiate-fails without its draft - default-migrate-restore-fails without its draft - remove-second-pool-fails without its draft - reorder-an-organization-fails without its draft - the draft removed from the refused conferral test - the tier filter without the set - the tier candidates in reverse order - the empty state's old second sentence restored - the tier add accepts a product the list left out - the tier filter without the lifecycle - the tier filter keeps a product on the ladder The seven fixture edits and the WithoutSet comment carry no break: they remove a state section 2's triggers forbid, and section 2's run is their test. make lint passes; openspec validate --strict passes; the suite on reset databases passes, 4694 tests, 15 skipped.
This commit is contained in:
@@ -65,7 +65,7 @@ The panel also flags the **invisible-limbo** case: a published product that is n
|
||||
### Attaching Tiers
|
||||
|
||||
1. From the ladder list, click **Manage** on the ladder you want to configure.
|
||||
2. Select any **published product** (Doc 41: display category never gates tier membership).
|
||||
2. Select any **published product** that has an entitlement set (Doc 41: display category never gates tier membership). The list leaves out a product with no entitlement set, because no source can deliver a tier that has none.
|
||||
3. Click **Add Tier** — the tier appends at the bottom (rank self-assigns) and you drag it into position. If the product already carries live provisions, the induction backfill aligns them to the grown shape in the same transaction.
|
||||
|
||||
The add decides inside the one transaction that inserts the tier:
|
||||
|
||||
@@ -51,6 +51,14 @@ func newActFixture(t *testing.T, q *dbtest.Q) actFixture {
|
||||
return actFixture{person: fxPerson(t, q.Tx()), ladder: uuid.NewString(), product: uuid.NewString()}
|
||||
}
|
||||
|
||||
// productWithSet inserts an entitlement set and a product that names it, both
|
||||
// named after prefix, and returns the product's id. A plan tier's product has
|
||||
// an entitlement set.
|
||||
func productWithSet(q *dbtest.Q, prefix string) string {
|
||||
set := q.ID(`INSERT INTO core.entitlement_sets (name) VALUES ($1) RETURNING set_id`, dbtest.Name(prefix+"set "))
|
||||
return q.ID(`INSERT INTO core.products (name, entitlement_set_id) VALUES ($1, $2) RETURNING product_id`, dbtest.Name(prefix), set)
|
||||
}
|
||||
|
||||
func TestActRecordIsEnclosed(t *testing.T) {
|
||||
q := dbtest.Tx(t, dbtest.Open(t))
|
||||
tx := q.Tx()
|
||||
@@ -152,8 +160,8 @@ func TestRecordPlanActChecksAReordersRankZero(t *testing.T) {
|
||||
q := dbtest.Tx(t, dbtest.Open(t))
|
||||
f := newActFixture(t, q)
|
||||
ladder := q.ID(`INSERT INTO core.plan_ladders (name) VALUES ($1) RETURNING plan_ladder_id`, dbtest.Name("acts ladder "))
|
||||
first := q.ID(`INSERT INTO core.products (name) VALUES ($1) RETURNING product_id`, dbtest.Name("acts first "))
|
||||
second := q.ID(`INSERT INTO core.products (name) VALUES ($1) RETURNING product_id`, dbtest.Name("acts second "))
|
||||
first := productWithSet(q, "acts first ")
|
||||
second := productWithSet(q, "acts second ")
|
||||
q.Exec(`INSERT INTO core.plan_ladder_tiers (plan_ladder_id, product_id, rank) VALUES ($1, $2, 0), ($1, $3, 1)`, ladder, first, second)
|
||||
q.Exec(exclusiveRendezvous)
|
||||
|
||||
|
||||
@@ -106,8 +106,8 @@ func (f obligationFixture) reorder(orgTypes ...string) (act, ladder string) {
|
||||
f.t.Helper()
|
||||
f.exclusive()
|
||||
ladder = f.q.ID(`INSERT INTO core.plan_ladders (name) VALUES ($1) RETURNING plan_ladder_id`, dbtest.Name("obligation ladder "))
|
||||
first := f.q.ID(`INSERT INTO core.products (name) VALUES ($1) RETURNING product_id`, dbtest.Name("obligation first "))
|
||||
second := f.q.ID(`INSERT INTO core.products (name) VALUES ($1) RETURNING product_id`, dbtest.Name("obligation second "))
|
||||
first := productWithSet(f.q, "obligation first ")
|
||||
second := productWithSet(f.q, "obligation second ")
|
||||
f.q.Exec(`INSERT INTO core.plan_ladder_tiers (plan_ladder_id, product_id, rank) VALUES ($1, $2, 0), ($1, $3, 1)`, ladder, first, second)
|
||||
for _, orgType := range orgTypes {
|
||||
f.q.Exec(`UPDATE core.org_types SET default_plan_ladder_id = $1 WHERE org_type = $2`, ladder, orgType)
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
gesture on the table below, not the product control itself. */}}
|
||||
<p class="text-muted small mb-0">New tiers are added at the highest rank; drag rows below to set the order.</p>
|
||||
{{ else }}
|
||||
<p class="text-muted small mb-0">No plan products are available to add. Every published product is already a tier on this ladder, or no products are published yet.</p>
|
||||
<p class="text-muted small mb-0">No plan products are available to add. A tier needs a published product with an entitlement set that is not on this ladder yet.</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -289,7 +289,8 @@ func TestResumption_TwoCandidatesSharingARungResumeTheNewerOnly(t *testing.T) {
|
||||
}
|
||||
|
||||
// A refused conferral rolls back to its savepoint alone: the other grant
|
||||
// resumes and the subscription's provisions stay ended.
|
||||
// resumes and the subscription's provisions stay ended. The conferral is
|
||||
// refused because Plus is draft.
|
||||
func TestResumption_ARefusedConferralIsRolledBackAlone(t *testing.T) {
|
||||
w := newResumeWorld(t)
|
||||
plus, forum, pro := w.product("Plus"), w.product("Forum"), w.product("Pro")
|
||||
@@ -301,8 +302,8 @@ func TestResumption_ARefusedConferralIsRolledBackAlone(t *testing.T) {
|
||||
w.confer(fine)
|
||||
sub := w.subscription()
|
||||
w.conferSub(sub, pro) // Pro spans both ladders and replaces both grants
|
||||
if _, err := w.tx.ExecContext(w.ctx, `UPDATE core.products SET entitlement_set_id = NULL WHERE product_id = $1`, plus); err != nil {
|
||||
t.Fatalf("drop Plus's entitlement set: %v", err)
|
||||
if _, err := w.tx.ExecContext(w.ctx, `UPDATE core.products SET lifecycle_status = 'draft' WHERE product_id = $1`, plus); err != nil {
|
||||
t.Fatalf("draft Plus: %v", err)
|
||||
}
|
||||
provisionsBefore := w.count(`SELECT count(*) FROM core.pool_provisions WHERE grant_id = $1`, broken.GrantID)
|
||||
|
||||
|
||||
@@ -481,7 +481,8 @@ func TestGetPoolDefaultResolvesTheTypesCurrentDefault(t *testing.T) {
|
||||
t.Errorf("a default ladder with no rank-0 tier returned %v, want sql.ErrNoRows", err)
|
||||
}
|
||||
|
||||
product := r.q.ID(`INSERT INTO core.products (name) VALUES ($1) RETURNING product_id`, dbtest.Name("pa product "))
|
||||
set := r.q.ID(`INSERT INTO core.entitlement_sets (name) VALUES ($1) RETURNING set_id`, dbtest.Name("pa set "))
|
||||
product := r.q.ID(`INSERT INTO core.products (name, entitlement_set_id) VALUES ($1, $2) RETURNING product_id`, dbtest.Name("pa product "), set)
|
||||
r.q.Exec(`INSERT INTO core.plan_ladder_tiers (plan_ladder_id, product_id, rank) VALUES ($1, $2, 0)`, ladder, product)
|
||||
got, err = r.e.GetPoolDefault(ctx, pool)
|
||||
if err != nil || !got.DefaultPlanLadderID.Valid || got.DefaultPlanLadderID.UUID.String() != ladder ||
|
||||
|
||||
@@ -82,6 +82,30 @@ func TestBuildAddonsDataExtrasBucket(t *testing.T) {
|
||||
return p
|
||||
}
|
||||
|
||||
// mkTierProduct makes a product with an entitlement set of its own, which a
|
||||
// plan tier requires; the extras below keep none.
|
||||
mkTierProduct := func(name, category string) billing.Product {
|
||||
t.Helper()
|
||||
set, err := entitlements.New(tx).CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
|
||||
Name: name + " set", IsActive: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create entitlement set for %s: %v", name, err)
|
||||
}
|
||||
p, err := bq.CreateProduct(ctx, billing.CreateProductParams{
|
||||
Name: name,
|
||||
DisplayCategory: sql.NullString{String: category, Valid: category != ""},
|
||||
IsActive: true,
|
||||
IsPublic: true,
|
||||
LifecycleStatus: "published",
|
||||
EntitlementSetID: uuid.NullUUID{UUID: uuid.MustParse(set.SetID), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create product %s: %v", name, err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
labeled := mkProduct("Labeled Extra "+sfx, "addon")
|
||||
otherLabel := mkProduct("Other-Label Extra "+sfx, "storage")
|
||||
blank := mkProduct("Blank-Label Extra "+sfx, "")
|
||||
@@ -89,7 +113,7 @@ func TestBuildAddonsDataExtrasBucket(t *testing.T) {
|
||||
// A tier that also carries display_category = 'addon': membership is
|
||||
// structural (ladder-tier presence), so this must render in its plan
|
||||
// section only and never duplicate into Extras.
|
||||
tierWithAddonLabel := mkProduct("Tiered Extra "+sfx, "addon")
|
||||
tierWithAddonLabel := mkTierProduct("Tiered Extra "+sfx, "addon")
|
||||
ladder, err := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{
|
||||
Name: "Extras Test Ladder " + sfx, IsActive: true,
|
||||
})
|
||||
|
||||
@@ -82,13 +82,21 @@ func (m offerMember) mapPrice(t *testing.T, priceID string) {
|
||||
}
|
||||
|
||||
// product creates a published product with one price, recurring when
|
||||
// recurring is true, mapped to Stripe. It returns the product and price ids.
|
||||
// recurring is true, mapped to Stripe, and an entitlement set of its own, so
|
||||
// it can be a plan tier. It returns the product and price ids.
|
||||
func (m offerMember) product(t *testing.T, name string, recurring bool) (productID, priceID string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
bq := billing.New(m.db)
|
||||
set, err := entitlements.New(m.db).CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
|
||||
Name: name + " set " + m.sfx, IsActive: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create entitlement set for %s: %v", name, err)
|
||||
}
|
||||
prod, err := bq.CreateProduct(ctx, billing.CreateProductParams{
|
||||
Name: name + " " + m.sfx, IsActive: true, IsPublic: true, LifecycleStatus: "published",
|
||||
EntitlementSetID: uuid.NullUUID{UUID: uuid.MustParse(set.SetID), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create product %s: %v", name, err)
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/logging"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/middleware"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world"
|
||||
)
|
||||
|
||||
@@ -187,6 +188,29 @@ func TestCreatePlanLadderTier_EveryOtherFailureShowsTheReference(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A post that names a product the list left out (a published product with no
|
||||
// entitlement set) is refused as a choice the select did not offer, and writes
|
||||
// nothing.
|
||||
func TestTierAddRefusesAProductTheListLeftOut(t *testing.T) {
|
||||
w, operator, main, _, _ := addTierWorld(t)
|
||||
bare := w.Product("Bare")
|
||||
w.WithoutSet(bare)
|
||||
h := newOtcHarness(t, w.DB, operator.ID)
|
||||
|
||||
status, body := h.addTierInRequest("ref-153-f4", main.ID, bare.ID)
|
||||
|
||||
if status != http.StatusUnprocessableEntity {
|
||||
t.Errorf("the tier add of a product the list left out answered %d, want 422", status)
|
||||
}
|
||||
const want = "Choose a product from the list."
|
||||
if got := htmltest.Parse(t, body).One(t, `#form-operator\.plan-ladder\.tier\.add-product_id-error`).Text(); got != want {
|
||||
t.Errorf("the Product field's error = %q, want %q", got, want)
|
||||
}
|
||||
if n := dbtest.On(t, w.DB).Int(`SELECT count(*) FROM core.plan_ladder_tiers WHERE plan_ladder_id = $1 AND product_id = $2`, main.ID, bare.ID); n != 0 {
|
||||
t.Errorf("the refused add left %d tier rows of the product, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// wantInLine fails the test unless one line of logs holds every part: the
|
||||
// request's own access log carries the request id too, so the parts must meet
|
||||
// in the failure's line.
|
||||
|
||||
@@ -19,8 +19,9 @@ func TestPlanLadderTiersFullLadderAlert(t *testing.T) {
|
||||
Ladder: PlanLadderViewModel{PlanLadderID: "l-1", Name: "Hosting"},
|
||||
})
|
||||
|
||||
if !strings.Contains(body, "No plan products are available to add.") {
|
||||
t.Errorf("expected the full-ladder alert copy")
|
||||
const want = "No plan products are available to add. A tier needs a published product with an entitlement set that is not on this ladder yet."
|
||||
if got := htmltest.Parse(t, body).One(t, "#tierAddPanel p").Text(); got != want {
|
||||
t.Errorf("the empty Add tier panel reads %q, want %q", got, want)
|
||||
}
|
||||
if strings.Contains(body, "no product type assigned") {
|
||||
t.Errorf("stale 'product type' remedy copy still renders")
|
||||
|
||||
@@ -1640,11 +1640,12 @@ func (h *OperatorPartialsHandler) loadPlanLadderTiersData(r *http.Request, ladde
|
||||
}
|
||||
|
||||
// availableTierProducts lists the products still available to add as a
|
||||
// tier of ladderID: any active, published product not already a tier of
|
||||
// this ladder (Doc 41: display_category never gates tier membership).
|
||||
// Shared by the composite's initial render and the tier-add form's option
|
||||
// set, so a submitted product id is checked against the same list the
|
||||
// control offered.
|
||||
// tier of ladderID: any active, published product with an entitlement set
|
||||
// not already a tier of this ladder (Doc 41: display_category never gates
|
||||
// tier membership). A product with no set is left out because no source can
|
||||
// deliver a tier that has none. Shared by the composite's initial render and
|
||||
// the tier-add form's option set, so a submitted product id is checked
|
||||
// against the same list the control offered.
|
||||
func (h *OperatorPartialsHandler) availableTierProducts(ctx context.Context, ladderID string) []ProductOption {
|
||||
products, err := h.BillingQ.ListActiveProducts(ctx)
|
||||
if err != nil {
|
||||
@@ -1656,12 +1657,22 @@ func (h *OperatorPartialsHandler) availableTierProducts(ctx context.Context, lad
|
||||
for _, t := range existingTiers {
|
||||
existingProductIDs[t.ProductID] = true
|
||||
}
|
||||
return tierCandidates(products, existingProductIDs)
|
||||
}
|
||||
|
||||
// tierCandidates keeps, in the order given, the products that are published,
|
||||
// have an entitlement set and are not in onLadder. The set test is the grant
|
||||
// form's (operator_enrollment.go). From migration 00035 on, the database's
|
||||
// triggers enforce the same rule; this one-column test on a row already read
|
||||
// restates it on purpose, so a drift shows as a refusal at submit, never as a
|
||||
// write.
|
||||
func tierCandidates(products []billing.Product, onLadder map[string]bool) []ProductOption {
|
||||
var out []ProductOption
|
||||
for _, p := range products {
|
||||
if p.LifecycleStatus != "published" {
|
||||
continue // skip non-published products
|
||||
if p.LifecycleStatus != "published" || !p.EntitlementSetID.Valid {
|
||||
continue
|
||||
}
|
||||
if existingProductIDs[p.ProductID] {
|
||||
if onLadder[p.ProductID] {
|
||||
continue // skip already-attached products
|
||||
}
|
||||
out = append(out, ProductOption{ProductID: p.ProductID, Name: p.Name})
|
||||
|
||||
@@ -47,11 +47,18 @@ func TestGetProductNameCheck(t *testing.T) {
|
||||
|
||||
uniqueName := "Name Check Fixture " + uuid.New().String()[:8]
|
||||
|
||||
otherSet, err := entitlements.New(tx).CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
|
||||
Name: uniqueName + " set", IsActive: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create entitlement set: %v", err)
|
||||
}
|
||||
other, err := bq.CreateProduct(ctx, billing.CreateProductParams{
|
||||
Name: uniqueName,
|
||||
IsActive: true,
|
||||
IsPublic: false,
|
||||
LifecycleStatus: "draft",
|
||||
Name: uniqueName,
|
||||
IsActive: true,
|
||||
IsPublic: false,
|
||||
LifecycleStatus: "draft",
|
||||
EntitlementSetID: uuid.NullUUID{UUID: uuid.MustParse(otherSet.SetID), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create other product: %v", err)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
|
||||
)
|
||||
|
||||
// The tier picker offers a product that is published, has an entitlement set
|
||||
// and is not on the ladder yet, in the order the products were read: no
|
||||
// source can deliver a tier whose product has no set.
|
||||
func TestTierCandidatesOmitAProductWithNoSet(t *testing.T) {
|
||||
tierProduct := func(id, status string, withSet bool) billing.Product {
|
||||
p := billing.Product{ProductID: id, Name: "Product " + id, LifecycleStatus: status}
|
||||
if withSet {
|
||||
p.EntitlementSetID = uuid.NullUUID{UUID: uuid.New(), Valid: true}
|
||||
}
|
||||
return p
|
||||
}
|
||||
option := func(id string) ProductOption { return ProductOption{ProductID: id, Name: "Product " + id} }
|
||||
a, b, c := tierProduct("a", "published", true), tierProduct("b", "published", true), tierProduct("c", "published", true)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
products []billing.Product
|
||||
onLadder map[string]bool
|
||||
want []ProductOption
|
||||
}{
|
||||
{"a published product with a set", []billing.Product{a}, nil, []ProductOption{option("a")}},
|
||||
{"a published product with no set", []billing.Product{tierProduct("n", "published", false)}, nil, nil},
|
||||
{"a draft product with a set", []billing.Product{tierProduct("d", "draft", true)}, nil, nil},
|
||||
{"a product already on the ladder", []billing.Product{a}, map[string]bool{"a": true}, nil},
|
||||
{"a mixed list keeps the order given (A, C, B)", []billing.Product{a, c, b}, nil, []ProductOption{option("a"), option("c"), option("b")}},
|
||||
{"a mixed list keeps the order given (C, B, A)", []billing.Product{c, b, a}, nil, []ProductOption{option("c"), option("b"), option("a")}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) { checkTierCandidates(t, tc.name, tc.products, tc.onLadder, tc.want) })
|
||||
}
|
||||
}
|
||||
|
||||
func checkTierCandidates(t *testing.T, name string, products []billing.Product, onLadder map[string]bool, want []ProductOption) {
|
||||
t.Helper()
|
||||
if got := tierCandidates(products, onLadder); !slices.Equal(got, want) {
|
||||
t.Errorf("tierCandidates(%s) = %v, want %v", name, got, want)
|
||||
}
|
||||
}
|
||||
@@ -224,16 +224,6 @@ func (f *actFixture) product(name string, seats int64) world.Product {
|
||||
return p
|
||||
}
|
||||
|
||||
// setless is NoSet, a published product with no entitlement set, a state
|
||||
// the product edit form writes when the set is cleared (#153): a conferral
|
||||
// of it fails.
|
||||
func (f *actFixture) setless() world.Product {
|
||||
p := f.w.Product("NoSet")
|
||||
f.norm.Name("product", "NoSet", p.ID)
|
||||
f.w.WithoutSet(p)
|
||||
return p
|
||||
}
|
||||
|
||||
// ladder is a ladder holding tiers, named for the files.
|
||||
func (f *actFixture) ladder(name string, tiers ...world.Product) world.Ladder {
|
||||
l := f.w.Ladder(name, tiers...)
|
||||
@@ -783,9 +773,10 @@ var planActScenarios = []struct {
|
||||
return f.act(actRemoveTier, actDisposition("migrate"), actOnTier(main, basic)...)
|
||||
}},
|
||||
{"remove-second-pool-fails", func(f *actFixture) actGolden {
|
||||
basic, plus, noset := f.product("Basic", 1), f.product("Plus", 2), f.setless()
|
||||
basic, plus, drafted := f.product("Basic", 1), f.product("Plus", 2), f.product("Drafted", 1)
|
||||
main := f.ladder("Main", basic, plus)
|
||||
broken := f.ladder("Broken", noset)
|
||||
broken := f.ladder("Broken", drafted)
|
||||
f.w.Draft(drafted)
|
||||
first := f.floored("First", f.orgType("T", &main))
|
||||
u := f.orgType("U", &main)
|
||||
second := f.floored("Second", u)
|
||||
@@ -793,8 +784,8 @@ var planActScenarios = []struct {
|
||||
f.t.Fatalf("the failing pool must lock second: %s >= %s", first.PoolID, second.PoolID)
|
||||
}
|
||||
// The state a failed migrate enactment leaves: U defaults to a
|
||||
// ladder whose rank-0 product confers nothing, and Second still
|
||||
// holds Basic through the old default.
|
||||
// ladder whose rank-0 product is draft, which core.confer refuses,
|
||||
// and Second still holds Basic through the old default.
|
||||
f.w.DefaultLadder(u, broken)
|
||||
return f.act(actRemoveTier, actDisposition("migrate"), actOnTier(main, basic)...)
|
||||
}},
|
||||
@@ -980,17 +971,19 @@ var planActScenarios = []struct {
|
||||
return f.act(actChangeDefault, actNewDefault(&twin, ""), authtest.PathValue("orgType", t))
|
||||
}},
|
||||
{"default-initiate-fails", func(f *actFixture) actGolden {
|
||||
basic, noset := f.product("Basic", 1), f.setless()
|
||||
basic, drafted := f.product("Basic", 1), f.product("Drafted", 1)
|
||||
main := f.ladder("Main", basic)
|
||||
broken := f.ladder("Broken", noset)
|
||||
broken := f.ladder("Broken", drafted)
|
||||
f.w.Draft(drafted)
|
||||
t := f.orgType("T", &main)
|
||||
f.org("Planless", t)
|
||||
return f.act(actChangeDefault, actNewDefault(&broken, ""), authtest.PathValue("orgType", t))
|
||||
}},
|
||||
{"default-migrate-restore-fails", func(f *actFixture) actGolden {
|
||||
basic, noset := f.product("Basic", 1), f.setless()
|
||||
basic, drafted := f.product("Basic", 1), f.product("Drafted", 1)
|
||||
main := f.ladder("Main", basic)
|
||||
broken := f.ladder("Broken", noset)
|
||||
broken := f.ladder("Broken", drafted)
|
||||
f.w.Draft(drafted)
|
||||
t := f.orgType("T", &main)
|
||||
f.floored("Holder", t)
|
||||
return f.act(actChangeDefault, actNewDefault(&broken, "migrate"), authtest.PathValue("orgType", t))
|
||||
@@ -1179,12 +1172,13 @@ var planActScenarios = []struct {
|
||||
return f.act(actReorderTiers, actOrder("", plus, basic), authtest.PathValue("ladderID", main.ID))
|
||||
}},
|
||||
{"reorder-an-organization-fails", func(f *actFixture) actGolden {
|
||||
basic, noset := f.product("Basic", 1), f.setless()
|
||||
main := f.ladder("Main", basic, noset)
|
||||
basic, drafted := f.product("Basic", 1), f.product("Drafted", 1)
|
||||
main := f.ladder("Main", basic, drafted)
|
||||
f.w.Draft(drafted)
|
||||
t := f.orgType("T", &main)
|
||||
f.floored("Holder", t)
|
||||
f.org("Planless", t)
|
||||
return f.act(actReorderTiers, actOrder("grandfather", noset, basic), authtest.PathValue("ladderID", main.ID))
|
||||
return f.act(actReorderTiers, actOrder("grandfather", drafted, basic), authtest.PathValue("ladderID", main.ID))
|
||||
}},
|
||||
{"reorder-listing-fails", func(f *actFixture) actGolden {
|
||||
basic, plus := f.product("Basic", 1), f.product("Plus", 2)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
],
|
||||
"checked": [],
|
||||
"logs": [
|
||||
"level=ERROR msg=\"a plan act move failed\" request_id=<uuid:1> act_id=<act:1> org_id=<org:Planless> pool_id=<pool:Planless> step=initiate error=\"reapply-defaults: confer default grant: confer grant: ERROR: confer: product <product:NoSet> has no entitlement set and confers nothing (SQLSTATE P0001)\"",
|
||||
"level=ERROR msg=\"a plan act move failed\" request_id=<uuid:1> act_id=<act:1> org_id=<org:Planless> pool_id=<pool:Planless> step=initiate error=\"reapply-defaults: confer default grant: confer grant: ERROR: confer: product <product:Drafted> is draft and not conferrable (SQLSTATE P0001)\"",
|
||||
"level=WARN msg=\"Temporal is not connected; the plan act drain was not started\" request_id=<uuid:1> act_id=<act:1>"
|
||||
],
|
||||
"statements": [
|
||||
@@ -41,7 +41,7 @@
|
||||
"rows": {
|
||||
"tiers": [
|
||||
"ladder | rank | product",
|
||||
"<ladder:Broken> | 0 | <product:NoSet>",
|
||||
"<ladder:Broken> | 0 | <product:Drafted>",
|
||||
"<ladder:Main> | 0 | <product:Basic>"
|
||||
],
|
||||
"defaults": [
|
||||
@@ -69,7 +69,7 @@
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried",
|
||||
"<act:1> | org_type_default_changed | <ladder:Broken> | Broken | <product:NoSet> | NoSet | none | none | <orgtype:T> | T | <ladder:Main> | Main | none | none"
|
||||
"<act:1> | org_type_default_changed | <ladder:Broken> | Broken | <product:Drafted> | Drafted | none | none | <orgtype:T> | T | <ladder:Main> | Main | none | none"
|
||||
],
|
||||
"reached_org_types": [
|
||||
"act | org_type | org_type_name | last_org",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
],
|
||||
"checked": [],
|
||||
"logs": [
|
||||
"level=ERROR msg=\"a plan act move failed\" request_id=<uuid:1> act_id=<act:1> org_id=<org:Holder> pool_id=<pool:Holder> step=restore error=\"reapply-defaults: confer default grant: confer grant: ERROR: confer: product <product:NoSet> has no entitlement set and confers nothing (SQLSTATE P0001)\"",
|
||||
"level=ERROR msg=\"a plan act move failed\" request_id=<uuid:1> act_id=<act:1> org_id=<org:Holder> pool_id=<pool:Holder> step=restore error=\"reapply-defaults: confer default grant: confer grant: ERROR: confer: product <product:Drafted> is draft and not conferrable (SQLSTATE P0001)\"",
|
||||
"level=WARN msg=\"Temporal is not connected; the plan act drain was not started\" request_id=<uuid:1> act_id=<act:1>"
|
||||
],
|
||||
"statements": [
|
||||
@@ -42,7 +42,7 @@
|
||||
"rows": {
|
||||
"tiers": [
|
||||
"ladder | rank | product",
|
||||
"<ladder:Broken> | 0 | <product:NoSet>",
|
||||
"<ladder:Broken> | 0 | <product:Drafted>",
|
||||
"<ladder:Main> | 0 | <product:Basic>"
|
||||
],
|
||||
"defaults": [
|
||||
@@ -75,7 +75,7 @@
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried",
|
||||
"<act:1> | org_type_default_changed | <ladder:Broken> | Broken | <product:NoSet> | NoSet | none | none | <orgtype:T> | T | <ladder:Main> | Main | migrate | none"
|
||||
"<act:1> | org_type_default_changed | <ladder:Broken> | Broken | <product:Drafted> | Drafted | none | none | <orgtype:T> | T | <ladder:Main> | Main | migrate | none"
|
||||
],
|
||||
"reached_org_types": [
|
||||
"act | org_type | org_type_name | last_org",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
],
|
||||
"checked": [],
|
||||
"logs": [
|
||||
"level=ERROR msg=\"tier removal failed\" request_id=<uuid:1> ladder_id=<ladder:Main> product_id=<product:Basic> pool_id=<pool:Second> step=\"restore the organization type's default\" error=\"restore the organization type's default: reapply-defaults: confer default grant: confer grant: ERROR: confer: product <product:NoSet> has no entitlement set and confers nothing (SQLSTATE P0001)\""
|
||||
"level=ERROR msg=\"tier removal failed\" request_id=<uuid:1> ladder_id=<ladder:Main> product_id=<product:Basic> pool_id=<pool:Second> step=\"restore the organization type's default\" error=\"restore the organization type's default: reapply-defaults: confer default grant: confer grant: ERROR: confer: product <product:Drafted> is draft and not conferrable (SQLSTATE P0001)\""
|
||||
],
|
||||
"statements": [
|
||||
"begin",
|
||||
@@ -39,7 +39,7 @@
|
||||
"rows": {
|
||||
"tiers": [
|
||||
"ladder | rank | product",
|
||||
"<ladder:Broken> | 0 | <product:NoSet>",
|
||||
"<ladder:Broken> | 0 | <product:Drafted>",
|
||||
"<ladder:Main> | 0 | <product:Basic>",
|
||||
"<ladder:Main> | 1 | <product:Plus>"
|
||||
],
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"answer": [
|
||||
"status 200",
|
||||
"Hx-Trigger showSuccessToast: NoSet is now the default plan for new T organizations: 1 grandfathered, 1 not yet reached.",
|
||||
"Hx-Trigger showSuccessToast: Drafted is now the default plan for new T organizations: 1 grandfathered, 1 not yet reached.",
|
||||
"[id^=\"plan-act-\"]: 1 organization not yet reached."
|
||||
],
|
||||
"checked": [],
|
||||
"logs": [
|
||||
"level=ERROR msg=\"a plan act move failed\" request_id=<uuid:1> act_id=<act:1> org_id=<org:Planless> pool_id=<pool:Planless> step=initiate error=\"reapply-defaults: confer default grant: confer grant: ERROR: confer: product <product:NoSet> has no entitlement set and confers nothing (SQLSTATE P0001)\"",
|
||||
"level=ERROR msg=\"a plan act move failed\" request_id=<uuid:1> act_id=<act:1> org_id=<org:Planless> pool_id=<pool:Planless> step=initiate error=\"reapply-defaults: confer default grant: confer grant: ERROR: confer: product <product:Drafted> is draft and not conferrable (SQLSTATE P0001)\"",
|
||||
"level=WARN msg=\"Temporal is not connected; the plan act drain was not started\" request_id=<uuid:1> act_id=<act:1>"
|
||||
],
|
||||
"statements": [
|
||||
@@ -57,7 +57,7 @@
|
||||
"rows": {
|
||||
"tiers": [
|
||||
"ladder | rank | product",
|
||||
"<ladder:Main> | 0 | <product:NoSet>",
|
||||
"<ladder:Main> | 0 | <product:Drafted>",
|
||||
"<ladder:Main> | 1 | <product:Basic>"
|
||||
],
|
||||
"defaults": [
|
||||
@@ -95,7 +95,7 @@
|
||||
],
|
||||
"act_details": [
|
||||
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried",
|
||||
"<act:1> | tiers_reordered | <ladder:Main> | Main | <product:NoSet> | NoSet | <product:Basic> | Basic | none | none | none | none | grandfather | none"
|
||||
"<act:1> | tiers_reordered | <ladder:Main> | Main | <product:Drafted> | Drafted | <product:Basic> | Basic | none | none | none | none | grandfather | none"
|
||||
],
|
||||
"reached_org_types": [
|
||||
"act | org_type | org_type_name | last_org",
|
||||
|
||||
@@ -238,8 +238,11 @@ func (w *World) EditProduct(p Product, edit func(*billing.UpdateProductParams))
|
||||
}
|
||||
|
||||
// WithoutSet clears p's entitlement set, as the operator's edit form does
|
||||
// when it is saved with no set chosen. A product in this state confers
|
||||
// nothing: a grant or a subscription to it is refused or left out.
|
||||
// when it is saved with no set chosen on a product that is no plan tier. A
|
||||
// tier's set cannot be cleared once migration 00035 is in place
|
||||
// (chk_products_tier_keeps_set), so the kit clears it only on a product no
|
||||
// ladder holds. A product in this state confers nothing: a grant or a
|
||||
// subscription to it is refused or left out.
|
||||
func (w *World) WithoutSet(p Product) {
|
||||
w.TB.Helper()
|
||||
w.EditProduct(p, func(u *billing.UpdateProductParams) { u.EntitlementSetID = uuid.NullUUID{} })
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-24
|
||||
@@ -0,0 +1,313 @@
|
||||
## Context
|
||||
|
||||
`core.confer` (latest definition `internal/db/migrations/00023_grant_resumption.sql`) refuses three product states before it writes anything: a NULL `entitlement_set_id`, "confer: product % has no entitlement set and confers nothing" (`:447-449`); a draft product, "confer: product % is draft and not conferrable" (`:459-460`); and a retired product, unless the same standing source held it before, which for a `default` grant means any earlier default grant of it in the pool (`:461-492`). It reads nothing else about the set: a set with no active rule, or an inactive set, confers a provision that carries no entitlements.
|
||||
|
||||
Restoring a default goes through `ReapplyDefaultsForPool` (`internal/entitlements/reapply_defaults.go:39-75`): it resolves the org type's default ladder and its rank-0 tier, and confers that product as a `default` grant. `ReapplyDefaultsIfVacant` (`:97-114`) runs it when the pool holds nothing. Its callers are first sign-in (`internal/provisioning/provisioning.go:145-195`), revocation (`internal/entitlements/grant_acts.go:309-314`), expiry (`:388-393`), a subscription's end (`reconcileEnded`, `internal/fulfillment/reconcile.go`; the floor restoration's error returns before the branch's `tx.Commit()`), a tier removal with Migrate (`internal/entitlements/ladder_acts.go:445-449`), the plan-act moves (`internal/entitlements/plan_act_move.go:135-143`, `:210-212`) and the demo seed's floor (`internal/demoseed/demoseed.go:603`). When the rank-0 product has no set, every one of them fails, as the proposal lists.
|
||||
|
||||
The console writes the bad pair (an org type with a default ladder whose rank-0 product has `entitlement_set_id IS NULL`) on six paths. The default change's first transaction (T0) reads the candidate's rank-0 product and refuses only a ladder with no rank 0 (`internal/entitlements/default_change.go:197-216`, refusal at `:199-200`). The reorder (`internal/entitlements/tier_reorder.go:152-221`) reads nothing of the new rank 0's set. A tier removal (`ladder_acts.go:267-311`, deletion and renumbering at `:398-414`) and a tier delete (`:662-710`) promote rank 1 without reading its set. A tier add refuses only an unpublished product (`:512-514`), and its picker offers every active, published product not already on the ladder (`availableTierProducts`, `internal/server/operator_plan_ladders.go`). The product edit accepts "None" (`internal/server/operator_products.go:339-409`; `openspec/specs/product-management/spec.md:101`), and the write is one `UPDATE` (`UpdateProduct`, `internal/billing/queries/products.sql:47-51`), the only writer of the column.
|
||||
|
||||
The previews do not help: the default-change preview refuses only a missing rank 0 (`resolveCandidate`, `internal/server/operator_org_types.go:445-468`, sentence at `:462`), and the rule-less warning returns "" for a product with no set (`internal/server/rule_less_warning.go:48-52`), so a product with no set reads as a working default.
|
||||
|
||||
`core.products.entitlement_set_id` has no foreign key (`internal/db/migrations/00001_init.sql:175-178`, `:189`; #99), while `core.pool_provisions.entitlement_set_id` is `NOT NULL` with one (`:773`, `:777`). No path changes an organization's type (`trigger_organizations_refuse_org_type_change`, `00026_outgoing_default_rule.sql:177-181`), so the issue's "org-type migration" is the default change's per-organization move.
|
||||
|
||||
Two precedents shape the fix. The grant form already hides a product with no set (`loadIssuanceProducts`, `internal/server/operator_enrollment.go`). And plan-ladder-management already refuses a draft tier candidate "since draft products are never conferrable" (`openspec/specs/plan-ladder-management/spec.md:77-78`); this change applies the same reason to a product with no set.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- No default ladder can start on a product with no entitlement set, whichever path changes the ladder, the product or the default.
|
||||
- No member can buy a plan tier that delivers nothing because its product has no set.
|
||||
- The operator who tries either refused action reads why, at the field, in one sentence.
|
||||
|
||||
**Non-Goals:**
|
||||
- A check in the default change, the reorder, the removal or the delete (D4).
|
||||
- Code for rows that already break the rule (D5).
|
||||
- The lifecycle leg, a set id that points at no row, and off-ladder products with no set (proposal, Out of scope).
|
||||
- Widening the sign-in degrade (D9).
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1. Where the rule lives
|
||||
|
||||
Options:
|
||||
- (a) Refuse in the default change's T0 only, as #153's comment suggests: one more test on the rank-0 product it already reads (`default_change.go:209`).
|
||||
- (b) An invariant on the pair, "a default ladder's rank 0 confers", enforced at all six paths and their previews.
|
||||
- (c) No plan ladder tier may have a product with no entitlement set.
|
||||
- (d) Make `core.confer` of a product with no set a no-op that records a conferral with no entitlements.
|
||||
- (e) `products.entitlement_set_id NOT NULL`.
|
||||
|
||||
Pick: (c).
|
||||
|
||||
Reason: a tier with no set cannot be delivered by any source. `core.confer` refuses it for grants and defaults, and the subscription reconcile skips it with a warning (`mirrorItems`, `internal/fulfillment/reconcile.go`; cited by function because `reconcile-takes-pool-lock` is moving its lines). Every default ladder's rank 0 is a tier, so (c) closes #153 at two guard points: a tier insert of a product with no set, and clearing the set of a product that is a tier. The rule does not depend on ranks or org types, so the reorder's renumbering, which parks every rank at a negative value before it assigns 0..N-1 (`ladder_acts.go:20-43`), needs no deferred check.
|
||||
|
||||
- (a) leaves five paths open. A reorder fails per organization exactly as #187 describes, a delete or a removal with Keep commits the pair silently, and clearing rank 0's set on the product page breaks sign-in.
|
||||
- (b) needs six guard sites and about five sentences. Its database form needs deferred constraint triggers on three tables, and the product edit would have to take the shared rendezvous to avoid write skew with the acts, a new lock on a path that has none. It still leaves any other tier with no set sellable.
|
||||
- (d) would make `pool_provisions.entitlement_set_id` nullable, and a set given to the product later would never reach those provisions, since a provision keeps the set it was conferred with (`docs/models/product-catalog.md`, invariant 10). It erases the line between "unfinished" (a set with no rule) and "undeliverable" (no set).
|
||||
- (e) decides #98 in advance: it rules out #98's option that authoring a set is never a prerequisite.
|
||||
|
||||
What (c) refuses that is legal today: on a ladder no type defaults to, making a product with no set a tier, and clearing the set of a product that is a tier. Both produce a tier no source can deliver. The proposal says so in What Changes. Rank-0 free tiers keep working: they keep a set, which may have no rules, and the rule-less warning still applies. Off-ladder products with no set, such as the demo seed's add-ons (`demoseed.go:426-428`), stay allowed.
|
||||
|
||||
### D2. Enforcement
|
||||
|
||||
Options:
|
||||
- (i) Two database triggers, with the handlers mapping the constraint names to copy through their existing `ConstraintMessages`.
|
||||
- (ii) Go guards: at the tier add (`find`, with a `FOR SHARE` read of the product) and at the product edit (a transaction that updates the product, then reads its tiers).
|
||||
|
||||
Pick: (i).
|
||||
|
||||
Reason: the triggers also cover the demo seed, SQL run by hand and the test fixtures, and the copy comes from one source, the constraint name. The triggers are the rule. The picker filter and the edit form's option list only keep the console from offering what the triggers refuse. They state the rule again on purpose, as the grant form does (`loadIssuanceProducts`, `operator_enrollment.go`), because both are one-column tests on a row already read. A drift between them and the triggers shows as R1 or R2 at submit, never as a write. (ii) needs no fixture sweep, but then the fixtures keep building a state the console forbids.
|
||||
|
||||
Migration `internal/db/migrations/00035_tier_products_keep_a_set.sql`:
|
||||
|
||||
```sql
|
||||
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;
|
||||
$$;
|
||||
|
||||
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();
|
||||
|
||||
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;
|
||||
$$;
|
||||
|
||||
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();
|
||||
```
|
||||
|
||||
- **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`).
|
||||
- **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.
|
||||
|
||||
### D3. The product edit's "None" for a tier product
|
||||
|
||||
Options:
|
||||
- Keep "None" offered and refuse it on submit with a field error.
|
||||
- Leave "None" out of that record's option list, and say why on the field. A page rendered before the product became a tier can still submit it, so a refusal is needed anyway.
|
||||
|
||||
Pick: leave it out and say why. The first option was the earlier pick: it keeps the option list as the spec declares it today, and its refusal names the ladders. It is set aside for the reason below.
|
||||
|
||||
Reason: the console does not offer an action whose outcome it would refuse, and it says why visibly (the rule the plan-act work set: a control the page cannot honour is not drawn as available). The grant form already leaves out products it cannot deliver (`loadIssuanceProducts`, `operator_enrollment.go`).
|
||||
|
||||
The rule, for the edit side's Entitlement set select:
|
||||
|
||||
- **"None" is offered** only when the product is known to be no ladder's tier. A tier product never gets it, whatever its stored set, and neither does a product whose ladders could not be read.
|
||||
- **The placeholder takes its place.** A select that does not offer "None" opens with the library's disabled `forms.ChooseOption("an entitlement set")`, "Choose an entitlement set" (Copy, R3), as `form-conventions` requires of a select whose value must be chosen ("A select that needs a choice opens with a placeholder option"; `internal/forms/forms.go:480-487`). In record mode the product's own set is the selected option, so the placeholder sits unselected at the top of the list. It matters in two states. A refused submission of "None" re-renders the submitted empty value, and `selectOptions` marks the placeholder selected (`internal/forms/render.go:1060-1080`); with no placeholder, no option would be marked and the browser would show the first real set, which a second Save would write without the operator choosing it (the defect `loadEntitlementSetOptionsFor` was written against, `operator_products.go:860-866`). And a tier whose stored set is already NULL, a row from before migration `00035` (D5), shows the placeholder selected, so the select never displays a real set the product does not have (`product-management/spec.md:101`). The select then carries `required`, since no enabled option has an empty value (`render.go:1132-1138`), and a disabled option is never submitted, so saving such a product untouched is refused with R2 until a set is chosen. Its stored set stays NULL, as the spec's "saving an untouched form SHALL NOT change the stored entitlement set" requires, and the refusal points at the fix D5 asks for.
|
||||
- **The field's one line** is R2 (Copy) when the product is a tier of at least one ladder, in place of the off-ladder sentence "The set is what the product delivers; a product with none delivers no capabilities." (today the field's declared hint, `operator_product_forms.go:82`; it moves to the slot, below). That sentence describes "None", and for a tier product the line now states the prerequisite instead. Under `AGENTS.md`'s copy rule a prerequisite is a fact that earns its line.
|
||||
- **When the ladder read fails**, the select does not offer "None" and opens with the placeholder, and the field shows no line: neither the off-ladder sentence, which describes an option that is not offered, nor R2, whose ladders are unknown. The failed read is never shown as "no tier" (#207). On submit, the handler logs the failure (Copy, L3) and answers the existing form-level "Failed to update the product. Details are in the server logs." (`operator_products.go:401`), writing nothing. It renders a fresh error set holding only that form-level message, so the parse's `entitlement_set_id` error ("Choose an entitlement set.") is dropped (the library has no call that removes one field's error), because that message reads as though a set were required while the product may be on no ladder; the form-level failure is the only refusal. It cannot state the option list the page offered, and it does not answer with a sentence that leaves the ladders out.
|
||||
- **A stale page that submits "None"** for a product that is a tier now: the parse checks the value against the current option list, whose only empty option is the disabled placeholder, and refuses with the library's "Choose an entitlement set." (`internal/forms/parse.go:335`, `:480-481`). The handler replaces that field error with R2 when the submitted value is empty and the product is a tier. Nothing is written.
|
||||
- **A product that becomes a tier between the handler's read and its write**: the parse accepts "None" and the trigger refuses the update. On a refused write the handler reads the product's ladders again and maps `chk_products_tier_keeps_set` to R2 over the names it read. If that read fails, or finds no ladder (the tier was removed again in the meantime), the entry maps to the form-level failure sentence instead, so the constraint never falls to the generic "This combination of values is not allowed." (`dberrors.go:51-52`). `web.FieldErrorsFromDB` answers `ok=true` there, so the handler's existing `failed to update product` log would not run; in this branch it logs `failed to update product` with the error and `product_id` itself (Copy, L4), so the page's "Details are in the server logs" has a line behind it.
|
||||
- **The refused submission re-renders** with R2 as the field's error and no line beside it, because the line would repeat the error word for word. Every other field keeps its hint beside its error (`describeField`); this field departs from that only while its line and its error are one sentence. The rule is general: while the Entitlement set field of a tier product carries an error, the slot is empty, so the error is the field's one line.
|
||||
|
||||
`<ladders>` comes from `ListLaddersByProduct` (`internal/billing/queries/plan_ladders.sql:82-87`), which already orders by ladder name. The edit page already runs it for its "Plan ladders" section (`operator_products.go:806-817`), so the form reads the tiers from that one read on the page and adds no statement to it. `UpdateProduct` makes one read of its own before the parse, because the parse needs the option list. A plain type, `productTier{ladders []string; unread bool}` (the ladders' names in the query's order, and whether they could not be read), carries the answer to `productSetOptions` and to the line. `tierNeedsSetSentence(ladders []string) string` builds R2 with `joinWithAnd` (`internal/server/operator_partials.go:837-849`). That is the join the grant lines and the member's plan moves use (`internal/server/member_plans.go:669`, `:730`), and the one `LastTierRefusal` writes inline (`internal/entitlements/plan_act_runner.go:254-258`). A rule the page shows and the endpoint enforces is one function that reads no row (`docs/go-conventions.md`, "Where a rule and an act live"), so the option list and the line are pure functions of `productTier`, and the page and `UpdateProduct` both call them.
|
||||
|
||||
**The line, through a slot.** The Entitlement set field declares no `Hint` and a declared `Slot`, `line`. The handler fills it per record through `Binding.Slots["entitlement_set_id"]`, the mechanism three forms already use for a line the record decides: the product name's duplicate-name warning (`productNameField` and `productEditSlots`, `operator_product_forms.go`), the tier add's rule-less warning (`operator_plan_ladder_forms.go:115`) and the grant form's (`operator_enrollment_forms.go:220`); the slot's content reaches the render through `Binding.Slots` (`internal/forms/render.go:68-70`, `:1109-1112`). The slot holds one of three things: the off-ladder sentence "The set is what the product delivers; a product with none delivers no capabilities.", a Go constant `productSetOffLadderLine` beside `productSetLine`, which `productCreateForm` passes on the create page as well; R2 for a tier whose field has no error; nothing when the ladders are unread, or when a tier product's field carries an error. The line is in the hint's own markup (`<div class="form-text">`), so the line keeps the hint's type on both pages. The slot is the field's one line because the field declares no hint, so there is no second line. The option list already follows the same split: a field declares `RuntimeOptions`, and the handler gives the same list to the render and to `ParseSide` (`render.go:64-67`; `operator_product_forms.go:209-213`).
|
||||
|
||||
The cost: the sentence stops being a declared hint, so the registry's hint checks (`internal/forms/invariants.go:266-291`, among them the 100-character cap) no longer read it, and `TestFormsHTML` regenerates `internal/server/testdata/forms/operator.product.html` once, its diff read. A per-render hint override in the library (`Binding.Hints`) was considered and set aside: it would be a new rule in the form-library contract, a structural change that `CONTRIBUTING.md` item 1 gives its own issue and its own change, and the slot already does the job. The `filler-copy` lint still reads the Go literals that build R2.
|
||||
|
||||
### D4. No check in the default change or the reorder
|
||||
|
||||
Options: none; or (a)'s check in T0 as well, and one in the reorder.
|
||||
|
||||
Pick: none.
|
||||
|
||||
Reason: under D1 and D2 no write can produce the pair. A check would serve only rows written before migration `00035`, which makes it compatibility code for one deployment's history. The maintainer's rule is to delete such code before launch and fix the deployment by hand after a read-only count (D5). The default-change preview and its option labels (`internal/server/operator_org_type_forms.go:86-94`) stay as they are.
|
||||
|
||||
### D5. Rows that already break the rule
|
||||
|
||||
Options: a migration that refuses while violators exist (an upgrade stop); a card line on the org types page that extends `DefaultBroken` (`operator_org_types.go:51-57`); a boot check; or nothing in code, with read-only counts at landing and a fix by hand.
|
||||
|
||||
Pick: nothing in code. The triggers refuse transitions and new tier rows, and rows that already violate the rule stay. Before launch only the production deployment and development databases can hold them. Three read-only counts run on the test stack's app database before anything in this change writes to it or resets it (tasks, item 1.0). The landing also re-runs the sequence the issue reproduces with (tasks, item 5.4). The maintainer runs the same counts on the production deployment as the reader role and fixes each hit by hand. This change's tasks do not touch the production deployment.
|
||||
|
||||
An operator's ways out, after the change: give the product a set on its page (NULL to a set passes the trigger), after which the failed obligations succeed on Retry; change the default to a ladder whose rank 0 has a set; or remove the bad tier.
|
||||
|
||||
### D6. The tier picker
|
||||
|
||||
Options: hide products with no set; or show them and refuse on submit.
|
||||
|
||||
Pick: hide them, with the grant form's test (`!p.EntitlementSetID.Valid`, `loadIssuanceProducts` in `operator_enrollment.go`). The filter is a second statement of the rule that D2 names on purpose. It moves into a pure function, `tierCandidates(products []billing.Product, onLadder map[string]bool) []ProductOption`, which `availableTierProducts` calls, so a unit test covers it. A stale page or a hand-built post of such a product is refused by the parse against the same list ("Choose a product from the list.", `parse.go:328`), as an unpublished product is today. The filter lands in the first section, with the fixtures: it passes on today's tree, and without it a walkthrough run between the migration and the tier add's section would add the picker's second option (`test/e2e/operator-walkthroughs/plan_ladders_test.go:272`), which on a demo-seeded app database is `demo-addon`, a product with no set, and meet the trigger. R1 answers the case the picker cannot see: a product whose set was cleared after the picker read it and before the insert, which the tier trigger refuses.
|
||||
|
||||
### D7. Fault injection for the per-organization failure goldens
|
||||
|
||||
Four goldens (`internal/server/plan_acts_golden_db_test.go:785-800`, `:982-997`, `:1181-1188`; `internal/server/testdata/plan_acts/{default-initiate-fails,default-migrate-restore-fails,remove-second-pool-fails,reorder-an-organization-fails}.json`) and `TestResumption_ARefusedConferralIsRolledBackAlone` (`internal/entitlements/grant_resumption_test.go:293-326`) make the conferral fail with a product that has no set. Under D2 that product can no longer be a tier.
|
||||
|
||||
Options: a draft lifecycle on the product, set after the tier is in place (`w.Draft`, `internal/testkit/world/plans.go:209-215`; `core.confer` refuses a draft product always, `00023:459-460`); or a test-only hook. The goldens' refusing queriers (`actRefusals`, `plan_acts_golden_db_test.go:99-115`) wrap Go queriers and cannot reach inside `core.confer`.
|
||||
|
||||
Pick: draft. The failure is raised at the same statement and step, and no production code gains a hook. The golden helper `setless()` (`:227-235`) is deleted. Each case makes the product with the fixture's own `f.product("Drafted", 1)` (`:214-225`), which names the product and its set for the files and gives the set a rule, so no rule-less warning can enter an answer, and calls `f.w.Draft` on it once the ladder that holds it is built, the order a later retire or draft act would produce. Nothing the four acts read before their moves looks at a product's lifecycle; the tier picker is the only reader of it on these paths (`tierCandidates`, `operator_plan_ladders.go`). Every recorded `NoSet` reads `Drafted`: the placeholder `<product:NoSet>` becomes `<product:Drafted>`, the toasts and act rows that name the product change with it, and the driver text in the log lines changes from "has no entitlement set and confers nothing" to "is draft and not conferrable"; `last_error` changes the same way in the database, and the goldens do not record it. The statements, the steps and the obligation rows do not change.
|
||||
|
||||
### D8. What the triggers treat as "no set"
|
||||
|
||||
Options: NULL only; or NULL and a set with no active rule.
|
||||
|
||||
Pick: NULL only. `openspec/specs/org-types/spec.md:302-327` and `plan-ladder-management/spec.md:605-633` require a set with no active rule to be warned about and never refused, "because a product whose set has no rule is unfinished rather than dangerous: a rule added later now reaches every pool carrying the set" (`org-types/spec.md:306`). The code draws the same line (`rule_less_warning.go:48-52`). An inactive set is not refused either, since `core.confer` does not read `is_active`.
|
||||
|
||||
### D9. The sign-in degrade
|
||||
|
||||
Options: none; or extend "First-login provisioning survives a missing bottom tier" (`auto-provisioning/spec.md:174-186`) to any refused default conferral.
|
||||
|
||||
Pick: none in this change. Under D1 a missing set cannot cause one. The draft and retired causes are not reachable from the console and belong to #155 and #97. The auto-provisioning spec is unchanged.
|
||||
|
||||
### D10. Migration number and the changes beside this one
|
||||
|
||||
`displaced-subscription-waits` claims `00033` and `00034` (its design, Migration plan), and `reconcile-takes-pool-lock` adds no migration. This change takes `00035` and lands after both. If it lands first, its file takes the next free number after `00032_lock_plan_act_obligation.sql` and the other change renumbers, whichever comes second. The spec deltas touch no requirement the other two modify. `docs/database-locks.md` is edited by all three, in different places: this change edits the opening paragraph, the row-lock table's new "Product row" entry and the tier add's path, while the other two edit the pool row's "Taken by" cell, "Subscription reconcile", rule 2 and "Where the paths differ". `docs/plan-management.md` is edited by this change in "Attaching Tiers" and by `displaced-subscription-waits` in "Issue Grant". This change does not edit `internal/fulfillment/reconcile.go`, which `reconcile-takes-pool-lock` is editing; where that edit has moved a cited line (`mirrorItems`' skip), the citation names the function instead.
|
||||
|
||||
## Failure table
|
||||
|
||||
"The pair" is an org type whose default ladder's rank-0 product has no set.
|
||||
|
||||
| # | State | Today | After | Rule |
|
||||
|---|---|---|---|---|
|
||||
| F1 | A default change to a ladder whose rank-0 product has no set | Commits; each organization's move fails at initiate or restore; Retry fails | Cannot arise: no ladder holds such a tier, except rows from before the migration | D1, D4, D5 |
|
||||
| F2 | A reorder that moves a product with no set to rank 0 of a default ladder | Commits; each move fails | Cannot arise | D1 |
|
||||
| F3 | A removal or delete of rank 0 that promotes a product with no set | Commits the pair (Keep, no holders, delete) or rolls back (Migrate) | Cannot arise | D1 |
|
||||
| F4 | A tier add of a product with no set, offered by a page rendered before | Commits | Refused by the parse, "Choose a product from the list."; nothing written | D6 |
|
||||
| F5 | A tier add of a product whose set is cleared after the picker read it | Commits | The trigger refuses the insert; 422 with R1 under Product; no tier row and no act row | D2, D6 |
|
||||
| F6 | A tier add of a product with a set | Commits | Unchanged, and the insert holds the product row until commit | D2 |
|
||||
| F7 | The product edit of a tier product, page rendered now | "None" offered; saving it commits the pair when the ladder is a default | No "None"; the first option is R3, disabled, and the product's set is selected; the field's line is R2 | D3 |
|
||||
| F8 | The same, from a page rendered before the product became a tier, submitting "None" | Commits | 422, R2 as the field's error and no other line, the select showing R3 selected, nothing written | D3 |
|
||||
| F9 | The product becomes a tier between the handler's read and its write | Commits | The trigger refuses; the handler reads the ladders again; 422 with R2 | D2, D3 |
|
||||
| F10 | The same, and that second read fails or finds no ladder | Commits | 422 with the form-level failure sentence; the refusal is logged (L4), and a read failure as well (L3) | D3 |
|
||||
| F11 | The product edit when the ladder read fails | The field reads normally; the "Plan ladders" section reads empty (#207) | No "None", R3 first; no line under the field; a save, an empty set included, answers the failure sentence as the only refusal and writes nothing; L3 logged | D3 |
|
||||
| F12 | The product edit of a product on no ladder | "None" offered with the off-ladder sentence; saving it clears the set | Unchanged | D3 |
|
||||
| F13 | A tier product's set replaced, A to B | Saves | Unchanged | D2 (the `WHEN`) |
|
||||
| F14 | A tier product whose set is already NULL (a row from before the migration) | Edits save | No "None"; R3 selected, line R2; a save without a chosen set is refused with R2 and writes nothing; choosing a set saves (NULL to a set passes the trigger) | D3, D5 |
|
||||
| F15 | A tier product edited in another field | Saves | Unchanged | D2 (the `WHEN`) |
|
||||
| F16 | A tier add and a set clear on one product at the same time | Both can commit | One commits; the other is refused | D2 (T3) |
|
||||
| F17 | Rank 0's set has no active rule | Warned on the previews | Unchanged | D8 |
|
||||
| F18 | The set is deactivated, or its id names no row | Confer succeeds, or fails at the provisions foreign key (#99) | Unchanged | D8; out of scope |
|
||||
| F19 | Rank 0 is draft or retired | Restoration fails | Unchanged (#155, #97) | Out of scope |
|
||||
| F20 | The demo seed | Its tier products carry the plan set (`demoseed.go:407-462`, tiers at `:496-516`) | Unchanged; the seed's tier inserts pass the trigger. A seed run over a tier row from before the migration whose product has no set is refused, because a row-level `BEFORE INSERT` trigger fires before `ON CONFLICT DO NOTHING` (`EnsurePlanLadderTier`, `plan_ladders.sql:53-67`) skips the row; D5's counts find that row | D2, D5 |
|
||||
| F21 | A tier insert naming a product id no row holds | The foreign key refuses | Unchanged: the trigger finds no row and leaves it to the foreign key | D2 |
|
||||
| F22 | Ladder create (research P7) | Commits | Unchanged: a new ladder has no tiers, and a default change refuses a ladder with no rank 0 (`default_change.go:199-200`) | D1 |
|
||||
| F23 | Org type create (research P8) | Cannot arise | Cannot arise: no surface creates a type; `00003` seeds `personal` with no default, and `EnsureSystemOrgType` writes none (`org_types.sql:22-28`) | D1 |
|
||||
| F24 | Migrations (research P14) | None before `00035` writes a tier of a product with no set or a default ladder | Unchanged; any later migration meets the triggers | D1, D2 |
|
||||
|
||||
## Locks and transactions
|
||||
|
||||
The new lock is the product row, `FOR SHARE`, taken by the tier trigger inside the tier insert. The tier add's order becomes: the exclusive rendezvous, the reads that decide the add, the pool rows of the product's holders in ascending order, the ladder's name, the act rows, then the tier insert, whose trigger takes the product row, then the collision read, the alignments and the materializations (`docs/database-locks.md`, "Tier add").
|
||||
|
||||
The product edit takes the same row in `FOR NO KEY UPDATE` through its `UPDATE`, in one statement with no transaction of its own and no other lock. Its trigger reads the tier rows without locking them. So the product edit never waits for a lock the tier add holds, and no cycle forms. Nothing else locks product rows: `UpdateProduct` is the only writer of the table outside the test kit, and the foreign keys that reference it take `FOR KEY SHARE`, which conflicts with neither mode. Two tier adds of one product share the row in `FOR SHARE`, and the exclusive rendezvous already serializes them. The demo seed adds its tiers outside the rendezvous (`docs/database-locks.md`, rule 1) and takes the row the same way.
|
||||
|
||||
A wait for the product row is bounded by the rendezvous's 5 s `lock_timeout`. A tier add that waits past it fails with SQLSTATE 55P03, which `refuseTierAdd` already reads as the contention refusal (`db.IsLockContention`, `operator_plan_ladders.go:497-499`).
|
||||
|
||||
`docs/database-locks.md` changes with the migration. The opening paragraph's "No code takes ... an explicit `FOR SHARE`" gains the tier trigger as its second exception. The row-lock table gains:
|
||||
|
||||
| Lock | Row | Mode | Taken by | Serializes |
|
||||
|---|---|---|---|---|
|
||||
| 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 (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. |
|
||||
|
||||
The "Tier add" bullet names the tier insert's lock on the product row.
|
||||
|
||||
## Copy
|
||||
|
||||
Every sentence a person reads that this change adds. The UI copy rules apply (`AGENTS.md`, "UI copy"; `openspec/specs/ui-vocabulary/spec.md`, "Copy earns its place"): one line, no em dash, no filler. Each states a prerequisite, which is a fact the copy rules keep. Angle brackets mark a value filled at run time.
|
||||
|
||||
### Operator
|
||||
|
||||
| # | Where | Text |
|
||||
|---|---|---|
|
||||
| R1 | Tier add, the field error under Product, when the trigger refuses the insert (F5) | `Only products with an entitlement set can be tiers.` |
|
||||
| R2 | Product edit, the Entitlement set field's one line, in place of the off-ladder sentence, when the product is a tier (F7, F14); and the field's error when "None" is refused for a tier product (F8, F9, F14) | `This product is a tier of <ladders>, and a tier needs an entitlement set.`, for example `This product is a tier of Forum and Hosting, and a tier needs an entitlement set.` |
|
||||
| R3 | Product edit, the Entitlement set select's first option, disabled, when "None" is not offered (F7, F8, F11, F14) | `Choose an entitlement set`, the library's own placeholder (`forms.ChooseOption("an entitlement set")`), not a new string |
|
||||
| R4 | Tier add, the panel's empty state, when no product can be added (F4); its first sentence is the existing one, and the second replaces the old second sentence ("Every published product is already a tier on this ladder, or no products are published yet.") because the picker now also leaves out products with no set | `No plan products are available to add. A tier needs a published product with an entitlement set that is not on this ladder yet.` |
|
||||
|
||||
`<ladders>` is the display names of the ladders the product is a tier of, in name order, joined `A`, `A and B`, `A, B and C` with `joinWithAnd` (D3).
|
||||
|
||||
R1 follows the published-product refusal ("Only published products can be tiers. This product is {status}.", `operator_plan_ladders.go:466`) and has no second sentence: with only one way to break the rule, "This product has none." would restate it.
|
||||
|
||||
Reused unchanged: "Failed to update the product. Details are in the server logs." (`operator_products.go:401`, F10, F11), and "Choose a product from the list." (`parse.go:328`, F4). "Choose an entitlement set." (`parse.go:335`), the parse's refusal of the empty value, never reaches the page of a product known to be a tier: the handler replaces it with R2. When the ladders could not be read, the form-level failure is the only refusal.
|
||||
|
||||
### Database texts and log lines
|
||||
|
||||
| # | Where, level | Text |
|
||||
|---|---|---|
|
||||
| L1 | `core.refuse_tier_product_without_set`, the raise; reaches logs only | `tier: product <id> has no entitlement set`, constraint `chk_plan_ladder_tiers_product_has_set` |
|
||||
| L2 | `core.refuse_tier_set_clear`, the raise; reaches logs only | `product <id> is a plan tier and keeps its entitlement set`, constraint `chk_products_tier_keeps_set` |
|
||||
| L3 | The product edit's read of the product's tiers (the product row and its ladders), on the page and in `UpdateProduct` (Error); logged at one site, `productTierOf`, which the page loader and `loadProductTier` both call | `product edit: tier read failed`, attributes `product_id`, `error` |
|
||||
| L4 | `UpdateProduct`, a write refused with `chk_products_tier_keeps_set` whose second ladder read fails or finds no ladder (Error) | `failed to update product`, attributes `product_id`, `error` |
|
||||
|
||||
## Tests
|
||||
|
||||
Each test claim is pinned by a break (`docs/testing.md`, "Prove a test with deliberate breaks"). The tasks list every break with the text its failing test prints. A break of the migration's SQL is a command record that resets the test databases with the broken file before it runs the test, since the shared database runs the triggers as migrated.
|
||||
|
||||
| Test (home) | Proves | Break it catches |
|
||||
|---|---|---|
|
||||
| T1 `TestATierOfAProductWithNoEntitlementSetIsRefused` (`internal/db`) | As `console_runtime`: a tier insert of a product with no set fails with `chk_plan_ladder_tiers_product_has_set` and leaves no tier row; a product with a set inserts; changing a tier's product to one with no set is refused | The function's `IF FOUND AND v_set_id IS NULL` branch removed; the trigger declared on `INSERT` only |
|
||||
| T2 `TestClearingTheSetOfATierProductIsRefused` (`internal/db`) | As `console_runtime`: set to NULL on a tier product is refused with `chk_products_tier_keeps_set`; A to B on a tier product passes; NULL on an off-ladder product passes; an update of a tier product that writes its own set back, as the product edit's statement does, passes; and on a tier row written with the tier trigger disabled inside the test's transaction (the state a row from before the migration holds, written the way `internal/db/integration_delivery_schema_test.go:422` bypasses a guard), an update that leaves the NULL set NULL passes | (1) The `WHEN` without `AND NEW.entitlement_set_id IS NULL` (A to B refused); (2) the function's tier `EXISTS` replaced by `TRUE` (the off-ladder clear refused); (3) the `WHEN` without `OLD.entitlement_set_id IS NOT NULL AND` (the NULL-to-NULL update refused) |
|
||||
| T3 `TestATierAddAndAClearedSetCannotBothCommit` (`internal/db`, two connections, `dbtest.AwaitLockWait`) | Two orders, each a row: the tier insert first, then the set clear waits, the insert commits, the clear is refused; the set clear first, then the insert waits, the clear commits, the insert is refused | `FOR SHARE` removed from the tier trigger: neither order waits, and both writes commit |
|
||||
| T4 `TestTierAddRefusesAProductWhoseSetWasClearedSinceThePicker` (`internal/server`) | Through the handler, with a handler querier whose product list still shows the set: 422 with R1 under `product_id`, compared as the whole sentence; no tier row and no `tier_added` act row for the ladder | The `chk_plan_ladder_tiers_product_has_set` entry removed from `refuseTierAdd`'s map: `web.FieldErrorsFromDB` then answers the class message "This combination of values is not allowed." in the form-level slot (`dberrors.go:51-52`), and the Product field carries no error |
|
||||
| T5 `TestTierCandidatesOmitAProductWithNoSet` (`internal/server`, no database) | `tierCandidates` leaves out a published product with no set and keeps a published one with a set; a draft product and one already on the ladder stay left out, as today; a mixed list keeps the order given, fed in two orders | The `!p.EntitlementSetID.Valid` test removed; the output reversed (a swap loop inserted before `return out` in `tierCandidates`) |
|
||||
| T6 `TestProductEditOfATierProductOffersNoNone` (`internal/server`) | The record page of a tier of two ladders, created in name order and in reverse (two rows): no "None" option, R3 the first option and disabled, the product's set selected, the field's line R2 with the ladders in name order; the PUT of "None" answers 422 with R2 once under the field, no other line under it, R3 the selected option, and the stored set unchanged | The handler's replacement of the parse's error removed (the field's error reads "Choose an entitlement set."); `productSetOptions` offering "None" to a tier product (the record page carries the "None" option); `ListLaddersByProduct`'s `ORDER BY l.name ASC` replaced by `ORDER BY l.plan_ladder_id DESC` in the generated `internal/billing/plan_ladders.sql.go` (the row that creates Forum first reads Hosting first) |
|
||||
| T6b `TestProductEditRefusesNoneForAProductThatBecameATier` (`internal/server`) | With a handler querier whose first ladder read finds none: the PUT of "None" reaches the trigger and answers 422 with R2; with the second read failing as well (`none, fail`), or finding no ladder (`none, none`), the form-level failure sentence, and in the `none, none` row a log line holding `failed to update product`, the product's id and L2's text; the stored set is unchanged in all | The `chk_products_tier_keeps_set` entry removed: "This combination of values is not allowed."; the `failed to update product` log call of that branch deleted |
|
||||
| T6c `TestProductEditWithUnreadLaddersSavesNothing` (`internal/server`) | With a handler querier whose ladder read fails: the page offers no "None", opens with R3, and has no line under the field; a PUT of a name change answers the form-level failure sentence and the stored name is unchanged; a PUT of an empty set answers it too with no error under the Entitlement set field; L3 is logged | `unread` ignored in `productSetLine`: the off-ladder line shown; the save going ahead when the read failed (the PUT's name is saved); the parse's field error left in place when the ladders are unread |
|
||||
| T6d `TestTheEntitlementSetFieldFollowsTheProductsTiers` (`internal/server`, no database) | The record-mode render through `renderProductEdit`, one row per state: on no ladder, with a set and with none ("None" offered first, the off-ladder sentence); on no ladder with a refused set (the off-ladder sentence beside the error); a tier with a set (no "None", R3 first and unselected, the set selected, R2); a tier with no set (no "None", R3 selected, R2); tiers unread, with a set (no "None", R3 first, no line) and with none (no "None", R3 selected, no line); and R2 for one, two and three ladders, compared as whole sentences | R3 left out of a list without "None" (the tier with no set renders no option selected, which the browser shows as the first real set); `joinWithAnd(ladders)` replaced by `ladders[0]` (the two-ladder sentence names Forum only); `productSetLine`'s `errs.Has` test moved before its `isTier` test (the refused-set row loses the off-ladder line) |
|
||||
| TestTheCreatePageCarriesTheOffLadderLine (`internal/server`, no database) | The create page's Entitlement set line is the off-ladder sentence, from productCreateForm's slot entry | The entry deleted (the line reads empty) |
|
||||
| T7 The four goldens (`internal/server/plan_acts_golden_db_test.go`) | Drafted replaces NoSet (D7); the per-organization failure is unchanged; the recorded driver text reads "is draft and not conferrable" | The draft removed from one case (its `f.w.Draft` call deleted): the move succeeds and the case's golden differs; the existing breaks of those cases stand |
|
||||
| T8 `TestResumption_ARefusedConferralIsRolledBackAlone` (`internal/entitlements/grant_resumption_test.go`) | Plus is drafted in place of having its set cleared at `:304`, which the trigger now refuses because Plus is a tier | The draft removed (the `UPDATE` deleted): both grants resume |
|
||||
| T9 `TestTierProductsKeepASetMigrationUpDownUp` (`internal/db`) | Down restores the schema Up started from, and a second Up rebuilds the first (`requireMigrationUpDownUp`, `internal/db/outgoing_default_rule_test.go:159-187`) | A Down without one of its `DROP TRIGGER` statements |
|
||||
|
||||
`TestUpdateProductEntitlementSetSentinel` (`internal/server/operator_products_handler_test.go:27-129`), whose product is on no ladder, passes unedited; it is the proof that F12 is unchanged. `TestRuntimeRolePrivileges` (`internal/db/runtime_role_privileges_test.go:235`) passes unedited: its function list names definer functions only (`:218-223`), and the two trigger functions run with the caller's rights. `TestFormsHTML` (`internal/server/forms_golden_test.go`) regenerates `internal/server/testdata/forms/operator.product.html` once, with `-update` and its diff read: the Entitlement set field's `Hint` becomes a `line` slot; it renders each declaration through bindings of its own, never through `productSetOptions` or `productSetLine`, so the diff shows that declaration change (in each binding the `-hint` div becomes an empty `-line` div, and `aria-describedby` names `-line` in place of `-hint`, after `-error` in the refused binding) and no other. `TestProductEditEntitlementSetSentinel` (`internal/server/operator_products_test.go:22`) passes with a wording edit, because its subtest "sentinel is present as an option regardless of selection state" and its error "the unset sentinel option must always be present on the edit side" are no longer true for a tier product: they say "for a product on no ladder".
|
||||
|
||||
Breaks no test sees, with the reason:
|
||||
- The two `GRANT EXECUTE` lines: `PUBLIC` keeps `EXECUTE` on a function, and a trigger function's `EXECUTE` is checked when the trigger is created, not when it fires, so removing a grant changes nothing a role can do. The grants follow `00026` so a later revoke from `PUBLIC` leaves the three core roles able to call them.
|
||||
- The tier trigger's `TABLE` and the product trigger's `TABLE` and `COLUMN`: no code reads them; the handlers map by constraint name.
|
||||
- The `FOUND` test's other side (a product no row names): the foreign key refuses the row either way, with its own error, and the console's tier add answers before the insert.
|
||||
|
||||
**The walkthrough order dependency.** #153 was found through the browser walkthroughs, which share the app database across runs. The plan-ladders walkthrough uses leftover addable products when the picker offers at least two (`test/e2e/operator-walkthroughs/plan_ladders_test.go:250-260`), and the demo seed's add-ons have no set (`demoseed.go:426-428`). That is how a ladder of tiers with no set appeared, which the org-types walkthrough then made the personal type's default (`test/e2e/operator-walkthroughs/org_types_test.go:97-113`), and the next run's grant revoke failed. After this change the picker hides those products and the insert refuses them, so the walkthrough either uses products with a set or seeds two through the create page (`seedTierProducts`, `:212-232`), which picks a real set. On a reset app database the dependency is gone. On an app database that already holds such ladders it persists until they are fixed or the database is reset, which is why the counts run first and the app database is reset after them (tasks, item 1.0).
|
||||
|
||||
## What is unverified
|
||||
|
||||
- **The trigger's timing.** That a `BEFORE UPDATE` row trigger runs after the row lock is granted, in a statement that sees the tier row the other transaction committed, is read from PostgreSQL's documented behaviour for READ COMMITTED and for volatile functions, not run. The premise is READ COMMITTED for both writers (D2); under REPEATABLE READ the tier-insert-first order would let both commit. T3's first order proves it. If it ever fails, `AFTER UPDATE` is the safe form.
|
||||
- **The fixture sweep's size.** Nine test files are known to make a product with no set a tier (proposal, Quick fixes), found by reading every test file that both creates a product and writes a tier, and every caller of `WithoutSet`. A fixture that builds its product in one file and tiers it in another, through a helper, could escape that reading. Tests were not run, so `make test` may name more, each by the constraint.
|
||||
- **`BEFORE INSERT` and `ON CONFLICT DO NOTHING`.** That a row-level `BEFORE INSERT` trigger fires for a row that `ON CONFLICT DO NOTHING` then skips (F20) is read from PostgreSQL's documentation of `INSERT ... ON CONFLICT`, not run. It affects only a seed run over a tier row from before the migration.
|
||||
- **Which product page `make screens` captures.** `operator-product-detail` captures the first product the products list links to (`test/e2e/screens/manifest.go:71-72`). If that product is a tier, its Entitlement set field's line changes; otherwise no screen changes.
|
||||
|
||||
## Migration plan
|
||||
|
||||
- `internal/db/migrations/00035_tier_products_keep_a_set.sql`: a header comment in the style of `00026`; the two functions and two triggers as D2 writes them; `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()`. 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;`, then both `DROP FUNCTION`s. Each section is wrapped in `StatementBegin` and `StatementEnd` (`docs/database-management.md`).
|
||||
- It writes no row and adds no relation or column: `internal/migrate/testdata/console-reader-columns.txt` does not change, and `scripts/db-reader-role.sql` needs no re-run.
|
||||
- No query changes, so `make sqlc-generate` produces no diff.
|
||||
|
||||
## Rollback
|
||||
|
||||
The previous binary runs against the migrated schema. It never writes a tier of a product with no set through a path the triggers would let through, and when it tries one of the two refused writes it meets the triggers and answers its generic failure. `migrate down` once removes the triggers and functions. No data needs restoring.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Two operator actions become refusals** on ladders that are nobody's default (D1). Each produced a tier that delivers nothing, and the field states why.
|
||||
- **A new row lock** on the tier add, held from the insert to the commit. A product edit of the same product waits for the tier add's commit (its alignments and materializations; about 0.15 s at the holder limit, `docs/plan-management.md` "Attaching Tiers"); a tier add waits at most for one product `UPDATE`, bounded by its 5 s `lock_timeout`.
|
||||
- **The field's line is outside the registry's hint checks.** R2 and the off-ladder sentence travel through a slot, so the 100-character cap and the other hint invariants do not read them (D3). R2's length grows with the ladders' names and count, and a long list wraps to a second visual line. Names are data, so the sentence is not shortened.
|
||||
- **Rows from before the migration** stay until they are fixed by hand (D5). Development databases that ran the walkthroughs before the fix keep failing the old way until they are reset.
|
||||
- **#98** may fold sets into products; the triggers would go with the column.
|
||||
@@ -0,0 +1,81 @@
|
||||
## Why
|
||||
|
||||
`core.confer` refuses a product whose `entitlement_set_id` is NULL: "confer: product % has no entitlement set and confers nothing" (`internal/db/migrations/00023_grant_resumption.sql:447-449`). Restoring an organization type's default confers the rank-0 product of the type's default ladder as a `default` grant (`ReapplyDefaultsForPool`, `internal/entitlements/reapply_defaults.go:39-75`). Nothing in the console keeps that product's set. Six operator paths can leave a default ladder starting on a product with no set, and none of them refuses:
|
||||
|
||||
- a default change: its first transaction reads the candidate's rank-0 product and refuses only a ladder with no rank 0 (`internal/entitlements/default_change.go:197-216`);
|
||||
- a reorder that moves a product with no set to rank 0 (`internal/entitlements/tier_reorder.go:152-221`);
|
||||
- a tier removal or a tier delete of rank 0 that promotes such a product (`internal/entitlements/ladder_acts.go:267-311`, `:662-710`);
|
||||
- a tier add to an emptied default ladder, which refuses only an unpublished product (`ladder_acts.go:512-514`); the picker offers products with no set (`availableTierProducts`, `internal/server/operator_plan_ladders.go`);
|
||||
- the product edit's "None" on the rank-0 product (`UpdateProduct`, `internal/server/operator_products.go:339-409`).
|
||||
|
||||
Once a default ladder starts on such a product, every restoration of that type's default fails:
|
||||
|
||||
- **A new person's first sign-in.** `AutoProvision` returns the confer error (`internal/provisioning/provisioning.go:181-183`), the whole provisioning chain rolls back, and the callback renders the 500 "Sign-in failed" page (`internal/auth/auth.go:642-651`). The degrade path covers only a missing rank 0 (`provisioning.go:184-191`). The issue does not mention this one.
|
||||
- **A revoke** (`internal/entitlements/grant_acts.go:309-314`), the failure #153 observed.
|
||||
- **An expiry** (`grant_acts.go:388-393`): it rolls back, the activity retries (`internal/workflows/entitlements/activities.go:68`), and the grant stays active past its bound.
|
||||
- **A subscription's end** (`reconcileEnded`, `internal/fulfillment/reconcile.go`; the floor restoration's error returns before the branch's `tx.Commit()`): the reconcile's one transaction rolls back, the subscription's own status included, so the subscription's provisions stay live and the member keeps a cancelled plan. Each replay of the event fails the same way.
|
||||
- **A tier removal with Migrate** (`ladder_acts.go:445-449`), which rolls back whole.
|
||||
- **Each organization's move** after a default change or a reorder (`internal/entitlements/plan_act_move.go:135-143` at initiate, `:210-212` at restore). The org type's card records "The default plan could not be conferred." (`internal/server/operator_plan_acts.go:88-89`), and Retry fails the same way until the product gets a set (#153's comment).
|
||||
- **The demo seed's floor** (`internal/demoseed/demoseed.go:603`).
|
||||
|
||||
A plan tier whose product has no set cannot be delivered by any source. `core.confer` refuses it for grants and defaults, the grant form already hides such products (`loadIssuanceProducts`, `internal/server/operator_enrollment.go`), and the subscription reconcile skips the product with a warning, "reconcile: product has no entitlement set, skipping provision" (`mirrorItems`, `internal/fulfillment/reconcile.go`). A member who buys such a tier pays for a plan that delivers nothing.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **No plan ladder tier may have a product with no entitlement set.** Migration `00035` adds two database triggers. The first refuses a tier row whose product has no set, and takes the product row `FOR SHARE` while it checks. The second refuses an update that clears the set of a product that is a tier of any ladder. Each raises SQLSTATE 23514 with a constraint name (`chk_plan_ladder_tiers_product_has_set`, `chk_products_tier_keeps_set`), so the handlers map the refusal to a sentence by name. Every default ladder's rank 0 is a tier, so no default ladder can start on a product with no set. The default change, the reorder, the tier removal and the tier delete need no check of their own.
|
||||
- **Two operator actions that are legal today are refused**, on ladders that no org type defaults to as well: making a product with no entitlement set a tier, and clearing the entitlement set of a product that is a tier. Both write a tier that no source can deliver.
|
||||
- **The tier add refuses such a product under the Product field** with "Only products with an entitlement set can be tiers." The tier picker no longer offers products with no set.
|
||||
- **The product edit form does not offer "None" for a product that is a tier.** The select opens instead on the library's disabled "Choose an entitlement set", as every select that needs a choice does, so the product's own set stays the selected option and a re-rendered refusal never shows a set nobody chose. The Entitlement set field's one line says why, through the field's declared slot, which the product name, the tier add and the grant form already fill per record: "This product is a tier of Forum and Hosting, and a tier needs an entitlement set." A page rendered before the product became a tier that still submits "None" gets the same sentence as the field's error, and nothing is written. A tier product whose set is already empty (a row from before this change) shows "Choose an entitlement set" selected, and a save is refused with the same sentence until a set is chosen. If the console cannot read the product's ladders, the form still leaves out "None", the field shows no line, and a save answers "Failed to update the product. Details are in the server logs."
|
||||
- **A new row lock.** The tier insert's trigger takes the product row `FOR SHARE`. A product edit that clears the set takes the same row for update, so a tier add and a set clear on one product cannot both commit. `docs/database-locks.md` gains a "Product row" entry, and its tier add path gains the lock.
|
||||
- **A set with no rules is still a warning, not a refusal**, as the org-types and plan-ladder-management specs require. Only a NULL set is refused.
|
||||
|
||||
## Quick fixes inside this change
|
||||
|
||||
Listed here so the change carries them openly (`CONTRIBUTING.md`, "How changes are made", item 1).
|
||||
|
||||
- **The forms library's `Slot` doc comment** (`internal/forms/forms.go:614-617`) says a slot may also hold a line the handler decides per render (the product's Entitlement set); a comment, no behaviour.
|
||||
- **The tier picker hides products with no set**, with the filter the grant form already applies (`loadIssuanceProducts`, `operator_enrollment.go`), in `tierCandidates`, which `availableTierProducts` calls.
|
||||
- **The fixture sweep, ten files.** Nine test files make a product with no set a tier, and the trigger refuses each one. Seven build the product without a set: `internal/db/plan_act_obligations_test.go`, `internal/db/acts_test.go`, `internal/entitlements/plan_act_queries_db_test.go`, `internal/server/member_switch_offer_db_test.go`, `internal/server/member_extras_test.go`, `internal/server/operator_product_name_check_test.go` and `test/e2e/merchant-disclosure/sheet_test.go`. `internal/server/plan_acts_golden_db_test.go` clears a product's set and then ladders it (`setless()`), and `internal/entitlements/grant_resumption_test.go` clears the set of a tier. Each gets a set, or moves its failure to a draft lifecycle (below). The tenth is the test kit's `internal/testkit/world/world.go`, whose `WithoutSet` doc comment says the edit form clears any product's set; it gains that a tier's set cannot be cleared. Once the trigger is in place, `make test` names any other case by the constraint.
|
||||
- **The failure the plan-act goldens inject.** Four goldens of `internal/server/testdata/plan_acts/` (`default-initiate-fails`, `default-migrate-restore-fails`, `remove-second-pool-fails`, `reorder-an-organization-fails`) and `TestResumption_ARefusedConferralIsRolledBackAlone` use a rank-0 product with no set as the conferral that fails. They move to a product that is made draft after it is laddered, which `core.confer` also refuses. The failure step is the same; the recorded driver text changes.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Each is a forge issue, a comment at landing, or a note for the maintainer.
|
||||
|
||||
- **A member can buy an add-on that has no entitlement set and receive nothing.** The member gate has no leg for the set (`internal/server/product_readiness.go:281-310`), and the reconcile skips the product with a warning (`mirrorItems`, `internal/fulfillment/reconcile.go`). This change closes it for plan tiers only; #272 covers add-ons.
|
||||
- **Clearing the set of a held product that is on no ladder makes its grant's extension fail.** Manage extends a delivering grant through `ExtendGrantTx`, which confers again (`docs/models/entitlements.md:51`; `extendHead`, `internal/entitlements/grant_acts.go:663-705`). `core.confer` refuses the product, and Manage answers "Failed to save the grant. Details are in the server logs." with the refusal only in the log (`ManageGrant`, `internal/server/operator_enrollment.go`). This change covers tier products only; #273 covers the rest.
|
||||
- **The lifecycle leg.** A draft or retired rank-0 product on a default ladder breaks restoration and sign-in the same way (`00023_grant_resumption.sql:459-492`). No console path writes the lifecycle today. #155 asks how retirement meets the default floor, and #97 is the missing retire act. Whichever change lands a retire act must refuse retiring a default ladder's rank 0, or settle #155 first. A comment on #155 says so at landing.
|
||||
- **A set id that points at no row** passes both triggers and confer's check, then fails at the provisions foreign key (#99). This change rests on #99 for that case. A comment on #99 says so at landing.
|
||||
- **The sign-in degrade** ("First-login provisioning survives a missing bottom tier", `openspec/specs/auto-provisioning/spec.md:174-186`) is not widened to a refused default conferral. After this change a missing set cannot cause one, and the draft and retired causes belong to #155 and #97.
|
||||
- **The rank-0 existence guard stays in the application** (`docs/models/identity-organization-workspace.md`, invariant 7; #214). A later change could move "a default ladder has a rank 0" into the database the same way. #61 is about gaps in the rank order, a different rule, so it gets no comment.
|
||||
- **Rows that already break the rule.** No migration stop, no boot check and no card line. Read-only counts run on the test stack's app database before the first `make test` writes to it or `make screens` resets it (design D5). The maintainer runs the same counts on the production deployment and fixes any hit by hand (design D5).
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
None.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `plan-ladder-management`: added "Every plan tier's product has an entitlement set" (the two triggers, the lock, and the rows they leave alone); modified "Operator tier management within a ladder" (the picker offers only products with a set, and the tier add's refusal).
|
||||
- `product-management`: modified "Operator product editing" ("None" is not offered for a tier product and the placeholder takes its place, the field's line, the stale page's refusal, and the failed read).
|
||||
|
||||
## Impact
|
||||
|
||||
- **Code:** `internal/server/operator_plan_ladders.go` (`refuseTierAdd`'s constraint map, `availableTierProducts`), `internal/server/operator_products.go` and `operator_product_forms.go` (the product's tiers, the option list, the field's line through a slot, the refusal), the test kit's `WithoutSet` doc comment, and the `Slot` doc comment in `internal/forms/forms.go`.
|
||||
- **Schema:** one core migration, `00035_tier_products_keep_a_set.sql`: two trigger functions and two triggers. No table, column, view or data changes. It lands after `displaced-subscription-waits` (`00033`, `00034`) and `reconcile-takes-pool-lock` (no migration).
|
||||
- **Stripe calls, routes:** none.
|
||||
- **Behaviour a deployment sees:** an operator can no longer make a product with no set a tier, or clear a tier product's set; the product edit form explains why its select has no "None". A default ladder can no longer start on a product with no set, so sign-in, revocation, expiry, a subscription's end, a removal with Migrate and the plan-act moves stop failing on that cause.
|
||||
- **Deploy:** nothing beyond the migration. It adds no relation or column, so `scripts/db-reader-role.sql` needs no re-run.
|
||||
- **Tests:** database tests of both triggers and their race, a migration up-down-up test, handler tests of the tier add, the picker and the product edit, the fixture sweep and the fault swap.
|
||||
- **Docs:** `docs/database-locks.md`, `docs/models/product-catalog.md` (a new invariant and a "Where this lives" row), `docs/models/identity-organization-workspace.md` (invariant 7), `docs/plan-management.md` ("Attaching Tiers").
|
||||
|
||||
## Related issues
|
||||
|
||||
- **#153** is this change; it closes with it. Its comment (the default change's per-organization failure after #187) is covered by the same rule.
|
||||
- **#155** and **#97**: the lifecycle leg (out of scope).
|
||||
- **#99**: a set id that points at no row (out of scope).
|
||||
- **#98** may fold sets into products; if it does, the triggers go with the column.
|
||||
- **#207**: a failed read is never shown as an answer. The product edit's failed ladder read follows it: the form does not treat the product as off-ladder. The "Plan ladders" section's own failed-read answer ("Not a tier on any ladder.") stays #207's; this change only logs that read (L3). At landing, a comment on #207 says the product page now logs the failed ladder read and the Entitlement set field no longer treats it as no tier.
|
||||
- **#214**: the rank-0 existence guard, which stays in the application.
|
||||
@@ -0,0 +1,430 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Operator tier management within a ladder
|
||||
|
||||
The system SHALL provide an operator UI for adding, removing, and reordering tiers
|
||||
within a ladder. The tier view SHALL display each tier's product name, display
|
||||
category, rank, and whether any active provisions are attached at that tier. Adding
|
||||
a tier SHALL permit selecting **any published product that has an entitlement set**
|
||||
(`core.products.lifecycle_status = 'published'` and `entitlement_set_id IS NOT
|
||||
NULL`); the system SHALL NOT reject a
|
||||
candidate product on the basis of `display_category` or any other type/kind label —
|
||||
the "non-plan products cannot be tiers" rejection dissolves, because no structural
|
||||
kind label remains for a tier-eligibility check to consult (Doc 41 §6.7, Decision
|
||||
138). A product becomes **position-bearing** for a ladder the instant it gains a
|
||||
tier row on that ladder; a product carrying live provisions before it was inducted
|
||||
onto the ladder is reconciled by the induction backfill described under "Tier
|
||||
membership changes reconcile existing live holders via align_conferral_shape"
|
||||
below. A product with `lifecycle_status = 'draft'` SHALL be rejected as a tier
|
||||
candidate, since draft products are never conferrable. A product whose
|
||||
`entitlement_set_id` is NULL SHALL NOT be offered as a tier candidate and SHALL be
|
||||
rejected as one, since a product with no entitlement set is conferrable by no
|
||||
source (see "Every plan tier's product has an entitlement set"); a tier add the
|
||||
database refuses on that ground SHALL answer 422, writing nothing, with "Only
|
||||
products with an entitlement set can be tiers." under the product field. A newly
|
||||
added tier SHALL be
|
||||
**appended at the end of the ladder** — the system self-assigns the next rank in
|
||||
the INSERT (no operator-supplied rank) — and the operator positions it by
|
||||
drag-and-drop. The product's publication SHALL be checked inside the tier add's
|
||||
own transaction, so the check and the insert are one transaction, and a failed
|
||||
read of the product SHALL answer the tier add's generic failure, not "Product
|
||||
not found.", which is for a product that does not exist. A tier add SHALL find,
|
||||
under its pool locks and before it aligns any provision, each holder's pool that
|
||||
already holds a live position on the ladder and each that holds more than one
|
||||
live provision of the product, and SHALL refuse, writing nothing, with a field
|
||||
error naming the organization and the product it holds on the ladder, or the
|
||||
organization holding the product more than once, all read inside the add's
|
||||
transaction. A collision the alignment raises anyway SHALL be answered with the
|
||||
tier add's generic failure, and its database text SHALL be logged, not shown.
|
||||
Adding a tier SHALL be refused, writing nothing, when more
|
||||
organizations hold a live provision of the product than the measured holder limit
|
||||
(`plan-acts`), with the refusal under the product field naming the count and the
|
||||
limit.
|
||||
|
||||
Reordering tiers SHALL be a two-step flow: dropping a dragged tier row SHALL write
|
||||
nothing — it SHALL render a server-side preview of the pending order inside the
|
||||
tier card (pending ranks visible, the rank-0 marker following the pending top
|
||||
tier), together with a consequence panel and Commit / Discard actions, so the
|
||||
pending order is server-rendered state that no swap can silently discard. When the
|
||||
pending order does not change which product holds rank 0, or the ladder is not any
|
||||
org type's default plan ladder, the panel SHALL state that the reorder affects
|
||||
display order only. When the pending order changes rank 0 of a ladder that is one
|
||||
or more org types' default, the panel SHALL classify each affected org type's
|
||||
population per the org-type default-change classification (cross-ref `org-types`, including its limit of 50 named organizations per class),
|
||||
with the candidate being this same ladder's pending rank-0 product, and the commit
|
||||
SHALL require exactly one disposition — `grandfather` or `migrate` — applied to
|
||||
the outgoing-default bucket across all affected types; a commit whose affected
|
||||
outgoing-default population is non-empty and whose disposition is absent SHALL be
|
||||
rejected with a validation error, writing nothing, including ranks. Committing
|
||||
SHALL, in one transaction that holds the materialization lock in exclusive mode,
|
||||
reconcile the submitted order against the ladder's current tiers (products no
|
||||
longer in the ladder dropped, absent tiers appended in current relative order),
|
||||
assign each tier a contiguous `rank` by its pending position (`0..N-1`, preserving
|
||||
the `(plan_ladder_id, rank)` UNIQUE constraint), record the act, and — when rank
|
||||
0 changed on a default ladder — record, inside that transaction, the org types
|
||||
that default to the ladder as the types the act reaches, and evaluate the
|
||||
disposition gate against the order it commits and those types (`plan-acts`). That
|
||||
transaction SHALL write no per-organization row. A reorder that cannot read which
|
||||
org types default to the ladder SHALL be refused, writing nothing, rather than
|
||||
treated as affecting no organization. A reorder preview, and the tier card's
|
||||
default-ladder badges, that cannot read which org types default to the ladder
|
||||
SHALL show "Failed to read the org types that default to this ladder. Reference:
|
||||
{request id}." in their place, the preview offering Discard alone, and SHALL NOT
|
||||
treat the failed read as no org type defaulting to the ladder. After the commit, a listing SHALL write one
|
||||
obligation per organization of the reached types that has an active default pool,
|
||||
in short batches (`plan-acts`). Each obligation SHALL then be settled with the
|
||||
same semantics, per-organization transactions, actor attribution, failure
|
||||
recording and Retry as the org-type default-change commit (cross-ref
|
||||
`org-types`), against the affected type's current default ladder's current
|
||||
rank-0 product, would-initiate pools always initiated onto it. The ladder's page
|
||||
SHALL list, per reorder, the organizations it did not move, beside a Retry
|
||||
(`plan-acts`). Discard SHALL restore the persisted order without writing.
|
||||
Ladder display reordering on the topology overview
|
||||
(`core.plan_ladders.sort_order`) is display-only and SHALL remain a single-step
|
||||
write.
|
||||
|
||||
Removing a tier SHALL renumber the remaining tiers to contiguous ranks (`0..N-1`)
|
||||
in the same transaction, so ranks never gap and a ladder with tiers always has a
|
||||
rank-0 tier. Removing a tier with **no** live holders SHALL use a confirmation
|
||||
modal, whose copy SHALL state that deleting the top tier promotes the next tier to
|
||||
the new-org provisioning default. A holder is live when its position is `active`
|
||||
or `suspended`, so a tier held only by suspended positions SHALL be removed
|
||||
through the preview and SHALL be refused by the plain delete. Removing a tier that has one or more live
|
||||
(`active` or `suspended`) `pool_provision_ladders` rows at that tier SHALL be
|
||||
**permitted, not blocked**, as a two-step flow: the Remove action SHALL render a
|
||||
server-side read-only preview inside the tier card, naming the affected
|
||||
organizations classified by the source of their position, together with Commit /
|
||||
Discard actions — nothing SHALL be written by the preview. Positions held by
|
||||
another source (subscription, operator or legacy grant, purchase) SHALL be
|
||||
reconciled on commit via `align_conferral_shape` (cross-ref
|
||||
`entitlement-conferral`), ending the junction row on the removed ladder while
|
||||
delivery of the product continues, and SHALL never be force-ended. Positions held
|
||||
through the org type default (`grant_reason = 'default'`) SHALL require exactly
|
||||
one disposition: `keep` — align-shrink identically, with the preview stating
|
||||
plainly that the organization keeps the product's entitlements AND will still
|
||||
receive the current type default at its next floor restoration (an off-ladder
|
||||
product cannot block the floor) — or `migrate` — the default-sourced position is
|
||||
ended and the floor-guarded restoration confers the current default immediately
|
||||
(resolving rank 0 after renumbering, so removing a default ladder's top tier
|
||||
migrates holders onto the promoted tier; a None default ends to baseline). A
|
||||
commit whose default-sourced population is non-empty and whose disposition is
|
||||
absent SHALL be rejected with a validation error, writing nothing. The commit
|
||||
SHALL be atomic: tier deletion, renumbering, and every holder reconciliation run
|
||||
in one transaction with affected pools locked in deterministic order, so a
|
||||
failure rolls back everything including the deletion and no provision is left
|
||||
mismatched with its product's shape. A removal whose holders number more than the
|
||||
measured holder limit SHALL be refused before anything changes, the tier card
|
||||
SHALL render that tier's Preview removal disabled with the refusal as its reason,
|
||||
and the removal preview SHALL show the refusal in place of its outcome lines and its disposition
|
||||
choice and SHALL offer Discard alone, with no commit (`plan-acts`). A removal, with or without holders, of the last tier of a ladder an
|
||||
org type defaults to SHALL be refused under either disposition (see "The last tier
|
||||
of a live default ladder cannot be deleted"), and the removal preview SHALL show
|
||||
that refusal the same way, with Discard alone. A removal preview that cannot
|
||||
read the org types its last-tier guard needs SHALL show "Failed to preview the
|
||||
removal. Reference: {request id}." in place of the preview, never an enabled
|
||||
commit. When the tier card cannot read the org types that default to its ladder,
|
||||
the rank-0 tier's Remove or Preview removal SHALL render disabled with that
|
||||
failure's sentence as its reason, and every other tier's control SHALL stay
|
||||
enabled. When the removed tier is
|
||||
rank 0 of a ladder that is one or more org types' default, the preview SHALL state
|
||||
which product is promoted to the new-org provisioning default and for which org
|
||||
types. Constraint violations on tier operations (for example re-adding a product
|
||||
already in the ladder) SHALL render as field-level validation errors — raw
|
||||
database driver text SHALL NOT reach the UI. A lock wait past the timeout or a
|
||||
deadlock in a tier add, a tier delete, a tier removal or a reorder SHALL be
|
||||
answered with "Another change collided with this one; try again."
|
||||
(cross-ref `entitlements`). A tier's `rank` is its display order within the ladder
|
||||
(rank 0 is the default product for new-org provisioning); active member
|
||||
provisions bind to a tier by `product_id`, so rank changes SHALL NOT move an
|
||||
existing member between plans.
|
||||
|
||||
#### Scenario: Operator adds a tier — it appends at the end
|
||||
|
||||
- **WHEN** an operator adds product `standard-tier` to a ladder that has tiers at
|
||||
ranks 0 and 1
|
||||
- **THEN** a new `core.plan_ladder_tiers` row SHALL be inserted with `rank = 2`
|
||||
(self-assigned), without the operator supplying a rank
|
||||
|
||||
#### Scenario: A rung held by another product is named in console terms
|
||||
|
||||
- **WHEN** an operator adds a product held by an organization that already holds another product on the ladder
|
||||
- **THEN** the field error SHALL read "Cannot add this tier; {Organization} already holds {Product} on this ladder."
|
||||
- **AND** no database text SHALL appear in it, and no tier row SHALL be inserted
|
||||
|
||||
#### Scenario: A product held twice by one organization is named
|
||||
|
||||
- **WHEN** an operator adds a product that one organization holds through two live provisions, neither on the ladder
|
||||
- **THEN** the field error SHALL read "Cannot add this tier; {Organization} holds {Product} more than once, and an organization holds one position per ladder."
|
||||
- **AND** no tier row SHALL be inserted
|
||||
|
||||
#### Scenario: A failed read of the org types is not shown as none
|
||||
|
||||
- **WHEN** an operator drops a tier on a ladder page while the org types cannot be read
|
||||
- **THEN** the preview SHALL show "Failed to read the org types that default to this ladder. Reference: " and the request's identifier, and Discard with no Commit action
|
||||
|
||||
#### Scenario: A tier held only by suspended positions is not deleted plainly
|
||||
|
||||
- **WHEN** an operator deletes a tier whose only holders are suspended positions
|
||||
- **THEN** the delete SHALL be refused and Remove SHALL lead to the removal preview
|
||||
|
||||
#### Scenario: Re-adding a product renders a field error, not driver text
|
||||
|
||||
- **WHEN** an operator adds a product that is already a tier in the ladder
|
||||
- **THEN** the form SHALL re-render with a field-level error stating the product is
|
||||
already a tier, and no raw constraint/SQLSTATE text SHALL appear
|
||||
|
||||
#### Scenario: Dropping a dragged tier row renders a preview, writing nothing
|
||||
|
||||
- **WHEN** an operator drags a tier row to a new position and drops it
|
||||
- **THEN** the tier card SHALL re-render showing the pending order and its
|
||||
consequence panel with Commit and Discard actions
|
||||
- **AND** no `core.plan_ladder_tiers` row SHALL be written by the drop
|
||||
|
||||
#### Scenario: Committing a display-order-only reorder
|
||||
|
||||
- **WHEN** an operator commits a pending order that keeps the same rank-0 product
|
||||
(or reorders a ladder that is not any org type's default)
|
||||
- **THEN** the system SHALL assign each tier a contiguous `rank` by its pending
|
||||
position (`0..N-1`), preserving the `(plan_ladder_id, rank)` UNIQUE constraint
|
||||
- **AND** existing member provisions SHALL remain attached to the same product
|
||||
- **AND** no dispositions SHALL be required or enacted, and no obligation SHALL be
|
||||
written
|
||||
|
||||
#### Scenario: Reorder that changes a default ladder's rank 0 requires a disposition
|
||||
|
||||
- **WHEN** an operator's pending order puts a different product at rank 0 of a
|
||||
ladder that is an org type's default, and organizations of that type hold the
|
||||
outgoing rank-0 product through default-sourced positions
|
||||
- **THEN** the preview SHALL classify the affected type's population with the
|
||||
outgoing-default bucket named, and the commit SHALL require `grandfather` or
|
||||
`migrate` for that bucket
|
||||
- **AND** a commit without the required disposition SHALL be rejected with a
|
||||
validation error, writing nothing — the persisted ranks SHALL be unchanged
|
||||
|
||||
#### Scenario: Committed rank-0 change enacts dispositions like a default change
|
||||
|
||||
- **WHEN** an operator commits a rank-0-changing reorder of a default ladder with
|
||||
the `grandfather` disposition
|
||||
- **THEN** the ranks, the act and the org types it reaches SHALL be committed
|
||||
together first, a listing SHALL then write one obligation per affected
|
||||
organization, and each affected org's outgoing-default position
|
||||
SHALL then be enacted with the same semantics as the org-type default-change
|
||||
commit (cross-ref `org-types`): legacy-grant reissue under `grandfather`,
|
||||
per-pool transactions, in-transaction bucket re-derivation against the current
|
||||
rank 0, per-org failure recording, and Retry
|
||||
- **AND** would-initiate pools of the affected types SHALL be initiated onto the
|
||||
new rank-0 product
|
||||
|
||||
#### Scenario: A reorder that cannot read the org types is refused
|
||||
|
||||
- **WHEN** an operator commits a rank-0-changing reorder and the org types cannot
|
||||
be read inside its transaction
|
||||
- **THEN** the commit SHALL be refused and the ranks SHALL be unchanged
|
||||
|
||||
#### Scenario: Discard restores the persisted order
|
||||
|
||||
- **WHEN** an operator discards a pending reorder preview
|
||||
- **THEN** the tier card SHALL re-render the persisted tier order and ranks,
|
||||
without writing
|
||||
|
||||
#### Scenario: Removing a tier renumbers the remainder
|
||||
|
||||
- **WHEN** an operator removes the rank-1 tier of a three-tier ladder (ranks 0,1,2)
|
||||
- **THEN** the remaining tiers SHALL hold ranks 0 and 1 after the removal (no gap)
|
||||
|
||||
#### Scenario: Removing the top tier promotes the next tier to default
|
||||
|
||||
- **WHEN** an operator removes the rank-0 tier of a ladder with more than one tier
|
||||
- **THEN** the confirmation (modal for a holder-less tier, preview panel
|
||||
otherwise) SHALL state the next tier becomes the new-org default, and after
|
||||
removal the former rank-1 tier SHALL hold rank 0
|
||||
|
||||
#### Scenario: Removing a tier with holders renders a preview, writing nothing
|
||||
|
||||
- **WHEN** an operator clicks Remove on a tier that has live holders
|
||||
- **THEN** the tier card SHALL re-render with a removal preview naming the
|
||||
affected organizations classified by position source, with Commit and Discard
|
||||
actions
|
||||
- **AND** no tier row, junction, provision, or grant SHALL be written by the
|
||||
preview
|
||||
|
||||
#### Scenario: Removal with default-sourced holders requires a disposition
|
||||
|
||||
- **WHEN** an operator commits the removal of a tier whose holders include
|
||||
default-sourced positions and supplies no disposition
|
||||
- **THEN** the commit SHALL be rejected with a validation error and nothing SHALL
|
||||
be written — the tier row and all junctions SHALL be unchanged
|
||||
|
||||
#### Scenario: Committed removal align-shrinks other-source holders
|
||||
|
||||
- **WHEN** an operator commits the removal of a tier held by a
|
||||
subscription-sourced position
|
||||
- **THEN** the tier row SHALL be deleted, remaining tiers renumbered, and the
|
||||
holder's junction row on this ladder ended via `align_conferral_shape`
|
||||
- **AND** the provision SHALL continue delivering the product (no grant,
|
||||
purchase, or subscription item is created or ended)
|
||||
|
||||
#### Scenario: Committed removal migrates default-sourced holders on request
|
||||
|
||||
- **WHEN** an operator commits the removal of a tier held by default-sourced
|
||||
positions with the `migrate` disposition
|
||||
- **THEN** each default-sourced position SHALL be ended and the floor-guarded
|
||||
restoration SHALL confer the org type's current default resolved after
|
||||
renumbering, in the same transaction as the tier deletion
|
||||
- **AND** a failure in any holder's reconciliation SHALL roll back the entire
|
||||
commit, including the tier deletion
|
||||
|
||||
#### Scenario: A removal above the holder limit is refused
|
||||
|
||||
- **WHEN** an operator previews and then commits the removal of a tier that more
|
||||
organizations hold than the holder limit
|
||||
- **THEN** the preview SHALL show the limit sentence in place of its outcome lines
|
||||
and its disposition choice and offer Discard alone, and the commit SHALL be
|
||||
refused with the tier, its ranks and every position unchanged
|
||||
|
||||
#### Scenario: The tier card does not offer a removal it cannot preview
|
||||
|
||||
- **WHEN** an operator opens a ladder page while the org types that default to
|
||||
the ladder cannot be read
|
||||
- **THEN** the rank-0 tier's Remove or Preview removal SHALL render disabled with
|
||||
"Failed to read the org types that default to this ladder. Reference: " and
|
||||
the request's identifier as its reason
|
||||
- **AND** every other tier's Remove or Preview removal SHALL stay enabled
|
||||
|
||||
#### Scenario: The tier card does not offer a removal above the holder limit
|
||||
|
||||
- **WHEN** an operator opens a ladder page with a tier that more organizations
|
||||
hold than the holder limit
|
||||
- **THEN** that tier's Preview removal SHALL render disabled with the limit
|
||||
sentence, and its second sentence when it applies, as its reason
|
||||
- **AND** when the card cannot read a tier's holder counts, that tier's Preview
|
||||
removal SHALL render disabled with "Failed to load the tier's holders.
|
||||
Reference: " and the request's identifier as its reason
|
||||
|
||||
#### Scenario: A tier add above the holder limit is refused
|
||||
|
||||
- **WHEN** an operator adds a product that more organizations hold than the holder
|
||||
limit
|
||||
- **THEN** the form SHALL re-render with the refusal under the product field and
|
||||
no tier row SHALL be inserted
|
||||
|
||||
#### Scenario: A tier change that waits too long is a readable refusal
|
||||
|
||||
- **WHEN** a tier add, a tier delete, a tier removal or a reorder waits past the
|
||||
lock timeout for the materialization lock
|
||||
- **THEN** the answer SHALL read "Another change collided with this one; try
|
||||
again." and nothing SHALL be written
|
||||
|
||||
#### Scenario: Any published product with an entitlement set may be added as a tier
|
||||
|
||||
- **WHEN** an operator adds a published product that has an entitlement set and
|
||||
carries `display_category = 'addon'` (or any other display category, or none)
|
||||
to a ladder
|
||||
- **THEN** the system SHALL insert the `core.plan_ladder_tiers` row without
|
||||
rejecting on the product's `display_category` or any other type label
|
||||
- **AND** the product SHALL become position-bearing for that ladder from the
|
||||
moment the tier row exists
|
||||
|
||||
#### Scenario: Draft product rejected as tier
|
||||
|
||||
- **WHEN** an operator attempts to add a product with `lifecycle_status = 'draft'`
|
||||
as a ladder tier
|
||||
- **THEN** the UI SHALL reject the operation with an explanatory error and SHALL
|
||||
NOT issue the INSERT
|
||||
|
||||
#### Scenario: A product with no entitlement set is not offered as a tier
|
||||
|
||||
- **WHEN** an operator opens the Add tier panel and a published, active product
|
||||
has no entitlement set
|
||||
- **THEN** the product select SHALL NOT offer that product
|
||||
- **AND** a submission that names it anyway SHALL be refused as a choice the
|
||||
select did not offer, writing nothing
|
||||
- **AND** when no product can be added, the panel SHALL read "No plan products
|
||||
are available to add. A tier needs a published product with an entitlement set
|
||||
that is not on this ladder yet."
|
||||
|
||||
#### Scenario: A product whose set was cleared after the panel opened is refused
|
||||
|
||||
- **WHEN** an operator submits a tier add of a product whose entitlement set was
|
||||
cleared after the panel listed it
|
||||
- **THEN** the answer SHALL be 422 with "Only products with an entitlement set can
|
||||
be tiers." under the product field
|
||||
- **AND** no tier row and no act record SHALL be written
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Every plan tier's product has an entitlement set
|
||||
|
||||
A `core.plan_ladder_tiers` row SHALL NOT name a product whose `entitlement_set_id`
|
||||
is NULL, and the database SHALL enforce it, so that every writer is bound: the
|
||||
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.
|
||||
|
||||
- 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
|
||||
product exists and has no entitlement set. A product id that names no row SHALL
|
||||
be left to the foreign key.
|
||||
- An update that changes a product's `entitlement_set_id` from a value to NULL
|
||||
SHALL be refused with SQLSTATE 23514 and the constraint name
|
||||
`chk_products_tier_keeps_set` while any tier row names the product. Replacing
|
||||
the set with another, clearing the set of a product that is no tier, and any
|
||||
update that leaves a NULL set NULL or gives it a value SHALL be allowed.
|
||||
- The tier check SHALL take the product row `FOR SHARE`, so that a tier insert
|
||||
and a clear of the same product's set cannot both commit: whichever writes
|
||||
second SHALL see the first one's commit and be refused. Both writers run at
|
||||
READ COMMITTED, the server's default isolation; the guarantee holds at that
|
||||
isolation only.
|
||||
- The rule is about the set being absent. A set with no active rule, or an
|
||||
inactive set, SHALL NOT be refused here; a set with no active rule stays the
|
||||
warning the ladder previews and the org-type default-change preview state.
|
||||
- Rows written before the rule existed SHALL NOT be changed or refused by it:
|
||||
the rule refuses new tier rows and changes, never a state already stored.
|
||||
- The default change, the tier reorder, the tier removal and the tier delete
|
||||
SHALL NOT carry a check of their own for a rank-0 product with no set; the
|
||||
rule makes that state unreachable through them.
|
||||
|
||||
#### Scenario: A product with no set cannot become a tier
|
||||
|
||||
- **WHEN** any writer inserts a tier row for a published product whose
|
||||
`entitlement_set_id` is NULL
|
||||
- **THEN** the insert SHALL fail with SQLSTATE 23514 and the constraint
|
||||
`chk_plan_ladder_tiers_product_has_set`, and no tier row SHALL exist
|
||||
|
||||
#### Scenario: A tier product keeps its set
|
||||
|
||||
- **WHEN** any writer sets `entitlement_set_id` to NULL on a product that is a
|
||||
tier of a ladder
|
||||
- **THEN** the update SHALL fail with SQLSTATE 23514 and the constraint
|
||||
`chk_products_tier_keeps_set`, and the product SHALL keep its set
|
||||
|
||||
#### Scenario: Replacing a tier product's set is allowed
|
||||
|
||||
- **WHEN** a writer changes a tier product's entitlement set from one set to
|
||||
another
|
||||
- **THEN** the update SHALL succeed
|
||||
|
||||
#### Scenario: A product on no ladder may lose its set
|
||||
|
||||
- **WHEN** a writer sets `entitlement_set_id` to NULL on a product that is no
|
||||
ladder's tier
|
||||
- **THEN** the update SHALL succeed
|
||||
|
||||
#### Scenario: A tier add and a set clear on one product do not both commit
|
||||
|
||||
- **WHEN** one transaction inserts a tier row for a product and another, before
|
||||
the first commits, sets the same product's entitlement set to NULL
|
||||
- **THEN** the second SHALL wait for the first and then be refused with
|
||||
`chk_products_tier_keeps_set`
|
||||
- **WHEN** the set clear runs first and the tier insert waits for it
|
||||
- **THEN** the tier insert SHALL be refused with
|
||||
`chk_plan_ladder_tiers_product_has_set` once the clear commits
|
||||
|
||||
#### Scenario: A set with no rule is not refused
|
||||
|
||||
- **WHEN** a writer inserts a tier row for a product whose entitlement set has no
|
||||
active rule
|
||||
- **THEN** the insert SHALL succeed, and the previews SHALL still state that the
|
||||
product provides nothing when it would take rank 0
|
||||
@@ -0,0 +1,68 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Operator product editing
|
||||
|
||||
The system SHALL provide a form for editing existing products on the product's addressable composite detail page at `/operator/products/{productID}`, rendered from the same declaration the create page renders (`form-library`), bound to the record. The form SHALL allow modifying name, description, features, `display_category`, is_active, is_public, and entitlement set; description, features and is_active are the declaration's edit-only fields and SHALL each render their reason at the field. `display_category` is a free-form grouping label: the system SHALL NOT validate its value against any fixed domain, and clearing it to blank SHALL be accepted. For a product that is no plan ladder's tier, the entitlement-set select on the edit side SHALL open with "None" as its first option, and the field's one line under its control SHALL state the consequence of leaving a product unset ("The set is what the product delivers; a product with none delivers no capabilities."). For a product that is a tier of one or more plan ladders, the select SHALL NOT offer "None", whatever the product's stored set, because the database refuses to clear a tier product's set (`plan-ladder-management`, "Every plan tier's product has an entitlement set"), and the field's one line under its control SHALL read "This product is a tier of {ladders}, and a tier needs an entitlement set." in place of that sentence, {ladders} being the display names of those ladders in name order, joined as "A", "A and B" or "A, B and C". A select that does not offer "None" SHALL open with the disabled "Choose an entitlement set" as its first option, as a select whose value must be chosen does (`form-conventions`, "A select that needs a choice opens with a placeholder option"), so that a re-rendered refusal never shows a set nobody chose. A submission of "None" for a product that is a tier when it arrives (a page rendered before the product became one) SHALL be refused at 422, writing nothing, with the same sentence as the field's error, no second line under the control, and the placeholder as the selected option. When the ladders the product is a tier of cannot be read, the select SHALL NOT offer "None", the field SHALL carry no line under its control, the failed read SHALL NOT be treated as the product being no tier, and a submission SHALL be refused at 422, writing nothing, with "Failed to update the product. Details are in the server logs." in the form-level slot as the only refusal, the Entitlement set field carrying no error of its own; a refusal by the database whose ladders cannot be named SHALL answer the same. When the product's stored `entitlement_set_id` is NULL, the select SHALL render "None" as selected where it offers "None", and the placeholder as selected where it does not; it SHALL NEVER display a real entitlement set the product does not have, and saving an untouched form SHALL NOT change the stored entitlement set. The create side's first option, "New set named after this product", SHALL NOT appear on the edit form. Changes to entitlement set SHALL NOT affect existing pool provisions (provisions resolve entitlement_set_id at creation time). Submitting the edit form SHALL re-render the composite detail page in place (the operator remains on `/operator/products/{productID}`) rather than redirecting to the product list, and SHALL report success through the toast, not through a banner in the body. The edit form SHALL carry the same duplicate-name warning as the create form, excluding the product being edited, and the page SHALL render the warning at load when another product already carries this product's name. The detail page SHALL render the product's `key` read-only when one is set, labelled "Key", in the muted code style, and SHALL render no key label, value, or empty-value marker when the key is NULL; the edit form SHALL NOT present a key input, and submitting it SHALL leave the key unchanged.
|
||||
|
||||
#### Scenario: Operator edits a product
|
||||
|
||||
- **WHEN** an operator modifies a product's fields and submits the edit form on `/operator/products/{productID}`
|
||||
- **THEN** the system SHALL update the product record
|
||||
- **AND** the system SHALL re-render the product's composite detail page reflecting the changes, leaving the operator on `/operator/products/{productID}`
|
||||
|
||||
#### Scenario: Unset entitlement set renders as None, and saving does not confer one
|
||||
|
||||
- **WHEN** an operator opens the edit form for a product that is no ladder's tier and whose `entitlement_set_id` is NULL
|
||||
- **THEN** the entitlement-set select renders "None" as the selected option, with its one line stating what an unset product delivers
|
||||
- **AND** submitting the form without touching the select leaves `entitlement_set_id` NULL
|
||||
|
||||
#### Scenario: Deactivating a product
|
||||
|
||||
- **WHEN** an operator sets a product's is_active to false
|
||||
- **THEN** the product SHALL NOT appear as an option when creating new grants
|
||||
- **AND** existing grants referencing the product SHALL continue to function
|
||||
|
||||
#### Scenario: Operator changes display_category freely
|
||||
|
||||
- **WHEN** an operator edits a product's `display_category` to an arbitrary string, or clears it to blank, and submits the edit form
|
||||
- **THEN** the system SHALL update the product record with the new value
|
||||
- **AND** the system SHALL NOT validate the new value against any fixed domain
|
||||
|
||||
#### Scenario: Existing duplicate is disclosed on the edit page
|
||||
|
||||
- **WHEN** an operator opens the edit page of a product whose name another product also carries, ignoring case
|
||||
- **THEN** the warning naming the other product renders under the name field before the operator types anything
|
||||
|
||||
#### Scenario: A product's key renders read-only where it exists
|
||||
|
||||
- **WHEN** an operator opens the detail page of a product a seed created with the key `demo_plan`
|
||||
- **THEN** the page SHALL render `demo_plan` labelled "Key" as a muted read-only value, with no input and no edit control
|
||||
- **AND** submitting the edit form SHALL leave `demo_plan` unchanged
|
||||
- **WHEN** an operator opens the detail page of a product whose key is NULL
|
||||
- **THEN** the page SHALL render no key label, no value, and no empty-value marker in a key position
|
||||
|
||||
#### Scenario: A tier product's form offers no None and says why
|
||||
|
||||
- **WHEN** an operator opens the edit form of a product that is a tier of the ladders Hosting and Forum and has an entitlement set
|
||||
- **THEN** the entitlement-set select SHALL NOT offer "None", its first option SHALL be the disabled "Choose an entitlement set", and the product's set SHALL be the selected option
|
||||
- **AND** the one line under the select SHALL read "This product is a tier of Forum and Hosting, and a tier needs an entitlement set."
|
||||
|
||||
#### Scenario: A stale page's None is refused for a tier product
|
||||
|
||||
- **WHEN** an operator submits "None" from an edit form rendered before the product became a tier of the ladder Hosting
|
||||
- **THEN** the response SHALL be 422 with "This product is a tier of Hosting, and a tier needs an entitlement set." as the field's error and no other line under the select
|
||||
- **AND** the select SHALL show "Choose an entitlement set" as the selected option, not a real set
|
||||
- **AND** the product SHALL keep its entitlement set
|
||||
|
||||
#### Scenario: An unreadable ladder list neither offers None nor saves
|
||||
|
||||
- **WHEN** the ladders a product with an entitlement set is a tier of cannot be read
|
||||
- **THEN** the edit form's select SHALL NOT offer "None" and the field SHALL carry no line under its control
|
||||
- **AND** submitting the form, including with the select empty, SHALL answer 422 with "Failed to update the product. Details are in the server logs." as the only refusal, with no error under the select, and SHALL write nothing
|
||||
|
||||
#### Scenario: A tier product with no set asks for one
|
||||
|
||||
- **WHEN** an operator opens the edit form of a product that is a tier and whose stored `entitlement_set_id` is NULL, a row written before the rule existed
|
||||
- **THEN** the select SHALL NOT offer "None" and SHALL render "Choose an entitlement set" as selected, with the tier sentence as the field's line
|
||||
- **AND** saving the form without choosing a set SHALL be refused at 422 with the tier sentence as the field's error, leaving the set NULL
|
||||
- **AND** choosing a set and saving SHALL save it
|
||||
@@ -0,0 +1,177 @@
|
||||
Tests follow `docs/testing.md`: database tests open the database through `dbtest`, build their rows through the test kit (`internal/testkit/world`) or the package's fixtures, filter to the rows their own fixture made, and pass twice in a row without a reset; tests meet the same complexity limit as production code, so a table runs its rows through a named function. A test of a lock holds one transaction open and waits for the block with `dbtest.AwaitLockWait`, never a sleep. A test of an order feeds its input in more than one order. A test of a migration applies it to a `dbtest.Scratch` database. A page's or a refusal's test compares the whole sentence with its entry in the design's Copy table. A test that asserts a log line finds one line that holds the message and each named attribute (`wantInLine`, `internal/server/operator_plan_ladder_tier_add_test.go:193`).
|
||||
|
||||
Sections 1 to 4 are one commit each, and their items, item 1.0 aside, are that commit's parts; section 5 is not a commit, and item 5.4 runs on section 4's tree before that commit. The sections are ordered so that each passes alone. Section 1 moves every fixture off the state the trigger forbids and gives the tier picker its filter, on today's tree, so it passes before and after section 2; the filter belongs there because a walkthrough run between sections 2 and 3 would otherwise add a product with no set and meet the trigger (design D6). Section 2 adds the migration and its tests. Section 3 gives the tier add its refusal. Section 4 changes the product edit. Every commit passes `make test` with the test stack up, which runs `make lint` first (`filler-copy`, `em-dash-copy`, `screen-coverage`, the notebook-citations check and `deadcode` among its checks) and then every package one at a time (`-p 1`). A migration is applied by `test/reset-test-db.sh` before the section's tests run (`make test` runs it). No query changes, so `make sqlc-generate` produces no diff; a diff is a stop. No golden, route table or test result changes except where a section's items name it; any other change is a stop. A golden is rewritten with `-update` only where an item says so, and its diff is read before the commit. Section 2 adds a test that runs a statement on a goroutine, so it runs that test with `go test -race -p 1 -count=1` after `test/reset-test-db.sh`. Sections 3 and 4 change what an operator page renders, so each runs `make screens` after its tests pass, and `make screens` resets the app database to the demo state. Between sections 2 and 4 the two refused writes answer the database's class message ("This combination of values is not allowed."); no test pins that interval. The Copy table is approved by the maintainer before section 3 starts; a string that differs from its approved entry is a stop. This change starts from the tree that `displaced-subscription-waits` (migrations `00033` and `00034`) and `reconcile-takes-pool-lock` leave; if it lands before them, its migration takes the next free number and the other change renumbers (design D10). A script's usage is learned from its header or source, never by running it.
|
||||
|
||||
**Breaks.** Each section's list is written to a file outside the repository and run with `go run ./scripts/mutation-check -list <file> -side new` on the section's commit, from the repository root, after `test/reset-test-db.sh` and with `test/.env` loaded as `make test` loads it (without it the database tests skip and every record reports SKIPPED), after its green control run; the tests are new or rewritten in the section, so there is no old side. A break of Go code or of a generated sqlc file is a test record (`file`, `old`, `new`, `pkg`, `run_new`, `want_new`); a break of a sqlc query edits the generated `.sql.go` file beside the querier, whose constant the test runs, not the `.sql` file. A break of the migration's SQL is a command record, because the shared test database runs the triggers as they were migrated: `cmd` is `(cd test && ./reset-test-db.sh) && go test -p 1 -count=1 -run '<pattern>' ./internal/db`, `exit` 1, and the list carries `"prepare": "cd test && ./reset-test-db.sh"`, so each record starts from the restored migration and the last `prepare` restores it. Every record's `want_new` is a text the failing test prints only when it fails, never a test or subtest name; for a golden it is `<path> differs from what the code produced`, which `internal/testkit/golden/golden.go:42` prints only on a mismatch, with the path as the test passes it (`testdata/plan_acts/default-initiate-fails.json`). An `old` text that occurs more than once in its file is extended with the lines around it until it occurs once. Every record must be CAUGHT. An `old` text below is the code as the section's items write it; when the committed code words it differently, the record is corrected to the committed text before the run, never dropped. A break that no test of its own section sees is named with the reason, and the commit message says so. The commit message lists every break with its result.
|
||||
|
||||
## 1. Fixtures stop tiering a product with no set, the failure goldens fail on a draft, and the tier picker leaves out a product with no set
|
||||
|
||||
- [x] 1.0 The read-only counts (design D5), run first, on the test stack's application database as it stands, before section 1's first `make test` and before any reset of it: `make test` drives the operator walkthroughs against that database, and `make screens` runs `test/reset-app-db.sh` first, after which a count reads the demo state and can only read zero. Use `psql` on `MC_DB_DSN` from `test/.env` (the runtime user; the three statements only read). The result goes to the maintainer (item 5.6); no hit is fixed here. When the first count finds a row, the operator walkthroughs fail on it in `make test` for #153's own reason. After the counts are recorded, `test/reset-app-db.sh` resets the app database before section 1's run.
|
||||
|
||||
```sql
|
||||
-- Default ladders whose rank-0 product has no entitlement set.
|
||||
SELECT ot.org_type, l.name AS ladder, p.name AS product
|
||||
FROM core.org_types ot
|
||||
JOIN core.plan_ladder_tiers t ON t.plan_ladder_id = ot.default_plan_ladder_id AND t.rank = 0
|
||||
JOIN core.plan_ladders l ON l.plan_ladder_id = t.plan_ladder_id
|
||||
JOIN core.products p ON p.product_id = t.product_id
|
||||
WHERE p.entitlement_set_id IS NULL
|
||||
ORDER BY ot.org_type;
|
||||
|
||||
-- Tiers of a product with no entitlement set, which the tier trigger now refuses as new rows.
|
||||
SELECT l.name AS ladder, t.rank, p.name AS product
|
||||
FROM core.plan_ladder_tiers t
|
||||
JOIN core.plan_ladders l ON l.plan_ladder_id = t.plan_ladder_id
|
||||
JOIN core.products p ON p.product_id = t.product_id
|
||||
WHERE p.entitlement_set_id IS NULL
|
||||
ORDER BY l.name, t.rank;
|
||||
|
||||
-- Moves a plan act has not finished because the default confers nothing.
|
||||
SELECT count(*)
|
||||
FROM core.plan_act_obligations
|
||||
WHERE status IN ('pending', 'failed')
|
||||
AND last_error_step IN ('initiate', 'restore')
|
||||
AND last_error LIKE '%has no entitlement set and confers nothing%';
|
||||
```
|
||||
- [x] 1.1 The seven fixtures that tier a product built without a set give it one (proposal, Quick fixes). Each keeps what its test asserts; only the product's set changes:
|
||||
- `internal/db/acts_test.go`: `productWithSet(q *dbtest.Q, prefix string) string` beside `newActFixture` inserts a set into `core.entitlement_sets (name)` named `dbtest.Name(prefix + "set ")` and a product into `core.products (name, entitlement_set_id)` named `dbtest.Name(prefix)` that names it, and returns the product's id. `TestRecordPlanActChecksAReordersRankZero` (`:155-156`) makes its two tier products with it.
|
||||
- `internal/db/plan_act_obligations_test.go`: `obligationFixture.reorder` (`:109-110`) makes its two tier products with `productWithSet`. The grant product of `TestGrantActIDIsAForeignKeyToTheAct` (`:445`) is no tier and keeps no set.
|
||||
- `internal/entitlements/plan_act_queries_db_test.go:484`: the product is inserted naming a set the test inserts first (`INSERT INTO core.entitlement_sets (name) VALUES ($1) RETURNING set_id`, `dbtest.Name("pa set ")`).
|
||||
- `internal/server/member_switch_offer_db_test.go:86-107`: `offerMember.product` creates an active entitlement set named after the product (`entitlements.New(m.db).CreateEntitlementSet`) and passes it as `EntitlementSetID`; the case "a one-time default price" (`:240-246`) tiers its product, and no case of the file depends on a product having no set.
|
||||
- `internal/server/member_extras_test.go:92-101`: `tierWithAddonLabel`, the one tier of `TestBuildAddonsDataExtrasBucket`, is created with an entitlement set of its own made in the test's transaction (`entitlements.New(tx).CreateEntitlementSet`), through a `mkTierProduct` beside `mkProduct`; the three extras keep no set, the state the Extras bucket is tested in.
|
||||
- `internal/server/operator_product_name_check_test.go:50-72`: `other`, the draft product the test tiers, is created with an entitlement set of its own made in the test's transaction.
|
||||
- `test/e2e/merchant-disclosure/sheet_test.go:392-399`: each tier product of the sheet's extra ladders gets an entitlement set of its own with no rule, named after the product's key, through a `s.tierProduct(p)` beside `s.product` (`:405-413`), so the member pages the sheet captures show what they showed before (a set with no rule delivers no limit, as no set did); the add-ons keep no set. The sheet is a capture that `make test` does not run (it needs `MERCHANT_SHEET=1` and the app running with Stripe configured, its package comment says), so this edit is checked by reading it, and the commit message says so.
|
||||
- [x] 1.2 `internal/testkit/world/world.go:240-246`: `WithoutSet`'s doc comment says that the operator's edit form clears the set of a product that is no plan tier, and that a tier's set cannot be cleared once migration `00035` is in place (`chk_products_tier_keeps_set`), so the kit clears it only on a product no ladder holds.
|
||||
- [x] 1.3 `internal/server/plan_acts_golden_db_test.go` (design D7): `setless()` (`:227-235`) is deleted. In `remove-second-pool-fails` (`:785-800`), `default-initiate-fails` and `default-migrate-restore-fails` (`:982-997`), and `reorder-an-organization-fails` (`:1181-1188`), the product is `drafted := f.product("Drafted", 1)` (`:214-225`), and `f.w.Draft(drafted)` (`internal/testkit/world/plans.go:209-215`) runs on the line after the `f.ladder` call that holds it. The case comment of `remove-second-pool-fails` says that U defaults to a ladder whose rank-0 product is draft, which `core.confer` refuses. The four goldens of `internal/server/testdata/plan_acts/` are written with `-update`, and their diffs are read: every `NoSet` reads `Drafted` (the placeholder, the toast, the act and tier rows), and each logged driver text reads "confer: product <product:Drafted> is draft and not conferrable" in place of "has no entitlement set and confers nothing". A changed statement line, step or obligation row is a stop. The commit message names the four files and why.
|
||||
- [x] 1.4 `internal/entitlements/grant_resumption_test.go:304-306`: `TestResumption_ARefusedConferralIsRolledBackAlone` drafts Plus (`UPDATE core.products SET lifecycle_status = 'draft' WHERE product_id = $1`, failure "draft Plus: %v") in place of clearing its set, which the trigger refuses from section 2 on because Plus is a tier of Wiki. Its doc comment says that the conferral is refused because the product is draft. Its assertions are unchanged.
|
||||
- [x] 1.5 `internal/server/operator_plan_ladders.go` (design D6): `tierCandidates(products []billing.Product, onLadder map[string]bool) []ProductOption`, reading no row, keeps a product that is published, has an entitlement set and is not on the ladder, in the order given, with the grant form's test (`!p.EntitlementSetID.Valid`, `loadIssuanceProducts` in `internal/server/operator_enrollment.go`). The filter states the rule the triggers enforce a second time, on purpose, as the grant form does, because it is a one-column test on a row already read (design D2); a drift shows as R1 at submit, never as a write. `availableTierProducts` builds `onLadder` from its tier read and returns `tierCandidates(products, onLadder)`; its doc comment says "any active, published product with an entitlement set not already a tier of this ladder" and why: no source can deliver a tier with no set. The Add tier panel's empty state (`operator_plan_ladder_tiers.html`) keeps its first sentence and takes the design's Copy R4 as its second, because the old second sentence is false once the picker leaves out products with no set; `TestPlanLadderTiersFullLadderAlert` compares the paragraph's whole text with R4.
|
||||
- [x] 1.6 Test. T5 `TestTierCandidatesOmitAProductWithNoSet` (a new `internal/server/operator_tier_candidates_test.go`, package `server`, no database), rows through a named function, each a product list and the ladder's products: `a published product with a set` (kept); `a published product with no set` (left out); `a draft product with a set` (left out); `a product already on the ladder` (left out); `a mixed list keeps the order given (A, C, B)` and `a mixed list keeps the order given (C, B, A)` (three kept products, fed in two permutations, neither sorted by name or id). A failure reads `tierCandidates(%s) = %v, want %v`. `TestCreatePlanLadderTier_EveryOtherFailureShowsTheReference` (`internal/server/operator_plan_ladder_tier_add_test.go:149-188`) passes unedited: its tier insert is refused by a querier, not by the database, and still answers the failure sentence. `TestTierAddRefusesAProductTheListLeftOut` (`internal/server/operator_plan_ladder_tier_add_test.go`, design F4): a post naming a product with no set answers 422 with "Choose a product from the list." under Product and writes no tier.
|
||||
- [x] 1.7 `docs/plan-management.md`, "Attaching Tiers": step 2 reads "Select any **published product** that has an entitlement set", and says that the list leaves out a product with no entitlement set, because no source can deliver a tier that has none.
|
||||
- [x] 1.8 Breaks (test records; each edits the fixture's fault, to show that the draft is what fails the move):
|
||||
- `default-initiate-fails` without its draft (its `f.w.Draft(drafted)` line deleted, with the case's surrounding lines as context): caught by `TestPlanActsGolden/default-initiate-fails`, `want_new` `testdata/plan_acts/default-initiate-fails.json differs from what the code produced`; and one record each for `default-migrate-restore-fails`, `remove-second-pool-fails` and `reorder-an-organization-fails`, with their own paths;
|
||||
- the draft removed from `TestResumption_ARefusedConferralIsRolledBackAlone` (the `UPDATE` and its error check deleted, so both grants resume): caught by it, `want_new` `resumed = `;
|
||||
- the filter without the set (`if p.LifecycleStatus != "published" || !p.EntitlementSetID.Valid {` replaced by `if p.LifecycleStatus != "published" {` in `tierCandidates`): caught by T5, `want_new` `tierCandidates(a published product with no set) =`;
|
||||
- the order not kept (`for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { out[i], out[j] = out[j], out[i] }` inserted before `return out` in `tierCandidates`; A, C, B reads B, C, A, and C, B, A reads A, B, C): caught by T5, `want_new` `tierCandidates(a mixed list`;
|
||||
- the empty state's old second sentence restored in the template: caught by `TestPlanLadderTiersFullLadderAlert`, `want_new` `the empty Add tier panel reads`;
|
||||
- the filter without the set, against `TestTierAddRefusesAProductTheListLeftOut`: `want_new` `the tier add of a product the list left out answered 200`;
|
||||
- the filter without the lifecycle test and the filter without the on-ladder test: caught by T5, `want_new` `tierCandidates(a draft product with a set) =` and `tierCandidates(a product already on the ladder) =`;
|
||||
- `availableTierProducts` not calling `tierCandidates` is not a break of its own: `deadcode` refuses a `tierCandidates` nothing calls;
|
||||
- the seven fixture edits of item 1.1 and the doc comment of item 1.2 carry no break: they remove a state section 2 forbids, and section 2's run is their test.
|
||||
- [x] 1.9 `make screens` (it resets the app database; item 1.0 has run): `operator-plan-ladder-detail`'s `-open` and `-invalid` captures change, at both widths, and no other screen changes because of this section. The demo seed's three products that are tiers of no ladder (`demo-addon`, `demo-usage`, `demo-onetime`, `internal/demoseed/demoseed.go:426-428`) have no entitlement set, so the picker now leaves them out and the demo ladder has no product to add: the open panel shows R4's empty state, and the invalid capture no longer holds an Add tier form to submit. The captures lose the tier add form's filled and invalid states until the demo has an off-ladder product with a set; the demo add-ons with no set are #272's fault in the demo's own data, and its fix gives them sets. The other changed screens are the known data-only differences and the host's time zone (#274).
|
||||
|
||||
## 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)`:
|
||||
- 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"):
|
||||
- `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):
|
||||
- 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`;
|
||||
- the tier check's constraint name changed (`CONSTRAINT = 'chk_plan_ladder_tiers_product_has_set'` replaced by `CONSTRAINT = 'chk_tier'`): caught by T1, `want_new` `constraint = "chk_tier", want "chk_plan_ladder_tiers_product_has_set"` (`dbtest.WantPgError`, `internal/testkit/dbtest/query.go:229-244`);
|
||||
- the lock removed (`WHERE p.product_id = NEW.product_id` followed by `FOR SHARE;` replaced by `WHERE p.product_id = NEW.product_id;`), one edit and two records: caught by T3, `want_new` `the set clear returned without waiting for the tier insert` and `the tier insert returned without waiting for the set clear`;
|
||||
- the `WHEN` refusing a replacement (`WHEN (OLD.entitlement_set_id IS NOT NULL AND NEW.entitlement_set_id IS NULL)` replaced by `WHEN (OLD.entitlement_set_id IS NOT NULL)`): caught by T2, `want_new` `replacing a tier product's set was refused`;
|
||||
- the `WHEN` refusing the state (the same text replaced by `WHEN (NEW.entitlement_set_id IS NULL)`): caught by T2, `want_new` `an update that leaves a tier product's NULL set NULL was refused`;
|
||||
- the product check ignoring the tiers (`IF EXISTS (SELECT 1 FROM core.plan_ladder_tiers t WHERE t.product_id = NEW.product_id) THEN` replaced by `IF TRUE THEN`): caught by T2, `want_new` `clearing the set of a product on no ladder was refused`;
|
||||
- the product trigger not created (its `CREATE TRIGGER trigger_products_refuse_tier_set_clear` statement deleted), one edit and two records: caught by T2, `want_new` `clearing the set of a tier product was accepted`, and by T3, `want_new` `the product lost its set although it is a tier` (the clear still waits on the row lock, then commits);
|
||||
- the product check's constraint name changed (`CONSTRAINT = 'chk_products_tier_keeps_set'` replaced by `CONSTRAINT = 'chk_set'`): caught by T2, `want_new` `constraint = "chk_set", want "chk_products_tier_keeps_set"`;
|
||||
- a Down that leaves the product trigger and its function (`DROP TRIGGER trigger_products_refuse_tier_set_clear ON core.products;` and `DROP FUNCTION core.refuse_tier_set_clear();` deleted): caught by T9, `want_new` `the schema after Down differs from the schema before Up`;
|
||||
- breaks no test sees, named in the commit with the design's reasons (Tests): either `GRANT EXECUTE` line removed; the `TABLE` and `COLUMN` options of either raise; the `FOUND` test's other side, a product id no row names, which the foreign key refuses either way.
|
||||
|
||||
## 3. The tier add refuses a product with no set
|
||||
|
||||
- [ ] 3.1 `internal/server/operator_plan_ladders.go` (design D2, Copy R1): `refuseTierAdd`'s map at `StepCreateTier` (`:481-484`) gains `"chk_plan_ladder_tiers_product_has_set": {Field: "product_id", Message: "Only products with an entitlement set can be tiers."}`, and its doc comment names the refusal among the database refusals the operator can act on.
|
||||
- [ ] 3.2 Test. T4 `TestTierAddRefusesAProductWhoseSetWasClearedSinceThePicker` (`internal/server/operator_plan_ladder_tier_add_test.go`): `addTierWorld(t)` (`:132-144`); a product `Bare` made by `w.Product` and cleared with `w.WithoutSet`, which the trigger lets through because Bare is no tier; a harness `newOtcHarnessWith(t, w.DB, operator.ID, otcOptions{billingQ: staleSetBillingQ{…}})`, whose `ListActiveProducts` returns the real list with Bare's set put back, as the picker read it before the clear; `h.addTierInRequest("ref-153", main.ID, bare.ID)`. The answer is 422 (`the tier add answered %d, want 422`), the Product field's error (`#form-operator\.plan-ladder\.tier\.add-product_id-error`, read through `htmltest`) is R1 (`the Product field carries no error; the form-level slot reads %q` when it is absent, `the Product field's error = %q, want %q` when it differs), the ladder holds no tier of Bare (`the refused add left %d tier rows of the product, want 0`), and no `tier_added` act names it (`SELECT count(*) FROM core.plan_act_details WHERE cause = 'tier_added' AND product_id = $1`; `the refused add left %d tier_added acts naming the product, want 0`).
|
||||
- [ ] 3.3 `docs/plan-management.md`, "Attaching Tiers": the bullet **The product.** adds that the database refuses, at the insert, a product whose entitlement set was cleared after the list was shown, answering "Only products with an entitlement set can be tiers." under the Product field.
|
||||
- [ ] 3.4 `make screens` (it resets the app database; item 1.0 has run): no changed screen is expected beyond item 1.9's, because the demo ladder's Add tier panel offers no product, so no capture reaches the refusal; a changed screen is a stop to report, never a baseline this change accepts.
|
||||
- [ ] 3.5 Breaks (test records):
|
||||
- the map entry removed (`"chk_plan_ladder_tiers_product_has_set": {Field: "product_id", Message: "Only products with an entitlement set can be tiers."},` deleted, so `web.FieldErrorsFromDB` answers its class message in the form-level slot, `internal/web/dberrors.go:51-52`): caught by T4, `want_new` `the Product field carries no error`;
|
||||
|
||||
## 4. The product edit does not offer "None" for a tier product
|
||||
|
||||
- [ ] 4.1 `internal/server/operator_product_forms.go` (design D3; Copy R2, R3):
|
||||
- `productTier` (`ladders []string`, the names of the ladders the product is a tier of in the query's order; `unread bool`), `productTierFrom(rows []billing.ListLaddersByProductRow, err error) productTier`, and its methods `isTier() bool` (`len(ladders) > 0`) and `offersNone() bool` (`!unread && !isTier()`). Each reads no row.
|
||||
- `internal/forms/forms.go:614-617`: the `Slot` doc comment, which names only the htmx use, also says "or a line the handler decides per render (the product's Entitlement set)"; a comment, no behaviour.
|
||||
- `tierNeedsSetSentence(ladders []string) string`: `"This product is a tier of " + joinWithAnd(ladders) + ", and a tier needs an entitlement set."`.
|
||||
- `productSetOptions(side forms.Side, sets []EntitlementSetOption, tier productTier) []forms.Option`: on the edit side, `forms.NoneOption()` first when `tier.offersNone()`, and `forms.ChooseOption("an entitlement set")` first otherwise; the create side and the sets that follow are unchanged. Its doc comment states the rule. `productFormOptions` takes and passes `tier`; `productCreateForm` passes the zero `productTier`, which the create side does not read.
|
||||
- The Entitlement set field in `productForm` drops its declared `Hint` and declares `Slot: "line"`, the pattern the product name (`productNameField`), the tier add (`operator_plan_ladder_forms.go:115`) and the grant form (`operator_enrollment_forms.go:220`) use for a line the record decides. The off-ladder sentence "The set is what the product delivers; a product with none delivers no capabilities." moves into a constant `productSetOffLadderLine` beside `productSetLine`.
|
||||
- `productSetLine(tier productTier, errs *forms.Errors) string`: `""` when `tier.unread`; `tierNeedsSetSentence(tier.ladders)` when `tier.isTier()` and the field has no error, and `""` when it has one (`errs.Has`), so the error is the field's one line; `productSetOffLadderLine` otherwise. A helper turns a non-empty line into the slot's HTML in the markup a declared hint renders with (`ui_form_field.html:86`): ``template.HTML(`<div class="form-text">` + template.HTMLEscapeString(line) + `</div>`)``, as `compose.go:67` builds its own; an empty line gives no entry.
|
||||
- `productEditForm(productID string, sets []EntitlementSetOption, tier productTier, values forms.Values, errs *forms.Errors, slots map[string]template.HTML)` passes `productFormOptions(forms.EditOnly, sets, tier)` and a copy of `slots` with the `entitlement_set_id` entry from `productSetLine(tier, errs)`; `productCreateForm` passes `Slots` with the `entitlement_set_id` entry `productSetOffLadderLine`, so the create page reads as before. `productEditSlots`' doc comment, which says the name slot is the only one the form declares, is corrected.
|
||||
- [ ] 4.2 `internal/server/operator_products.go` (design D3):
|
||||
- `OperatorProductEditData` gains the unexported `tier productTier`. `loadProductEditDataResult` (`:751-858`) sets it from the "Plan ladders" read it already makes (`:806-817`) through `productTierOf`; the "Plan ladders" section is unchanged. It passes `data.tier` to `productEditForm`, and the page makes no statement it did not make before.
|
||||
- L3 has one site: `func (h *OperatorPartialsHandler) productTierOf(ctx context.Context, productID string, rows []billing.ListLaddersByProductRow, err error) productTier` logs L3 (`product edit: tier read failed`, attributes `product_id`, `error`, at Error on `h.Logger`) when `err` is set and returns `productTierFrom(rows, err)`. The page loader and `loadProductTier` both call it, so the page, the PUT and the refusal render (which loads the page data again) each go through the one `Logger.Error` call.
|
||||
- `loadProductTier(ctx context.Context, productID string) productTier` reads `ListLaddersByProduct` once and returns `h.productTierOf(…)` of that read.
|
||||
- `UpdateProduct` (`:339-409`): `tier := h.loadProductTier(r.Context(), productID)` before the parse, and the parse reads `productFormOptions(forms.EditOnly, sets, tier)`. When `tier.unread`, the handler renders a fresh `forms.NewErrors()` whose only message is the form-level "Failed to update the product. Details are in the server logs." (the library has no call that removes one field's error), writing nothing. When the parse refused `entitlement_set_id`, the submitted value is empty (`values.Raw`) and `tier.isTier()`, the field's error is replaced with `tierNeedsSetSentence(tier.ladders)`. A write refused with `chk_products_tier_keeps_set` (the error's `*pgconn.PgError`, through `errors.As`) reads the ladders again with `loadProductTier`: when they name a ladder, the map entry is R2 on `entitlement_set_id` over those names; when the read fails or finds none, it logs `failed to update product` (L4: `slog.Any("error", err)` and `product_id`) and the entry is the form-level failure sentence. That read runs only for that constraint, so a write refused on another constraint makes no extra read. The handler's doc comment states the rule in place of "opens on 'None', whose empty value is a legitimate submission". The function stays under the complexity limit by splitting, not by a lint exception.
|
||||
- `renderProductEditRefusal` (`:424-436`) passes `edit.tier` to `productEditForm`.
|
||||
- The two test callers of `productEditForm` pass the record's `tier`: `renderProductEdit` (`internal/server/operator_product_readiness_render_test.go:20-32`) passes `data.tier`, and `internal/server/operator_catalog_detail_render_test.go:87` passes `edit.tier`; their zero value is a product read and found on no ladder, so both render what they render today.
|
||||
- [ ] 4.3 Tests. Every check in T6, T6c and T6d after the status code uses `t.Errorf`, the status checks included, so a row reports every mismatch and each later message below prints. The field's line is the text of `#form-operator\.product-entitlement_set_id-line`, which renders on every page and is empty when there is no line. "No line" means that text is empty. "Lines under the Entitlement set field" counts the `.form-text` and `.invalid-feedback` elements inside `#form-operator\.product-entitlement_set_id` (the slot's line carries `form-text`, item 4.1):
|
||||
- T6 `TestProductEditOfATierProductOffersNoNone` (a new `internal/server/operator_product_tier_set_db_test.go`, package `server_test`, over `world.New` and `newOtcHarness`), rows `ladders created in name order` (Forum, then Hosting) and `ladders created in reverse` (Hosting, then Forum), through a named function, the product `Tiered` a tier of both. The record page (`GetProductDetailPage`) offers no "None" (`the record page of a tier product offers "None"`), its first option is R3 and disabled (`the first option = %q (disabled %v), want "Choose an entitlement set", disabled`), the product's set is the selected option (`the selected option = %q, want the product's set %q`), and the line under the field (the text of `#form-operator\.product-entitlement_set_id-line`) is R2 over the two names in name order (`the field's line = %q, want %q`). A PUT of the product's name with an empty `entitlement_set_id` answers 422 (`the PUT of "None" answered %d, want 422`), with R2 as the field's error (`the Entitlement set field's error = %q, want %q`), one line under the control (`lines under the Entitlement set field after the refusal = %d, want 1`), R3 as the selected option (`the selected option after the refusal = %q, want "Choose an entitlement set"`), and the stored set unchanged (`the stored set after the refused PUT = %v, want %s`).
|
||||
- T6b `TestProductEditRefusesNoneForAProductThatBecameATier` (same file), through `scriptedLaddersQ`, a billing querier whose `ListLaddersByProduct` answers its calls in turn from a list (`none`: no row; `fail`: a read error of the test's own), and the real read after the list ends; rows `none`, `none, fail` and `none, none` through a named function: with `none`, the PUT of "None" passes the parse, the trigger refuses the write, the second read finds the ladder, and the answer is 422 with R2 as the field's error (`the Entitlement set field carries no error; the form-level slot reads %q` when it is absent, `the Entitlement set field's error = %q, want %q` when it differs); with `none, fail`, the answer is 422 with the form-level failure sentence (`the form-level slot = %q, want %q`); with `none, none` (the second read answers no row), the answer is 422 with the form-level failure sentence and `wantInLine(t, h.logs.String(), "failed to update product", "product_id="+p.ID, "is a plan tier and keeps its entitlement set")` holds; in all three rows the stored set is unchanged (`the stored set after the refused PUT = %v, want %s`).
|
||||
- T6c `TestProductEditWithUnreadLaddersSavesNothing` (same file), through a billing querier whose `ListLaddersByProduct` always fails: the record page offers no "None" (`the page offers "None" while the ladders are unread`), opens with R3 (`the first option = %q, want "Choose an entitlement set"`), and carries no line under the field (`the field carries a line while the ladders are unread: %q`); a PUT that renames the product and keeps its set answers 422 (`the PUT answered %d while the ladders are unread, want 422`) with the form-level failure sentence (`the form-level slot = %q, want %q`), the stored name is unchanged (`the name changed although the ladders could not be read`); a PUT of an empty `entitlement_set_id` answers the same sentence with no error under the Entitlement set field (`the Entitlement set field carries %q while the ladders are unread`); and `wantInLine(t, h.logs.String(), "product edit: tier read failed", "product_id="+p.ID)` holds.
|
||||
- T6d `TestTheEntitlementSetFieldFollowsTheProductsTiers` (a new `internal/server/operator_product_tier_set_render_test.go`, package `server`, no database), the record-mode render through `renderProductEdit`, rows through a named function: `on no ladder, with a set` ("None" first, the set selected, the off-ladder sentence); `on no ladder, with no set` ("None" first and selected, the off-ladder sentence); `on no ladder, with a refused set` (the `Form` built by `productEditForm` with an `entitlement_set_id` error: the off-ladder sentence and the error); `a tier, with a set` (no "None", R3 first and unselected, the set selected, R2); `a tier, with no set` (no "None", R3 selected, R2); `unread, with a set` (no "None", R3 first, the set selected, no line); `unread, with no set` (no "None", R3 selected, no line). Failures read `%s: the options = %v, want %v`, `%s: the selected option = %q, want %q` `%s: the field's line = %q, want %q` and `%s: the field's error = %q, want %q`, with the row's name.
|
||||
- `TestTheCreatePageCarriesTheOffLadderLine` (same file): `productCreateForm(sets, forms.NewValues(), nil)` rendered, expecting the line `productSetOffLadderLine`; a failure reads `the create page's line = %q, want %q`.
|
||||
- `TestTierNeedsSetSentence` (same file), rows `one ladder`, `two ladders`, `three ladders`, each compared as the whole sentence (`This product is a tier of Forum, and a tier needs an entitlement set.`, `This product is a tier of Forum and Hosting, and a tier needs an entitlement set.`, `This product is a tier of Docs, Forum and Hosting, and a tier needs an entitlement set.`). A failure reads `tierNeedsSetSentence(%s) = %q, want %q`.
|
||||
- `TestFormsHTML` (`internal/server/forms_golden_test.go`) is run with `-update` once: `internal/server/testdata/forms/operator.product.html` changes because the Entitlement set field's `Hint` becomes a `line` slot (in each binding the `-hint` div becomes an empty `-line` div, and `aria-describedby` names `-line` in place of `-hint`, after `-error` in the refused binding), and the diff is read; any other change is a stop. `TestProductEditEntitlementSetSentinel` (`internal/server/operator_products_test.go:22`) passes with a wording edit: the comment, the subtest name "sentinel is present as an option regardless of selection state" and the error "the unset sentinel option must always be present on the edit side" say "for a product on no ladder", because a tier product no longer offers it.
|
||||
- Pass unedited: `TestUpdateProductEntitlementSetSentinel` (`internal/server/operator_products_handler_test.go:27-129`; its product is on no ladder, which proves F12), and the product page's row of `TestOperatorPagesReadAFixedNumberOfTimes` (`internal/server/operator_read_count_db_test.go:122-129`; the page reads the tiers once).
|
||||
- [ ] 4.4 `make screens` (it resets the app database; item 1.0 has run): `operator-product-detail` captures the first product the products list links to (`test/e2e/screens/manifest.go:71-72`). If that product is a tier in the demo state, its Entitlement set field's line reads R2 and the commit message names the screen; any other changed screen is a stop to report, and `operator-product-new` does not change.
|
||||
- [ ] 4.5 Breaks (test records):
|
||||
- the parse's error left in place (`errs.Field("entitlement_set_id", tierNeedsSetSentence(tier.ladders))` deleted from `UpdateProduct`'s branch after the parse): caught by T6, `want_new` `the Entitlement set field's error = "Choose an entitlement set."`;
|
||||
- "None" offered whatever the tiers (`if tier.offersNone() {` replaced by `if true {` in `productSetOptions`), one edit and three records: caught by T6, `want_new` `the record page of a tier product offers "None"`; by T6c, `want_new` `the page offers "None" while the ladders are unread`; and by T6d, `want_new` `a tier, with a set: the options =`;
|
||||
- the placeholder left out (`out = append(out, forms.ChooseOption("an entitlement set"))` deleted): caught by T6d, `want_new` `a tier, with no set: the selected option = ""`, and by T6, `want_new` `the selected option after the refusal = ""`;
|
||||
- the ladders out of name order (in the generated `internal/billing/plan_ladders.sql.go`, `WHERE t.product_id = $1` followed by `ORDER BY l.name ASC` replaced by `WHERE t.product_id = $1` followed by `ORDER BY l.plan_ladder_id DESC`): caught by T6, `want_new` `the field's line = "This product is a tier of Hosting`, in the row that creates Forum first (uuidv7 ids rise with creation, so `DESC` reverses it);
|
||||
- the line kept beside the error (the `errs.Has("entitlement_set_id")` branch of `productSetLine` removed, so R2 renders as the slot's line and as the error): caught by T6, `want_new` `lines under the Entitlement set field after the refusal = 2, want 1`;
|
||||
- the off-ladder line blanked beside an error (`productSetLine`'s `errs.Has` test moved before its `isTier` test): caught by T6d, `want_new` `on no ladder, with a refused set: the field's line =`;
|
||||
- the create page's line lost (the create side's `entitlement_set_id` entry deleted from `productCreateForm`'s `Slots`): caught by `TestTheCreatePageCarriesTheOffLadderLine`, `want_new` `the create page's line = ""`;
|
||||
- the failed read treated as no tier (`unread` ignored in `productSetLine`: `if tier.unread {` replaced by `if false {`): caught by T6c, `want_new` `the field carries a line while the ladders are unread`;
|
||||
- the save going ahead when the read failed (the `tier.unread` refusal in `UpdateProduct` deleted): caught by T6c, `want_new` `the name changed although the ladders could not be read`;
|
||||
- the parse's field error left when the ladders are unread (the fresh `forms.NewErrors()` of the `tier.unread` branch replaced by the parse's errors): caught by T6c, `want_new` `the Entitlement set field carries`;
|
||||
- L3 not logged (the one `h.Logger.Error("product edit: tier read failed", …)` call in `productTierOf` deleted): caught by T6c, `want_new` `no log line holds all of`;
|
||||
- the second-read refusal not logged (the `failed to update product` log call of the `chk_products_tier_keeps_set` branch deleted): caught by the `none, none` row of T6b, `want_new` `no log line holds all of`;
|
||||
- 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) =`.
|
||||
|
||||
## 5. Landing
|
||||
|
||||
- [ ] 5.1 With the test stack up, `make test` passes (it runs `make lint` first), and `openspec validate tiers-need-an-entitlement-set --strict` passes; the lint's notebook-citations check is what holds the change's files free of a path into the local notebook.
|
||||
- [ ] 5.2 The race run of item 2.3 is in its commit's message, and every section's break list reads CAUGHT or names its unseen breaks with the reason.
|
||||
- [ ] 5.3 The same counts on a production deployment (item 1.0's three statements) are the maintainer's: he runs them as `console_reader` (`docs/database-management.md`, "The `console_reader` role"), which reads every column they name, and fixes each row by hand: the product given an entitlement set on its page, after which Retry moves the organizations a default change or a reorder left behind; or the default changed; or the bad tier removed. This change's tasks do not touch any production deployment.
|
||||
- [ ] 5.4 With the app rebuilt from section 4's tree, after section 4's `make screens` and before its commit, reproduce #153's sequence: run `go test ./test/e2e/operator-walkthroughs/ -count=1 -run 'PlanLadders|OrgTypes|GrantRevoke'` twice without a reset between the runs. Section 4's commit message records both runs and the second run's revoke result.
|
||||
- [ ] 5.5 After the push, post these comments under `CONTRIBUTING.md`'s rules for forge text (item 1: each reads for someone who has never opened the repository, explains the console's terms once, and names no decision number, change name, date as narrative, deployment or count). Comments need no go. #61 gets none: it is about gaps in a ladder's rank order, a different rule from the one this change adds.
|
||||
- On #153, then close it, with the change's commits filled in:
|
||||
|
||||
> An organization type's default plan can no longer start on a product that confers nothing. (A plan ladder is an ordered list of tiers, each tier a product. An organization type names one ladder as its default, and that ladder's bottom tier is what a new organization starts on and what an organization left with no plan is put back on. A product's entitlement set is what the product delivers.)
|
||||
>
|
||||
> The database now refuses the two writes that produced that state: making a product with no entitlement set a tier of any ladder, and clearing the entitlement set of a product that is a tier. Every default ladder starts on a tier, so no change to a ladder, a product or a default can leave one starting on a product with no set. This holds on ladders that no organization type uses as well, because a tier with no set can never be delivered: conferring it is refused for grants and for the default, and a subscription to it is skipped with a warning in the server log.
|
||||
>
|
||||
> On a ladder's page, Add tier no longer lists products with no entitlement set, and a product whose set was cleared after the list was shown is refused with "Only products with an entitlement set can be tiers." On a product's page, a product that is a tier no longer offers "None" for its entitlement set, and the field reads "This product is a tier of Hosting, and a tier needs an entitlement set."
|
||||
>
|
||||
> The same cause broke more than the revoke reported above. When the broken default was the personal type's, a new person's first sign-in failed with the "Sign-in failed" page, because sign-up confers that default and the whole sign-up is undone when that fails. A subscription's end was undone too, so a cancelled subscription's plan kept delivering, and every replay of the cancellation failed the same way. A grant's expiry, a tier removal that moves holders onto the default, and each organization's move after a default change (the comment above) or a reorder failed on it as well. None of these can now fail on this cause.
|
||||
>
|
||||
> Not changed here: a default ladder whose bottom tier is a draft or retired product breaks the same paths, and nothing in the console changes a product's lifecycle yet; #155 has a note on it. A set with no rules is still allowed, and the previews still warn about it. A database that already holds such a ladder keeps it until it is fixed by hand: give the product an entitlement set on its page, and Retry on the organization type's card then moves the organizations a default change left behind.
|
||||
>
|
||||
> Commits: …
|
||||
|
||||
- On #155:
|
||||
|
||||
> A note from fixing #153. Restoring an organization type's default confers the bottom tier of its default ladder, and that conferral refuses a draft product always, and a retired product for any organization that has never held it through its default. So a default ladder whose bottom tier is draft breaks the same paths #153 lists for a product with no entitlement set (a new person's first sign-in, a revoke, a grant's expiry, a subscription's end, and the moves after a default change), and one whose bottom tier is retired breaks them for every organization that never held that product as its default, every new sign-up included. Nothing in the console changes a product's lifecycle today (#97), so only the database or a seed reaches this. Whatever adds a retire action should refuse retiring the bottom tier of a ladder an organization type uses as its default, unless this issue first settles how retirement meets the default.
|
||||
|
||||
- On #207:
|
||||
|
||||
> A note from fixing #153. The product page now logs a failed read of the product's ladders (`product edit: tier read failed`), and its Entitlement set field no longer treats that failure as the product being on no ladder: it offers no "None" and saves nothing. The page's "Plan ladders" section still answers "Not a tier on any ladder." after a failed read; that answer stays with this issue.
|
||||
|
||||
- On #99:
|
||||
|
||||
> A note from fixing #153. Every plan tier's product must now have an entitlement set: the database refuses a tier whose product has none, and refuses clearing the set of a product that is a tier. Those checks, like the conferral's own, test only that the product's set column is filled; none checks that it names a set that exists. A tier whose product points at a set that does not exist passes all three and fails only when a provision is written, at the provision's foreign key to the sets table. That case still rests on this issue.
|
||||
|
||||
- [x] 5.6 Handed to the maintainer: the two issues in the proposal's "Out of scope", filed on his go as #272 (a member can buy an add-on that has no entitlement set and receive nothing) and #273 (a held product on no ladder whose set is cleared makes its grant's next extension through Manage answer "Failed to save the grant. Details are in the server logs."), and the result of item 1.0 on the test stack: no default ladder starts on a product with no set, no plan act waits for one, and the two tiers with no set were on a ladder the browser walkthroughs had left in the app database (design, "The walkthrough order dependency").
|
||||
- [ ] 5.7 The archive waits for the maintainer's go, and only after `displaced-subscription-waits` and `reconcile-takes-pool-lock` are archived; the deltas carry every spec change, and no Purpose needs an edit a delta cannot make. Nothing in this change writes to a production host: item 5.3's production counts are reads under the reader role, run by the maintainer, and any fix they lead to is a separate act on his go.
|
||||
@@ -393,7 +393,7 @@ func (s *sheet) seedCatalog() {
|
||||
for i, l := range extraLadders {
|
||||
ladderID := s.id(`INSERT INTO core.plan_ladders (key, name, sort_order) VALUES ($1, $2, $3) RETURNING plan_ladder_id`, l.key, l.name, i+1)
|
||||
for rank, p := range l.tiers {
|
||||
productID := s.product(p, "")
|
||||
productID := s.tierProduct(p)
|
||||
s.exec(`INSERT INTO core.plan_ladder_tiers (plan_ladder_id, product_id, rank) VALUES ($1, $2, $3)`, ladderID, productID, rank)
|
||||
}
|
||||
}
|
||||
@@ -402,6 +402,17 @@ func (s *sheet) seedCatalog() {
|
||||
}
|
||||
}
|
||||
|
||||
// tierProduct inserts p as product does and gives it an entitlement set of
|
||||
// its own, named after p's key and holding no rule: a plan tier's product has a
|
||||
// set, and a set with no rule delivers no limit, as no set did.
|
||||
func (s *sheet) tierProduct(p product) string {
|
||||
s.t.Helper()
|
||||
productID := s.product(p, "")
|
||||
setID := s.id(`INSERT INTO core.entitlement_sets (name) VALUES ($1) RETURNING set_id`, "sheet set "+p.key)
|
||||
s.exec(`UPDATE core.products SET entitlement_set_id = $2 WHERE product_id = $1`, productID, setID)
|
||||
return productID
|
||||
}
|
||||
|
||||
func (s *sheet) product(p product, category string) string {
|
||||
s.t.Helper()
|
||||
productID := s.id(`INSERT INTO core.products (key, name, description, display_category, is_active, is_public, lifecycle_status)
|
||||
|
||||
Reference in New Issue
Block a user