Disable a tier's removal on the card when the holder limit refuses it

The tier card checked the last-tier rule but not the holder limit, so
a tier more organizations hold than the limit offered an enabled
Preview removal whose preview could only say the removal is refused
(#187). The ladder controls' requirement already asks that a control
certain to be refused render disabled with its reason.

loadPlanLadderTiersData now decides every tier with the same
TierRemovalRefusal the preview and the removal call. It reads every
tier's counts in one statement, the new CountLiveTierHoldersPerTier,
so the page's reads do not grow with its tiers:
- over the limit, Preview removal is disabled with the limit sentence,
  and its Migrate sentence when most holders came from a default;
- a refusal the card knows wins over the unread org types on rank 0;
- when the counts cannot be read, every held tier's Preview removal is
  disabled with "Failed to load the tier's holders. Reference:
  {request id}.", and the failure is logged once with the request id.

The refused removal preview stays for a page rendered before the refusal
applied. Tests cover each case. The one golden that records the page's
statements gains the new read. The design, the plan-ladder-management
spec and docs/plan-management.md say the same, and task 8.8 records it.

The full suite passes.
This commit is contained in:
2026-10-20 14:20:57 -04:00
parent 9db9c107de
commit 35e14578a2
11 changed files with 243 additions and 31 deletions
+2 -2
View File
@@ -105,7 +105,7 @@ The commit is **atomic**: tier deletion, renumbering, and every holder reconcili
The removal decides inside that transaction, before it takes any pool lock, and the preview reads the same counts and, when either rule refuses, shows the same sentence and offers Discard alone:
- **The holder limit.** It counts the organizations holding a live position on the tier and refuses above 200, the same limit as the tier add (see "Attaching Tiers"); the removal's per-holder cost sets it. The refusal names the count and the limit. When more than half of those organizations hold the tier through their org type's default, it adds that changing that default with Migrate moves them in the background, and, for the rank-0 tier of a ladder that has another tier, when every one of the organizations holding it through an org type's default holds it through a type that defaults to this ladder, that reordering this ladder with Migrate does too.
- **The holder limit.** It counts the organizations holding a live position on the tier and refuses above 200, the same limit as the tier add (see "Attaching Tiers"); the removal's per-holder cost sets it. The refusal names the count and the limit. When more than half of those organizations hold the tier through their org type's default, it adds that changing that default with Migrate moves them in the background, and, for the rank-0 tier of a ladder that has another tier, when every one of the organizations holding it through an org type's default holds it through a type that defaults to this ladder, that reordering this ladder with Migrate does too. The tier card reads the same counts and renders the tier's Preview removal disabled over the limit, with the same sentences as its reason, or with "Failed to load the tier's holders. Reference: {request id}." when it cannot read them.
- **The last tier of a default ladder.** The removal reads the ladder's tiers and the org types that default to it, and refuses to remove the only tier of a ladder some org type defaults to under Keep as under Migrate, with the sentence the plain delete gives.
- **The record.** The removal writes one act (cause `tier_removed`, with the disposition when one was asked for) naming the ladder and the product as they were named at that moment, and the grants Migrate's floor restoration creates carry that act. A name that cannot be read refuses the removal. A lock wait or a deadlock at any step answers "Another change collided with this one; try again."; a failure while reconciling a holder answers "Removal failed and nothing was changed: could not …" for the step, ending "Reference: {request id}.", and every failure's log line carries the same request id and the step that failed. Any other failure (the holder count, the tiers, the org types or the names cannot be read, the act cannot be recorded, the tier cannot be deleted or renumbered, the commit fails) answers "Failed to remove the tier; nothing was changed." and logs the step. When no holder is left by the time the commit runs, it answers "{Product} removed from the ladder." with no count.
- **A repeated removal.** A removal of a product that is no tier of the ladder and has no holder, such as the second of two removals of one tier, records no act, deletes nothing and answers as a successful removal does, as the delete below does.
@@ -114,7 +114,7 @@ The removal decides inside that transaction, before it takes any pool lock, and
The plain delete of a tier with no live holders runs the same way, as a removal with no holders, and decides inside the one transaction that deletes:
- **Live holders.** A holder is live when its position is `active` or `suspended`, the population a removal reconciles. The delete counts them in its own transaction and refuses a tier that has any, so a tier held only by a past-due subscription's suspended position is not deleted under it; the tier page offers Preview removal for such a tier, and the delete a stale page sends is refused with the live-holder sentence. The Active column on the tier page still counts `active` positions only.
- **The last tier of a default ladder.** The tier card renders that tier's Remove (Preview removal when it has live holders) disabled, with this sentence as the reason. When the card cannot read the org types, it renders rank 0's Remove disabled with the failed read as the reason instead, because the card cannot tell whether this rule applies. The delete reads the ladder's tiers and the org types that default to it, and refuses to delete the only tier of a ladder some org type defaults to (new signups would land on a ladder with no tier). A read of the org types that fails refuses the delete too; the delete never proceeds for want of an answer.
- **The last tier of a default ladder.** The tier card renders that tier's Remove (Preview removal when it has live holders) disabled, with this sentence as the reason. When the card cannot read the org types, it renders rank 0's Remove disabled with the failed read as the reason instead, because the card cannot tell whether this rule applies; a holder-limit sentence the card knows takes the place of that failed read. The delete reads the ladder's tiers and the org types that default to it, and refuses to delete the only tier of a ladder some org type defaults to (new signups would land on a ladder with no tier). A read of the org types that fails refuses the delete too; the delete never proceeds for want of an answer.
- **The record.** The delete writes one act (cause `tier_removed`, no disposition) in the same transaction, naming the ladder and the product as they were named at that moment, and then deletes and renumbers. A failure at any step rolls the act back with the rest. A lock wait or a deadlock at any step answers "Another change collided with this one; try again."; every other failure answers "Failed to remove tier. Reference: {request id}.", and the log line carries the same request id and the step that failed.
- **A repeated delete.** A delete of a product that is no tier of the ladder, such as the second of two deletes of one tier, writes no act, deletes nothing and answers as a successful delete does.
@@ -91,9 +91,10 @@
Actions cell empty"; design D23 keeps tooltips for
disabled controls only). */}}
{{ else if .RemoveDisabled }}
{{/* Rank 0's removal cannot be previewed (the org types
are unread) or is certain to be refused (the ladder's
only tier, defaulted to); the reason is the sentence. */}}
{{/* The removal is certain to be refused (over the holder
limit, or the ladder's only tier, defaulted to) or
cannot be previewed (the holder counts, or rank 0's
org types, are unread); the reason is the sentence. */}}
{{ template "disabledControl" .RemoveDisabled }}
{{ else if gt .LiveHolders 0 }}
<button type="button" class="btn btn-outline-danger btn-sm ms-1"
@@ -164,6 +164,58 @@ func (q *Queries) CountLiveTierHolders(ctx context.Context, arg CountLiveTierHol
return i, err
}
const countLiveTierHoldersPerTier = `-- name: CountLiveTierHoldersPerTier :many
SELECT l.product_id,
COUNT(DISTINCT rp.org_id) AS holders,
COUNT(DISTINCT rp.org_id) FILTER (WHERE l.default_sourced) AS default_sourced,
COUNT(DISTINCT rp.org_id) FILTER (WHERE l.default_sourced AND ot.default_plan_ladder_id = l.plan_ladder_id) AS default_sourced_on_default
FROM core.pool_provision_ladders l
JOIN core.resource_pools rp ON rp.pool_id = l.pool_id
LEFT JOIN core.org_types ot ON ot.org_type = l.org_type
WHERE l.plan_ladder_id = $1 AND l.status IN ('active', 'suspended')
GROUP BY l.product_id
`
type CountLiveTierHoldersPerTierRow struct {
ProductID string `json:"product_id"`
Holders int64 `json:"holders"`
DefaultSourced int64 `json:"default_sourced"`
DefaultSourcedOnDefault int64 `json:"default_sourced_on_default"`
}
// CountLiveTierHolders' three counts for every tier of one ladder in one read,
// keyed by the tier's product: the ladder page decides each tier's removal
// from them, so the page reads a fixed number of times however many tiers the
// ladder has. A tier with no live position is absent from the result, and its
// reader takes all three as 0.
func (q *Queries) CountLiveTierHoldersPerTier(ctx context.Context, planLadderID string) ([]CountLiveTierHoldersPerTierRow, error) {
rows, err := q.db.QueryContext(ctx, countLiveTierHoldersPerTier, planLadderID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []CountLiveTierHoldersPerTierRow{}
for rows.Next() {
var i CountLiveTierHoldersPerTierRow
if err := rows.Scan(
&i.ProductID,
&i.Holders,
&i.DefaultSourced,
&i.DefaultSourcedOnDefault,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getActiveAttachmentsByPool = `-- name: GetActiveAttachmentsByPool :many
SELECT provision_id, plan_ladder_id, pool_id, status, activated_at, ended_at, created_at, updated_at, product_id, provision_ladder_id, org_type, default_sourced FROM core.pool_provision_ladders
WHERE pool_id = $1 AND status = 'active'
+6
View File
@@ -68,6 +68,12 @@ type Querier interface {
// through by, and which its preview reads as well. Counting organizations, not
// positions, is the limit's unit.
CountLiveTierHolders(ctx context.Context, arg CountLiveTierHoldersParams) (CountLiveTierHoldersRow, error)
// CountLiveTierHolders' three counts for every tier of one ladder in one read,
// keyed by the tier's product: the ladder page decides each tier's removal
// from them, so the page reads a fixed number of times however many tiers the
// ladder has. A tier with no live position is absent from the result, and its
// reader takes all three as 0.
CountLiveTierHoldersPerTier(ctx context.Context, planLadderID string) ([]CountLiveTierHoldersPerTierRow, error)
// The drain's own reads, beside the ledger queries in
// entitlement_set_changes.sql. Neither writes: one counts what a change still
// owes, the other names the pools whose last attempt left an error so the
@@ -153,6 +153,22 @@ JOIN core.resource_pools rp ON rp.pool_id = l.pool_id
LEFT JOIN core.org_types ot ON ot.org_type = l.org_type
WHERE l.plan_ladder_id = $1 AND l.product_id = $2 AND l.status IN ('active', 'suspended');
-- name: CountLiveTierHoldersPerTier :many
-- CountLiveTierHolders' three counts for every tier of one ladder in one read,
-- keyed by the tier's product: the ladder page decides each tier's removal
-- from them, so the page reads a fixed number of times however many tiers the
-- ladder has. A tier with no live position is absent from the result, and its
-- reader takes all three as 0.
SELECT l.product_id,
COUNT(DISTINCT rp.org_id) AS holders,
COUNT(DISTINCT rp.org_id) FILTER (WHERE l.default_sourced) AS default_sourced,
COUNT(DISTINCT rp.org_id) FILTER (WHERE l.default_sourced AND ot.default_plan_ladder_id = l.plan_ladder_id) AS default_sourced_on_default
FROM core.pool_provision_ladders l
JOIN core.resource_pools rp ON rp.pool_id = l.pool_id
LEFT JOIN core.org_types ot ON ot.org_type = l.org_type
WHERE l.plan_ladder_id = $1 AND l.status IN ('active', 'suspended')
GROUP BY l.product_id;
-- name: CountActiveAttachmentsPerTier :many
-- The tier's active and live positions for every tier of one ladder in one
-- read (#10), keyed by the tier's product: the ladder page and the tier
+42 -19
View File
@@ -71,13 +71,15 @@ type PlanLadderTierViewModel struct {
// removal's population. A tier with any is removed through Preview
// removal, and the plain delete refuses it.
LiveHolders int64
// RemoveDisabled is set on the rank-0 tier alone, in two cases: the org
// types that default to the ladder cannot be read, so the removal cannot
// be previewed (the reason is the unread sentence, which also reads beside
// the product name); or the tier is the ladder's only one and an org type
// defaults to it, so the removal is certain to be refused (the reason is
// entitlements.LastTierRefusal's sentence). The row's Remove or Preview
// removal then renders disabled with that reason; the unread case wins.
// RemoveDisabled is set when the tier's removal is certain to be refused
// or cannot be previewed, and the row's Remove or Preview removal then
// renders disabled with the reason: the holder limit (the sentence of
// entitlements.HolderLimitRefusal) or the ladder's only tier being an org
// type's default (entitlements.LastTierRefusal's sentence), both through
// entitlements.TierRemovalRefusal; the holder counts that cannot be read;
// or, on rank 0 alone, the org types that cannot be read (the unread
// sentence, which also reads beside the product name). A refusal the card
// knows wins over the unread org types.
RemoveDisabled *DisabledControl
}
@@ -1578,25 +1580,46 @@ func (h *OperatorPartialsHandler) loadPlanLadderTiersData(r *http.Request, ladde
LiveHolders: count.live,
}
}
planTiers := make([]billing.PlanLadderTier, len(data.Tiers))
guardTiers := make([]billing.PlanLadderTier, len(data.Tiers))
for i, t := range data.Tiers {
planTiers[i] = billing.PlanLadderTier{ProductID: t.ProductID}
guardTiers[i] = billing.PlanLadderTier{PlanLadderID: ladderID, ProductID: t.ProductID, Rank: t.Rank}
}
limit := h.PlanActs.HolderLimit()
// One read decides every tier's removal; a tier absent from it has no
// live holder.
holderCounts := map[string]entitlements.CountLiveTierHoldersPerTierRow{}
holderRows, err := h.EntitlementsQ.CountLiveTierHoldersPerTier(r.Context(), ladderID)
holdersUnread := err != nil
if holdersUnread {
logging.FromContext(r.Context()).Error("failed to count tier holders for the tier card", slog.Any("error", err), slog.String("plan_ladder_id", ladderID))
}
for _, row := range holderRows {
holderCounts[row.ProductID] = row
}
for i := range data.Tiers {
t := &data.Tiers[i]
if t.Rank != 0 {
continue
}
reason := data.DefaultTypesError
if reason == "" {
reason = entitlements.LastTierRefusal(planTiers, t.ProductID, data.DefaultForOrgTypeNames)
}
if reason == "" {
continue
}
label := "Remove"
var holders, defaultSourced, onDefault int64
if t.LiveHolders > 0 {
label = "Preview removal"
if holdersUnread {
c := NewDisabledControl("remove-tier-reason-"+t.ProductID, label, "btn btn-outline-danger btn-sm ms-1", failureReference(r, "Failed to load the tier's holders"))
t.RemoveDisabled = &c
continue
}
c := holderCounts[t.ProductID]
holders, defaultSourced, onDefault = c.Holders, c.DefaultSourced, c.DefaultSourcedOnDefault
}
// A refusal the card knows wins over the unread org types, which
// only rank 0's outcome depends on.
var reason string
if refusal := entitlements.TierRemovalRefusal(guardTiers, t.ProductID, data.DefaultForOrgTypeNames, holders, defaultSourced, onDefault, limit); refusal != nil {
reason = refusal.Error()
} else if t.Rank == 0 {
reason = data.DefaultTypesError
}
if reason == "" {
continue
}
c := NewDisabledControl("remove-tier-reason-"+t.ProductID, label, "btn btn-outline-danger btn-sm ms-1", reason)
t.RemoveDisabled = &c
@@ -44,6 +44,14 @@ func (holdersCountUnread) CountLiveTierHolders(context.Context, entitlements.Cou
return entitlements.CountLiveTierHoldersRow{}, errors.New("holder count unavailable")
}
// holdersPerTierUnread fails the one read the tier card makes for every tier's
// holders.
type holdersPerTierUnread struct{ entitlements.Querier }
func (holdersPerTierUnread) CountLiveTierHoldersPerTier(context.Context, string) ([]entitlements.CountLiveTierHoldersPerTierRow, error) {
return nil, errors.New("holder counts unavailable")
}
type holdersListUnread struct{ entitlements.Querier }
func (holdersListUnread) ListLiveTierHoldersByLadderProduct(context.Context, entitlements.ListLiveTierHoldersByLadderProductParams) ([]entitlements.ListLiveTierHoldersByLadderProductRow, error) {
@@ -466,3 +474,96 @@ func TestTierRemoval_AGateWithoutHoldersInTheRebuiltPreviewIsLogged(t *testing.T
})
}
}
// tierCardRows renders the ladder's detail page through h and returns its
// tier rows, rank 0 first.
func (r removalWorld) tierCardRows(t *testing.T, h *otcHarness) (string, []*htmltest.Node) {
t.Helper()
body := h.postPath(h.handler.GetPlanLadderDetailPage, map[string]string{"ladderID": r.main.ID}, nil).Body.String()
rows := htmltest.Parse(t, body).All("tbody tr")
if len(rows) != 2 {
t.Fatalf("%d tier rows, want 2: %.500s", len(rows), body)
}
return body, rows
}
// requireEnabledRemoval asserts the row's control is an enabled Remove.
func requireEnabledRemoval(t *testing.T, row *htmltest.Node) {
t.Helper()
row.None(t, "button[disabled]")
if got := row.One(t, "button.btn-outline-danger").Text(); got != "Remove" {
t.Errorf("the control reads %q, want an enabled Remove", got)
}
}
// The tier card decides each tier's removal with the rule the preview and the
// act apply: a tier more organizations hold than the limit renders Preview
// removal disabled with the limit's sentences as its reason, and the tier
// nobody holds keeps an enabled Remove.
func TestTierCardDisablesATierOverTheHolderLimit(t *testing.T) {
r := newRemovalWorld(t, true)
h := newOtcHarnessActs(t, r.w.DB, r.operator.ID, entitlements.PlanActConfig{HolderCap: 1})
_, rows := r.tierCardRows(t, h)
if got := rows[0].One(t, "button[disabled]").Text(); got != "Preview removal" {
t.Errorf("rank 0's disabled control reads %q, want Preview removal", got)
}
if got := rows[0].One(t, "#remove-tier-reason-"+r.basic.ID).Text(); got != removalLimitSentence {
t.Errorf("rank 0's reason reads %q, want %q", got, removalLimitSentence)
}
requireEnabledRemoval(t, rows[1])
}
// A tier at or under the limit, on a ladder that is not a default's only tier,
// keeps an enabled Preview removal.
func TestTierCardKeepsAHeldTierUnderTheHolderLimitEnabled(t *testing.T) {
r := newRemovalWorld(t, true)
h := newOtcHarness(t, r.w.DB, r.operator.ID)
_, rows := r.tierCardRows(t, h)
rows[0].None(t, "button[disabled]")
if got := rows[0].One(t, "button.btn-outline-danger").Text(); got != "Preview removal" {
t.Errorf("rank 0's control reads %q, want an enabled Preview removal", got)
}
requireEnabledRemoval(t, rows[1])
}
// A tier whose counts cannot be read renders Preview removal disabled with the
// failed read and the request's id; the tier nobody holds is unaffected.
func TestTierCardDisablesEveryHeldTierWhenTheHoldersCannotBeCounted(t *testing.T) {
r := newRemovalWorld(t, true)
h := newOtcHarnessWith(t, r.w.DB, r.operator.ID, otcOptions{entitlementsQ: holdersPerTierUnread{Querier: entitlements.New(r.w.DB)}})
body, rows := r.tierCardRows(t, h)
id := referenceIn(t, body)
if got := rows[0].One(t, "button[disabled]").Text(); got != "Preview removal" {
t.Errorf("rank 0's disabled control reads %q, want Preview removal", got)
}
if want, got := "Failed to load the tier's holders. Reference: "+id+".", rows[0].One(t, "#remove-tier-reason-"+r.basic.ID).Text(); got != want {
t.Errorf("rank 0's reason reads %q, want %q", got, want)
}
requireEnabledRemoval(t, rows[1])
if logs := h.logs.String(); !strings.Contains(logs, "request_id="+id) || !strings.Contains(logs, "level=ERROR") || !strings.Contains(logs, "holder counts unavailable") || strings.Count(logs, "holder counts unavailable") != 1 {
t.Errorf("the failed count is not logged with the request id %s: %s", id, logs)
}
}
// A refusal the card knows wins over an outcome it cannot know: rank 0 over the
// limit while the org types are unread shows the limit's sentences.
func TestTierCardShowsTheHolderLimitOverTheUnreadOrgTypes(t *testing.T) {
r := newRemovalWorld(t, true)
h := newOtcHarnessWith(t, r.w.DB, r.operator.ID, otcOptions{
acts: entitlements.PlanActConfig{HolderCap: 1},
orgQ: orgTypesUnread{Querier: organization.New(r.w.DB)},
})
_, rows := r.tierCardRows(t, h)
if got := rows[0].One(t, "#remove-tier-reason-"+r.basic.ID).Text(); got != removalLimitSentence {
t.Errorf("rank 0's reason reads %q, want %q", got, removalLimitSentence)
}
requireEnabledRemoval(t, rows[1])
}
@@ -40,6 +40,7 @@
"commit",
"ListTiersByLadderWithProducts",
"CountActiveAttachmentsPerTier",
"CountLiveTierHoldersPerTier",
"ListActiveProducts",
"ListTiersByLadder",
"GetProductShape"
@@ -226,7 +226,7 @@ The constants are set from the timed run of task 1 (`TestTimedPlanActCaps`, envi
A live position left on a deleted tier makes `core.confer` read no rank for it and record `initiate` where an upgrade, downgrade or transfer belongs, so a removal's alignment cannot be split from the deletion, and an add's from the insert. Both stay one transaction, write their act row and detail row, and write no obligations. Inside the transaction, before locking any pool, each counts its holders: the organizations holding a live position on the tier, or a live provision of the product being added, and how many of them hold it through an org type's default on a ladder (the position's `default_sourced` column). Above `planActHolderCap` it refuses, naming the count and the limit, and, when more than half of the holders hold it through an org type's default on a ladder, names the way through. The way through is a default change with Migrate, which ends only default-sourced positions on a ladder. A default-sourced provision that a `keep` removal left off every ladder (`ladder_acts.go:266-268`) counts toward the limit, because the add aligns it, but not toward the second sentence: a default change classifies its pool as would-initiate, floors it, and leaves the off-ladder provision live, so the holder count would not fall and the add would be refused again.
The rules are one pure function each in `internal/entitlements`, which read no row and which both the preview and the act call: `HolderLimitRefusal(holders, defaultSourced, cap, rankZeroOfTheirDefault)` returns the sentences or nothing, and `LastTierRefusal(tiers, productID, defaultingTypes)` returns the last-tier sentence or nothing. The removal's preview reads the same counts and the same org types. When either function refuses, the preview shows the sentence in place of its outcome lines and its disposition choice and offers Discard alone, because the destructive-controls requirement does not offer an action certain to be refused; the form library renders that state through `FormView.WayOutOnly`, which writes the commit row with the way out and no commit. The tier card applies the same rule to an outcome it cannot know: when it cannot read the org types, rank 0's Remove (or Preview removal), whose last-tier guard and promoted-tier note need them, renders disabled with that failure as its reason. The other tiers' controls stay enabled, because their outcome does not depend on the org types; the preview and the act still read them for every tier, so a read that fails again answers copy 14 or the generic failure. When the card can read the org types and a ladder an org type defaults to has one tier, that tier's Remove or Preview removal renders disabled with the last-tier sentence (copy 21) as its reason, through the same `LastTierRefusal`, because the deletion is certain to be refused. #237 replaces the limit with retire-then-remove.
The rules are one pure function each in `internal/entitlements`, which read no row and which both the preview and the act call: `HolderLimitRefusal(holders, defaultSourced, cap, rankZeroOfTheirDefault)` returns the sentences or nothing, and `LastTierRefusal(tiers, productID, defaultingTypes)` returns the last-tier sentence or nothing. The removal's preview reads the same counts and the same org types. When either function refuses, the preview shows the sentence in place of its outcome lines and its disposition choice and offers Discard alone, because the destructive-controls requirement does not offer an action certain to be refused; the form library renders that state through `FormView.WayOutOnly`, which writes the commit row with the way out and no commit. The tier card applies the same rule to an outcome it cannot know: when it cannot read the org types, rank 0's Remove (or Preview removal), whose last-tier guard and promoted-tier note need them, renders disabled with that failure as its reason. The other tiers' controls stay enabled, because their outcome does not depend on the org types; the preview and the act still read them for every tier, so a read that fails again answers copy 14 or the generic failure. The card also decides every tier's removal with the same `TierRemovalRefusal`, reading every tier's counts in one statement (`CountLiveTierHoldersPerTier`, so the page's reads do not grow with its tiers), so the card, the preview and the act apply one rule: a tier more organizations hold than the limit renders Preview removal disabled with the limit sentence (copy 15, with 16 or 18) as its reason, and when the card can read the org types and a ladder an org type defaults to has one tier, that tier's Remove or Preview removal renders disabled with the last-tier sentence (copy 21). A refusal the card knows wins over an outcome it cannot know, so a rank-0 tier over the limit shows the limit even when the org types are unread. When the counts cannot be read, every held tier's Preview removal renders disabled with copy 52, because the card cannot tell whether the limit refuses it. The refused preview stays for a page rendered before the refusal applied. #237 replaces the limit with retire-then-remove.
The tier add reads the product inside its transaction and refuses a product that is not published there, so the check and the insert are one transaction and a failed read is a failed add. No runtime statement writes a product's lifecycle status today (`UpdateProduct` does not set it; only test kits do), so the read does not guard against a concurrent change; if a writer is added, the read takes the product row `FOR SHARE`, which conflicts with a non-key update, where the tier insert's foreign key takes only `FOR KEY SHARE`. A missing product answers "Product not found."; any other error of that read answers the generic failure, because a failed read is not a missing product.
@@ -655,7 +655,7 @@ Each table lists what is committed, what the operator sees and how the act is ru
| Begin: lock wait past 5 s, or deadlock | nothing | the contention refusal | resubmit |
| Begin: other error | nothing | "Failed to remove the tier; nothing was changed."; logged | resubmit |
| Holder count, product name or ladder name read fails | nothing | "Failed to remove the tier; nothing was changed."; logged | resubmit |
| Holders above the limit | nothing | the removal's limit sentence, plus its second sentence when most are default-sourced; the preview already shows it, with Discard alone | move the default-sourced holders first; otherwise #237 |
| Holders above the limit | nothing | the removal's limit sentence, plus its second sentence when most are default-sourced; the tier card renders Preview removal disabled with it as its reason, and a preview requested from a stale page shows it with Discard alone | move the default-sourced holders first; otherwise #237 |
| Last tier of a ladder an org type defaults to, either disposition | nothing | the existing last-tier refusal; the preview shows it, with Discard alone | add another tier first |
| Holder read, pool locks, second read | nothing | the contention refusal or "Failed to remove the tier; nothing was changed."; now logged | resubmit |
| Default-sourced holders, no disposition | nothing | 422: "Choose whether to keep or migrate the N organization(s) ..." | choose |
@@ -803,7 +803,7 @@ Every sentence a person reads that this change adds, changes, reuses or removes.
| 12 | "Failed to retry. Reference: {request id}." | the card or the ladder page, as an error, when Retry fails for a reason other than lock contention | |
| 13 | "Pending plan changes could not be read. Reference: {request id}." | the card or the ladder page, in place of the lines, when they cannot be read | narrowed: a failed read of the toast's counts leaves those counts out of the toast and does not show this sentence |
| 14 | "Failed to preview the removal. Reference: {request id}." | the tier card, in place of the removal preview, when the org types its last-tier guard needs, or the ladder's tiers, cannot be read; and the 422 preview a refused commit rebuilds, on the same failure | "Where" extended with task 8 |
| 15 | "N organizations hold this tier; a removal is refused above C." | the removal's commit refusal; the refused removal preview, in place of its outcome lines and disposition choice, with Discard alone | "Where" changed: Discard alone |
| 15 | "N organizations hold this tier; a removal is refused above C." | the removal's commit refusal; the reason of the tier card's disabled Preview removal; the refused removal preview, in place of its outcome lines and disposition choice, with Discard alone | "Where" changed: Discard alone, the disabled Preview removal |
| 16 | "M of them hold it through their org type's default; changing that default with Migrate moves them in the background." When the tier is rank 0 of a ladder that has another tier, and every one of the M holders' org types defaults to that ladder: "M of them hold it through their org type's default; changing that default, or reordering this ladder, with Migrate moves them in the background." | after 15, when more than half of the holders are default-sourced | |
| 17 | "N organizations hold this product; adding it as a tier is refused above C." | the tier-add form's product field error | |
| 18 | "M of them hold it through their org type's default; changing that default with Migrate moves them in the background." | after 17, when more than half of the holders hold the product through a default-sourced position on a ladder; M counts only those holders, not default-sourced provisions a removal left off every ladder | |
@@ -838,7 +838,7 @@ Every sentence a person reads that this change adds, changes, reuses or removes.
| 49 | disclosure label "show all {N}" | "show all {N}" while the list holds every name; "show 50 of {N}" when the cap of 50 shortens it; N is the class's exact count | the default change's and the reorder's preview lists of outgoing-default and other-source organizations | new: with the cap, "show all {N}" would be false |
| 50 | "Failed to add tier; see server logs." | "Failed to add tier. Reference: {request id}." | the tier add on every failure path that is not a refusal: begin, the product read (other than no rows; today "Product not found."), the holder count, the pool locks, the act, the collision read, a collision the collision read did not find, the alignment, materialization and commit | approved |
| 51 | "Failed to remove tier; see server logs." | "Failed to remove tier. Reference: {request id}." | the tier delete, on every failure path that is not a refusal, including the read of the org types | approved |
| 52 | "Failed to load the tier's holders. Details are in the server logs." | "Failed to load the tier's holders. Reference: {request id}." | the removal preview, in place of the preview, when the holders cannot be listed, and the 422 preview a refused commit rebuilds, on the same failure | approved |
| 52 | "Failed to load the tier's holders. Details are in the server logs." | "Failed to load the tier's holders. Reference: {request id}." | the removal preview, in place of the preview, when the holders cannot be listed, and the 422 preview a refused commit rebuilds, on the same failure; the reason of the tier card's disabled Preview removal when the tier's counts cannot be read | approved; "Where" extended: the disabled Preview removal |
| 53 | "Removal failed and nothing was changed: could not end a default-sourced position. Details are in the server logs." | "Removal failed and nothing was changed: could not end a default-sourced position. Reference: {request id}." | the removal, when ending a default-sourced position fails | approved |
| 54 | "Removal failed and nothing was changed: could not reconcile a holder's position. Details are in the server logs." | "Removal failed and nothing was changed: could not reconcile a holder's position. Reference: {request id}." | the removal, when aligning a holder fails | approved |
| 55 | "Removal failed and nothing was changed: could not apply the current default. Details are in the server logs." | "Removal failed and nothing was changed: could not apply the current default. Reference: {request id}." | the removal, when restoring the current default fails | approved |
@@ -106,8 +106,9 @@ 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, and the removal
preview SHALL show the refusal in place of its outcome lines and its disposition
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
@@ -283,6 +284,16 @@ existing member between plans.
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
@@ -91,7 +91,8 @@ Each numbered section below is one commit, and its items are that commit's parts
- [x] 8.4 Tests: the last-tier refusal under keep and under migrate; the limit refusal; the toast with no holders left; a migrate floor grant's `act_id`; `TestTierRemoval_*` for the guards that moved inside.
- [x] 8.5 Goldens: `remove-begin-fails.json`, `remove-second-pool-fails.json` (every failing step logged with `request_id`; the alerts read the reference form of copy 55), `remove-keep-rank-0.json`, `remove-migrate-pool-held-elsewhere.json`, `remove-migrate-rank-0.json` (the floor grant's `act_id`), `remove-other-source.json`, `remove-two-pools.json` (the act row), `remove-last-tier-keep.json` and `remove-last-tier-migrate.json` (refused with the last-tier sentence before any pool step), `remove-no-holders-left.json` ("{Product} removed from the ladder."), `remove-product-name-unread.json` (the act's billing querier refuses the name inside the transaction: refused, nothing changed), the four files under `removal-against-default/` (the removal's side of the rebuilt queues-first cases), `tier-change-during-conferral/a-tier-removal.json` (the removal's act rows, on top of task 6's change), and `contention.json` (the removal answers the new refusal). New: `remove-above-the-limit.json` and `remove-org-types-unread.json` (the act's querier refuses `ListOrgTypes` inside the transaction: the generic failure, the step logged with its request id, nothing changed). `remove-needs-disposition.json` and `remove-refused-disposition.json` do not move: the gate runs before the record and writes nothing.
- [x] 8.6 Docs: the removal's holder count, last-tier guard and ascending locks in the "Tier add, tier removal, default change and reorder" section of `docs/database-locks.md`, and the removal's holder limit and last-tier refusal in `docs/plan-management.md`.
- [x] 8.7 A removal certain to be refused, or whose outcome cannot be read, is not offered. Add `FormView.WayOutOnly` to the form library (the commit row with the way out alone; `CommitHidden` wins over it, it wins over `CommitState` and `CommitLink`). A refused removal preview shows its heading and the refusal (copy 15 with 16 or 18, or copy 21) in place of its outcome lines, its promoted-tier note and its disposition choice, and offers Discard alone, also when a 422 rebuild finds the refusal. The tier card renders rank 0's Remove or Preview removal disabled through the disabled-control part with copy 45 as its reason when the org types cannot be read, and, when it can read them, the only tier of a ladder an org type defaults to with copy 21 as its reason (`LastTierRefusal`); the other tiers' controls stay enabled. The reorder preview that cannot read the org types offers Discard alone instead of no commit row. Render tests for each state, the goldens these change, and `docs/plan-management.md`.
- [x] 8.7 A removal certain to be refused, or whose outcome cannot be read, is not offered. Add `FormView.WayOutOnly` to the form library (the commit row with the way out alone; `CommitHidden` wins over it, it wins over `CommitState` and `CommitLink`). A refused removal preview shows its heading and the refusal (copy 15 with 16 or 18, or copy 21) in place of its outcome lines, its promoted-tier note and its disposition choice, and offers Discard alone, also when a 422 rebuild finds the refusal. The tier card renders rank 0's Remove or Preview removal disabled through the disabled-control part with copy 45 as its reason when the org types cannot be read, and, when it can read them, the only tier of a ladder an org type defaults to with copy 21 as its reason (`LastTierRefusal`); the other tiers' controls stay enabled.
- [x] 8.8 The tier card decides every tier's removal with `TierRemovalRefusal`, reading every tier's counts in one statement (`CountLiveTierHoldersPerTier`): a tier over the holder limit renders Preview removal disabled with copy 15 (and 16 or 18) as its reason; a known refusal wins over copy 45; when the counts cannot be read, every held tier's Preview removal renders disabled with copy 52. Render tests for each, and `docs/plan-management.md`. The reorder preview that cannot read the org types offers Discard alone instead of no commit row. Render tests for each state, the goldens these change, and `docs/plan-management.md`.
## 9. Docs that describe the new structures