Offer no tier removal that is certain to be refused or cannot be read

A refused removal preview still asked for a disposition and showed the
Keep and Migrate choices beside a disabled "Apply change" whose reason
showed only in a tooltip. The tier card's rank 0 offered Remove while it
could not read the org types its last-tier guard needs, and the only
tier of a default ladder offered a Remove the act always refuses
(#187).

- The form library gains FormView.WayOutOnly, a commit row with the
  way out alone. CommitHidden still wins over it, and it wins over
  CommitState and CommitLink.
- A removal preview the holder limit or the last-tier guard refuses
  shows its heading and the refusal, with Discard alone, and so does a
  rejected submission whose rebuilt preview finds the refusal.
- The tier card renders rank 0's Remove or Preview removal disabled,
  through the disabled-control part, with the failed read as its
  reason when the org types cannot be read, and with the last-tier
  sentence when the ladder's only tier is an org type's default.
- The reorder preview that cannot read the org types rendered no
  commit row, so the pending order held the table with no Discard. It
  now offers Discard alone.

No sentence is new. Tests pin each state, the rebuild paths and the
precedence of the new field. The design, the plan-ladder-management,
plan-acts and form-library specs, docs/plan-management.md and
docs/design-system.md say the same.

The full suite passes.
This commit is contained in:
2026-10-20 03:59:41 -04:00
parent 93cb55454f
commit 95a863e05b
17 changed files with 424 additions and 71 deletions
+4 -1
View File
@@ -827,7 +827,10 @@ have carried, so it and the way out beside it are the same height. A render
may name an element outside the form that states what committing costs
(`FormView.CommitDescribedBy`, the Products page's billing line); the part
writes it as `aria-describedby` on the enabled commit or a link commit, and
never on a disabled one, which is described by its reason.
never on a disabled one, which is described by its reason. A preview whose
change cannot be applied, because it is certain to be refused or its outcome
could not be read, renders its way out alone (`FormView.WayOutOnly`): no
commit, enabled or disabled, so the operator is not offered what will fail.
### The section box hugs its fields
+2 -2
View File
@@ -103,7 +103,7 @@ Removing a tier with **no live holders** is a plain confirm-modal delete (the mo
The commit is **atomic**: tier deletion, renumbering, and every holder reconciliation run in one transaction — a failure rolls back everything, tier included, so no provision is ever left mismatched with its product's shape.
The removal decides inside that transaction, before it takes any pool lock, and the preview reads the same counts and renders "Apply change" disabled with the same sentence as its reason:
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 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.
@@ -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 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. 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.
@@ -7,8 +7,13 @@
holds the tier and how each position resolves. Context:
*server.PendingTierRemoval. Rendered as a fragment
(SafeTemplates.Fragment) by operator_plan_ladders.go, never called
directly by a page. */}}
directly by a page. A removal the act would refuse (.Refusal) shows
its heading and the refusal alone: no outcome lines, promoted-tier
note, rule-less warning or disposition, since nothing can be applied. */}}
<p class="small fw-bold mb-2">Removing {{ .ProductName }} from this ladder: preview, nothing removed yet.</p>
{{ if .Refusal }}
<p class="small mb-2">{{ .Refusal }}</p>
{{ else }}
{{ if .PromotedName }}
<p class="small mb-2"><strong>{{ .PromotedName }}</strong> becomes the rank-0 tier{{ if .PromotedForTypes }}, what new {{ range $i, $n := .PromotedForTypes }}{{ if $i }}, {{ end }}{{ $n }}{{ end }} organizations start on{{ end }}.</p>
{{ end }}
@@ -42,3 +47,4 @@
</li>
{{ end }}
</ul>
{{ end }}
@@ -15,7 +15,7 @@
{{ end }}
{{ with .RuleLessWarning }}
{{/* The rule-less warning (design.md M1): the pending order moves a product
whose set has no active rule to rank 0. The commit stays enabled. */}}
whose set has no active rule to rank 0. It does not disable the commit. */}}
<p class="small mb-2">{{ . }}</p>
{{ end }}
{{ if .OrgTypesError }}
@@ -90,6 +90,11 @@
(chrome-conventions "A verb-less row leaves its
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. */}}
{{ template "disabledControl" .RemoveDisabled }}
{{ else if gt .LiveHolders 0 }}
<button type="button" class="btn btn-outline-danger btn-sm ms-1"
hx-post="{{ routeURL "/partials/operator/plan-ladders/{ladderID}/tiers/{productID}/remove/preview" .PlanLadderID .ProductID }}"
@@ -246,9 +246,13 @@
the same height as the enabled one it stands in for, and so is a
commit that is a link out of the console (FormView.CommitLink). An
enabled commit or a link names FormView.CommitDescribedBy, when set,
in aria-describedby; a disabled commit keeps only its reason. */}}
in aria-describedby; a disabled commit keeps only its reason. A form
whose change cannot be applied (FormView.WayOutOnly) renders no commit
of any kind, and the way out alone; FormView.CommitHidden still
suppresses the whole row before this part runs. */}}
{{ define "formCommit" }}
{{ if .CommitState }}{{ template "disabledControl" .CommitControl }}
{{ if .WayOutOnly }}
{{ else if .CommitState }}{{ template "disabledControl" .CommitControl }}
{{ else if .CommitLink }}<a href="{{ .CommitLink.URL }}" class="{{ .CommitClasses }}" target="_blank" rel="noopener"{{ with .CommitDescribedBy }} aria-describedby="{{ . }}"{{ end }}>{{ .CommitLink.Label }}</a>
{{ else }}
<button type="submit" class="{{ .CommitClasses }}"{{ with .CommitDescribedBy }} aria-describedby="{{ . }}"{{ end }}><span class="spinner-border spinner-border-sm htmx-indicator me-1" aria-hidden="true"></span>{{ .Commit }}</button>
+1 -1
View File
@@ -317,7 +317,7 @@ func HolderLimitRefusal(act HolderLimitAct, holders, defaultSourced, limit int64
}
// TierRemovalRefusal is the guard of a tier removal: the refusal the act
// returns, and the preview states as the reason its commit is disabled, or nil.
// returns, and the preview states in place of its outcome, or nil.
// holders are the organizations holding productID's tier, defaultSourced how
// many of them hold it through an org type's default and onDefault how many of
// those hold it through a type whose default is this ladder; defaultingTypes are
+6
View File
@@ -579,6 +579,12 @@ type FormView struct {
// message (design D23; maintainer, 2026-09-04: Discard with nothing to
// discard).
CommitHidden bool
// WayOutOnly renders the commit row with the way out alone and no
// commit, for a Preview form whose change cannot be applied: the act
// is certain to refuse it, or the preview could not read its outcome.
// It wins over CommitState and CommitLink; CommitHidden wins over it,
// because that suppresses the whole row.
WayOutOnly bool
// Message is prose rendered inside the section box (Binding.Message's
// doc), before the fields, or after the field MessageAfter names.
Message template.HTML
+139 -2
View File
@@ -22,6 +22,7 @@ import (
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
@@ -386,8 +387,8 @@ func TestTierCardShowsTheOrgTypesFailureInPlaceOfItsBadges(t *testing.T) {
}
// The reorder preview shows the same sentence in place of its consequence panel
// and offers no commit: nothing it could apply is known.
func TestReorderPreviewShowsTheOrgTypesFailureAndOffersNoCommit(t *testing.T) {
// and offers Discard alone: nothing it could apply is known.
func TestReorderPreviewShowsTheOrgTypesFailureAndOffersDiscardAlone(t *testing.T) {
database := dbtest.Open(t)
f := newOtcFixture(t, database)
otcOrgTypeDefaultsToA(t, database, f)
@@ -409,12 +410,148 @@ func TestReorderPreviewShowsTheOrgTypesFailureAndOffersNoCommit(t *testing.T) {
}
}
form.None(t, `button[type="submit"]`)
form.None(t, `button[disabled]`)
form.None(t, `input[name="bucket2_disposition"]`)
if got := form.One(t, `button[hx-get]`).Text(); got != "Discard" {
t.Errorf("the preview's way out reads %q, want Discard", got)
}
if r := trRank(t, database, f.ladderA, f.prodA); r != 0 {
t.Errorf("prodA rank = %d after the preview, want 0 (read-only)", r)
}
}
// With the org types unread, the tier card disables rank 0's removal control
// through the disabled-control part, with the failure sentence and its request
// id as the reason, and leaves the other tiers' controls enabled. A tier with
// live holders reads Preview removal, one without Remove.
func TestTierCardDisablesRankZeroRemovalWhenTheOrgTypesAreUnread(t *testing.T) {
for _, tc := range []struct {
name string
holders bool
label string
}{
{"no holders", false, "Remove"},
{"live holders", true, "Preview removal"},
} {
t.Run(tc.name, func(t *testing.T) {
database := dbtest.Open(t)
f := newOtcFixture(t, database)
otcOrgTypeDefaultsToA(t, database, f)
trAddTier(t, database, f.ladderA, "tc-c-"+uuid.NewString()[:8])
if tc.holders {
_, pool := otcOrg(t, database, f, "TC Holder", true)
otcConferDefaultA(t, database, f, pool)
}
h := newOtcHarnessWith(t, database, f.operatorID, otcOptions{orgQ: orgTypesUnread{Querier: organization.New(database)}})
rec := h.postPath(h.handler.GetPlanLadderDetailPage, map[string]string{"ladderID": f.ladderA}, nil)
body := rec.Body.String()
id := referenceIn(t, body)
sentence := "Failed to read the org types that default to this ladder. Reference: " + id + "."
page := htmltest.Parse(t, body)
rows := page.All("tbody tr")
if len(rows) != 2 {
t.Fatalf("%d tier rows, want 2: %.500s", len(rows), body)
}
rank0 := rows[0]
if got := rank0.One(t, "button[disabled]").Text(); got != tc.label {
t.Errorf("rank 0's disabled control reads %q, want %q", got, tc.label)
}
if got := rank0.One(t, "#remove-tier-reason-"+f.prodA).Text(); got != sentence {
t.Errorf("rank 0's reason reads %q, want %q", got, sentence)
}
rows[1].None(t, "button[disabled]")
if got := rows[1].One(t, "button.btn-outline-danger").Text(); got != "Remove" {
t.Errorf("rank 1's control reads %q, want an enabled Remove", got)
}
})
}
}
// The tier card offers no removal that is certain to be refused: the only tier
// of a ladder an org type defaults to renders Remove, or Preview removal when
// organizations hold it, disabled with the last-tier sentence as the reason. The
// only tier of a ladder no org type defaults to, and rank 0 of a default ladder
// with another tier, keep an enabled control.
func TestTierCardDisablesTheOnlyTierOfADefaultLadder(t *testing.T) {
for _, tc := range []struct {
name string
holders bool
label string
}{
{"no holders", false, "Remove"},
{"live holders", true, "Preview removal"},
} {
t.Run(tc.name, func(t *testing.T) {
database := dbtest.Open(t)
f := newOtcFixture(t, database)
otcOrgTypeDefaultsToA(t, database, f)
if tc.holders {
_, pool := otcOrg(t, database, f, "TD Holder", true)
otcConferDefaultA(t, database, f, pool)
}
h := newOtcHarness(t, database, f.operatorID)
rec := h.postPath(h.handler.GetPlanLadderDetailPage, map[string]string{"ladderID": f.ladderA}, nil)
rows := htmltest.Parse(t, rec.Body.String()).All("tbody tr")
if len(rows) != 1 {
t.Fatalf("%d tier rows, want 1: %.500s", len(rows), rec.Body)
}
if got := rows[0].One(t, "button[disabled]").Text(); got != tc.label {
t.Errorf("the disabled control reads %q, want %q", got, tc.label)
}
want := entitlements.LastTierRefusal([]billing.PlanLadderTier{{ProductID: f.prodA}}, f.prodA, []string{f.displayName})
if want == "" {
t.Fatal("the fixture's only tier is not refused")
}
if got := rows[0].One(t, "#remove-tier-reason-"+f.prodA).Text(); got != want {
t.Errorf("the reason reads %q, want %q", got, want)
}
})
}
}
func TestTierCardKeepsRemovalEnabledWhenNothingRefusesIt(t *testing.T) {
t.Run("only tier of a ladder no type defaults to", func(t *testing.T) {
database := dbtest.Open(t)
f := newOtcFixture(t, database)
h := newOtcHarness(t, database, f.operatorID)
rec := h.postPath(h.handler.GetPlanLadderDetailPage, map[string]string{"ladderID": f.ladderA}, nil)
rows := htmltest.Parse(t, rec.Body.String()).All("tbody tr")
if len(rows) != 1 {
t.Fatalf("%d tier rows, want 1: %.500s", len(rows), rec.Body)
}
rows[0].None(t, "button[disabled]")
if got := rows[0].One(t, "button.btn-outline-danger").Text(); got != "Remove" {
t.Errorf("the control reads %q, want an enabled Remove", got)
}
})
t.Run("rank 0 of a default ladder with two tiers", func(t *testing.T) {
database := dbtest.Open(t)
f := newOtcFixture(t, database)
otcOrgTypeDefaultsToA(t, database, f)
trAddTier(t, database, f.ladderA, "te-c-"+uuid.NewString()[:8])
h := newOtcHarness(t, database, f.operatorID)
rec := h.postPath(h.handler.GetPlanLadderDetailPage, map[string]string{"ladderID": f.ladderA}, nil)
rows := htmltest.Parse(t, rec.Body.String()).All("tbody tr")
if len(rows) != 2 {
t.Fatalf("%d tier rows, want 2: %.500s", len(rows), rec.Body)
}
for i, row := range rows {
row.None(t, "button[disabled]")
if got := row.One(t, "button.btn-outline-danger").Text(); got != "Remove" {
t.Errorf("row %d's control reads %q, want an enabled Remove", i, got)
}
}
})
}
// A commit the gate refuses, whose rebuilt preview cannot read the org types,
// answers as the preview does: the sentence with the request id, no commit, and
// no false error about an outgoing default the rebuild lacks.
+51 -16
View File
@@ -71,6 +71,14 @@ 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 *DisabledControl
}
// PlanLaddersData holds data for the plan ladders list partial. It carries
@@ -189,8 +197,8 @@ type PendingTierRemoval struct {
// disables nothing under any disposition.
RuleLessWarning template.HTML
// Refusal is the sentence the act would refuse the removal with (the holder
// limit, the last tier of a default ladder); the preview renders "Apply
// change" disabled with it as the reason. Empty when the removal is not
// limit, the last tier of a default ladder); the preview states it in place of
// its outcome lines and offers Discard alone. Empty when the removal is not
// refused.
Refusal string
// Form is the removal preview's commit form (operator.plan-ladder.
@@ -621,7 +629,7 @@ const orgTypesUnreadSentence = "Failed to read the org types that default to thi
// reorderPreviewForm is the reorder preview's commit form for the pending
// order, bound to values and errs. A preview that could not read the org types
// has nothing to apply: it offers no commit.
// has nothing to apply: it offers Discard alone.
func (h *OperatorPartialsHandler) reorderPreviewForm(ladderID string, pending *PendingTierReorder, values forms.Values, errs *forms.Errors) forms.FormView {
ladderURL, err := web.RouteURL("/operator/plan-ladders/{ladderID}", ladderID)
if err != nil {
@@ -629,7 +637,7 @@ func (h *OperatorPartialsHandler) reorderPreviewForm(ladderID string, pending *P
}
view := planLadderTierReorderPreview(ladderID, ladderURL, values, errs,
h.Templates.Fragment("operator_plan_ladder_tier_reorder_message.html", pending))
view.CommitHidden = pending.OrgTypesError != ""
view.WayOutOnly = pending.OrgTypesError != ""
return view
}
@@ -892,7 +900,7 @@ const removalPreviewFailure = "Failed to preview the removal"
// vs other-source positions (align-shrunk, named, never force-ended), plus
// the promoted-rank-0 note when removing the top tier, and the reason the
// removal would be refused (TierRemovalRefusal, the act's own guard), which
// the preview states as the reason "Apply change" is disabled. Read-only. A
// the preview states as the refusal it shows in place of the outcome. Read-only. A
// (nil, "") return means the tier has no live holders — callers fall back to
// the plain modal-confirmed delete. A failed read is logged through the
// request's logger and returns the sentence that stands in place of the
@@ -985,22 +993,21 @@ func (h *OperatorPartialsHandler) buildTierRemovalPreview(ctx context.Context, l
// removalPreviewForm is the removal preview's commit form for the pending
// removal, bound to values and errs. A removal the act would refuse (the holder
// limit, the last tier of a default ladder) renders "Apply change" disabled
// through the disabled-control part, with the refusal as its reason.
// limit, the last tier of a default ladder) has nothing to apply: whatever the
// caller bound, the form is rebound in record mode with no errors, so no
// disposition radio shows and no field error sits under a hidden field, and it
// offers Discard alone. The message carries the refusal.
func (h *OperatorPartialsHandler) removalPreviewForm(ladderID string, pending *PendingTierRemoval, values forms.Values, errs *forms.Errors) forms.FormView {
ladderURL, err := web.RouteURL("/operator/plan-ladders/{ladderID}", ladderID)
if err != nil {
ladderURL = "/operator/plan-ladders"
}
if pending.Refusal != "" {
values, errs = planLadderTierRemovalValues(false, ""), nil
}
view := planLadderTierRemovalPreview(ladderID, pending.ProductID, ladderURL, values, errs,
h.Templates.Fragment("operator_plan_ladder_tier_removal_message.html", pending))
if pending.Refusal != "" {
view.CommitState = &forms.DisabledControlView{
ID: "apply-change-reason-removal",
Label: "Apply change",
Reason: pending.Refusal,
}
}
view.WayOutOnly = pending.Refusal != ""
return view
}
@@ -1025,6 +1032,8 @@ func (h *OperatorPartialsHandler) renderPlanLadderTiersRemoval(w http.ResponseWr
// the removal preview's commit form in submission mode: the chosen
// disposition carried back and the refusal under its field (design D9,
// plan-ladder-management "A refused commit keeps the chosen dispositions").
// A rebuilt preview that finds the removal refused renders the refused state
// instead (removalPreviewForm): the refusal, Discard alone, no field error.
func (h *OperatorPartialsHandler) renderPlanLadderTiersRemovalRefusal(w http.ResponseWriter, r *http.Request, ladderID string, pending *PendingTierRemoval, values forms.Values, errs *forms.Errors) {
w.WriteHeader(http.StatusUnprocessableEntity)
edit, ok := h.loadPlanLadderEditData(r, ladderID)
@@ -1045,7 +1054,8 @@ func (h *OperatorPartialsHandler) renderPlanLadderTiersRemovalRefusal(w http.Res
// POST /partials/operator/plan-ladders/{ladderID}/tiers/{productID}/remove/preview
// — the first half of removing a tier with live holders. Writes nothing: the
// tier card re-renders with the affected orgs classified by position source,
// disposition radios when default-sourced holders exist, and Commit / Discard.
// disposition radios when default-sourced holders exist, and Commit / Discard
// (Discard alone when the removal is refused).
func (h *OperatorPartialsHandler) PreviewPlanLadderTierRemoval(w http.ResponseWriter, r *http.Request) {
ladderID := r.PathValue("ladderID")
productID := r.PathValue("productID")
@@ -1137,7 +1147,9 @@ func (h *OperatorPartialsHandler) CommitPlanLadderTierRemoval(w http.ResponseWri
// read failure (logged once, with the request id, where it is read). A rebuilt
// preview with no default-sourced holder, because they left between the
// rollback and the rebuild, answers the generic failure and logs the gate, since
// the preview has no radio to choose a disposition with.
// the preview has no radio to choose a disposition with. A rebuilt preview
// that finds the removal refused renders the refused state, with no field
// error under the radios it does not show.
func (h *OperatorPartialsHandler) refuseUndecidedTierRemoval(w http.ResponseWriter, r *http.Request, ladderID, productID, disposition string, holders int) {
pending, errMsg := h.buildTierRemovalPreview(r.Context(), ladderID, productID)
if errMsg != "" {
@@ -1566,6 +1578,29 @@ func (h *OperatorPartialsHandler) loadPlanLadderTiersData(r *http.Request, ladde
LiveHolders: count.live,
}
}
planTiers := make([]billing.PlanLadderTier, len(data.Tiers))
for i, t := range data.Tiers {
planTiers[i] = billing.PlanLadderTier{ProductID: t.ProductID}
}
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"
if t.LiveHolders > 0 {
label = "Preview removal"
}
c := NewDisabledControl("remove-tier-reason-"+t.ProductID, label, "btn btn-outline-danger btn-sm ms-1", reason)
t.RemoveDisabled = &c
}
// design D8: the tiers table renders ranks ascending (rank 0 first),
// matching the topology grid; ListTiersByLadderWithProducts already
// returns rank ASC, so no reordering is needed for display.
@@ -5,8 +5,8 @@ package server_test
// The tier removal's guards and its preview (openspec change
// plan-act-obligations, task 8): the holder limit and the last tier of a
// default ladder refuse the commit inside the act and disable "Apply change"
// in the preview with the same sentence as the reason; a preview that cannot
// default ladder refuse the commit inside the act, and the preview gives the
// same sentence and offers Discard alone; a preview that cannot
// read what a guard needs says so, with the request's id, in place of the
// preview; and the answer of a removal with no holder left names no positions.
@@ -69,6 +69,15 @@ func (q holdersListUndefaulted) ListLiveTierHoldersByLadderProduct(ctx context.C
return rows, err
}
// holdersCountInflated answers the preview's holder count as above any limit.
type holdersCountInflated struct{ entitlements.Querier }
func (q holdersCountInflated) CountLiveTierHolders(ctx context.Context, arg entitlements.CountLiveTierHoldersParams) (entitlements.CountLiveTierHoldersRow, error) {
row, err := q.Querier.CountLiveTierHolders(ctx, arg)
row.Holders += 1 << 40
return row, err
}
type tiersWithProductsUnread struct{ billing.Querier }
func (tiersWithProductsUnread) ListTiersByLadderWithProducts(context.Context, string) ([]billing.ListTiersByLadderWithProductsRow, error) {
@@ -127,10 +136,29 @@ func (r removalWorld) acts(t *testing.T) int {
return otcScalar(t, r.w.DB, `SELECT count(*) FROM core.plan_act_details WHERE plan_ladder_id = $1`, r.main.ID)
}
// A preview of a removal the act would refuse for the holder limit renders
// "Apply change" disabled, through the disabled-control part, with the limit's
// sentence as its reason, and renders no submit button.
func TestTierRemoval_PreviewDisablesApplyChangeAboveTheHolderLimit(t *testing.T) {
// requireRefusedPreview asserts the refused state of a removal preview: the
// refusal in the message in place of its outcome lines and promoted-tier note,
// Discard alone, no commit of any kind and no disposition radio.
func requireRefusedPreview(t *testing.T, form *htmltest.Node, want string) {
t.Helper()
form.None(t, `button[type="submit"]`)
form.None(t, "button[disabled]")
form.None(t, `input[name="default_disposition"]`)
form.None(t, ".invalid-feedback")
if got := form.Text(); strings.Contains(got, "Apply change") || !strings.Contains(got, "Discard") || !strings.Contains(got, want) {
t.Errorf("the refused preview reads %q, want the refusal %q and Discard alone", got, want)
}
for _, hidden := range []string{"choose their disposition below", "becomes the rank-0 tier"} {
if strings.Contains(form.Text(), hidden) {
t.Errorf("the refused preview still reads %q beside the refusal", hidden)
}
}
}
// A preview of a removal the act would refuse for the holder limit gives the
// limit's sentence in its message, no commit and no disposition choice, and
// offers Discard alone.
func TestTierRemoval_PreviewOffersDiscardAloneAboveTheHolderLimit(t *testing.T) {
r := newRemovalWorld(t, true)
h := newOtcHarnessActs(t, r.w.DB, r.operator.ID, entitlements.PlanActConfig{HolderCap: 1})
@@ -139,31 +167,52 @@ func TestTierRemoval_PreviewDisablesApplyChangeAboveTheHolderLimit(t *testing.T)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
form := htmltest.Parse(t, rec.Body.String()).One(t, removalPreviewForm)
form.None(t, `button[type="submit"]`)
if got := form.One(t, "button[disabled]").Text(); got != "Apply change" {
t.Errorf("the disabled control reads %q, want Apply change", got)
}
if got := form.One(t, "#apply-change-reason-removal").Text(); got != removalLimitSentence {
t.Errorf("the reason reads %q, want %q", got, removalLimitSentence)
}
requireRefusedPreview(t, htmltest.Parse(t, rec.Body.String()).One(t, removalPreviewForm), removalLimitSentence)
}
// A preview of the only tier of a default ladder renders the same control with
// the last-tier sentence, the guard the commit applies under either
// disposition.
func TestTierRemoval_PreviewDisablesApplyChangeForTheLastTierOfADefaultLadder(t *testing.T) {
// A preview of the only tier of a default ladder, whose holders are all
// default-sourced, gives the last-tier sentence the commit applies under either
// disposition, and shows no radio though the holders would need a disposition.
func TestTierRemoval_PreviewOffersDiscardAloneForTheLastTierOfADefaultLadder(t *testing.T) {
r := newRemovalWorld(t, false)
h := newOtcHarness(t, r.w.DB, r.operator.ID)
rec := r.preview(h, r.basic)
form := htmltest.Parse(t, rec.Body.String()).One(t, removalPreviewForm)
form.None(t, `button[type="submit"]`)
want := entitlements.LastTierRefusal([]billing.PlanLadderTier{{ProductID: r.basic.ID}}, r.basic.ID, []string{r.orgType})
if got := form.One(t, "#apply-change-reason-removal").Text(); got != want || want == "" {
t.Errorf("the reason reads %q, want %q", got, want)
if want == "" {
t.Fatal("the fixture's last tier is not refused")
}
requireRefusedPreview(t, htmltest.Parse(t, rec.Body.String()).One(t, removalPreviewForm), want)
}
// A submission without a disposition, while the removal is refused, rebuilds
// the preview at 422 in its refused state: the refusal and Discard alone, with
// no field error under the radios it does not show.
func TestTierRemoval_ARebuiltPreviewThatFindsTheRefusalRendersItWithoutAFieldError(t *testing.T) {
t.Run("holder limit", func(t *testing.T) {
r := newRemovalWorld(t, true)
h := newOtcHarnessActs(t, r.w.DB, r.operator.ID, entitlements.PlanActConfig{HolderCap: 1})
rec := h.postPath(h.handler.CommitPlanLadderTierRemoval, map[string]string{"ladderID": r.main.ID, "productID": r.basic.ID}, url.Values{"needs_disposition": {"true"}})
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("status = %d, want 422", rec.Code)
}
requireRefusedPreview(t, htmltest.Parse(t, rec.Body.String()).One(t, removalPreviewForm), removalLimitSentence)
})
t.Run("last tier", func(t *testing.T) {
r := newRemovalWorld(t, false)
h := newOtcHarness(t, r.w.DB, r.operator.ID)
rec := h.postPath(h.handler.CommitPlanLadderTierRemoval, map[string]string{"ladderID": r.main.ID, "productID": r.basic.ID}, url.Values{"needs_disposition": {"true"}})
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("status = %d, want 422", rec.Code)
}
want := entitlements.LastTierRefusal([]billing.PlanLadderTier{{ProductID: r.basic.ID}}, r.basic.ID, []string{r.orgType})
requireRefusedPreview(t, htmltest.Parse(t, rec.Body.String()).One(t, removalPreviewForm), want)
})
}
// A removal nothing refuses keeps its enabled "Apply change".
@@ -177,7 +226,16 @@ func TestTierRemoval_PreviewKeepsApplyChangeWhenNothingRefuses(t *testing.T) {
if got := form.One(t, `button[type="submit"]`).Text(); got != "Apply change" {
t.Errorf("the commit reads %q, want Apply change", got)
}
form.None(t, "#apply-change-reason-removal")
if form.Count(`input[name="default_disposition"]`) == 0 {
t.Error("the unrefused preview must offer the disposition radios")
}
// The same fixture, refused, must hide these lines (requireRefusedPreview),
// so its unrefused preview has to show them for that check to be able to fail.
for _, shown := range []string{"choose their disposition below", "becomes the rank-0 tier"} {
if !strings.Contains(form.Text(), shown) {
t.Errorf("the unrefused preview lacks %q", shown)
}
}
}
// A preview that cannot read what it needs shows the failure sentence with the
@@ -229,7 +287,7 @@ func TestTierRemoval_PreviewShowsAFailedReadInPlaceOfThePreview(t *testing.T) {
// The commit applies the same guards inside the act: above the limit, and the
// last tier of a default ladder under keep as under migrate, it answers the
// sentence the preview gave and changes nothing.
func TestTierRemoval_CommitRefusesWhatThePreviewDisables(t *testing.T) {
func TestTierRemoval_CommitRefusesWhatThePreviewRefuses(t *testing.T) {
t.Run("holder limit", func(t *testing.T) {
r := newRemovalWorld(t, true)
h := newOtcHarnessActs(t, r.w.DB, r.operator.ID, entitlements.PlanActConfig{HolderCap: 1})
@@ -350,6 +408,26 @@ func TestTierRemoval_ARebuiltPreviewThatCannotReadAnswersItsOwnFailure(t *testin
}
}
// A removal that found default-sourced holders and no disposition (the act's
// gate, which sees the real count), whose rebuilt preview finds the removal
// refused (its count is above the limit), answers 422 with the refused state:
// the holder-limit sentence and Discard alone, no field error, and nothing
// changed.
func TestTierRemoval_AGateWhoseRebuiltPreviewIsRefusedRendersTheRefusedState(t *testing.T) {
r := newRemovalWorld(t, true)
h := newOtcHarnessWith(t, r.w.DB, r.operator.ID, otcOptions{entitlementsQ: holdersCountInflated{Querier: entitlements.New(r.w.DB)}})
rec := h.postPath(h.handler.CommitPlanLadderTierRemoval, map[string]string{"ladderID": r.main.ID, "productID": r.basic.ID}, nil)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("status = %d, want 422: %.500s", rec.Code, rec.Body)
}
requireRefusedPreview(t, htmltest.Parse(t, rec.Body.String()).One(t, removalPreviewForm), "organizations hold this tier; a removal is refused above")
if !r.tierExists(t, r.basic) || r.acts(t) != 0 {
t.Errorf("the refused removal changed the ladder: tier present %v, %d acts", r.tierExists(t, r.basic), r.acts(t))
}
}
// A removal that found default-sourced holders and no disposition, whose
// rebuilt preview finds none because they left between the rollback and the
// rebuild, answers the generic failure and logs the gate with the ladder, the
@@ -593,3 +593,52 @@ func TestFormPartCommitDescribedBy(t *testing.T) {
t.Errorf("an unset CommitDescribedBy must write nothing:\n%s", out)
}
}
// A preview whose change cannot be applied (FormView.WayOutOnly) renders the
// way out alone: no submit, no disabled control, whatever commit the view
// would otherwise carry. CommitHidden still suppresses the whole row.
func TestFormPartWayOutOnlyRendersTheWayOutAlone(t *testing.T) {
spec := forms.FormSpec{
Name: "test.way-out-only", Kind: forms.KindPreview, Family: forms.Stacked,
Method: "POST", Path: "/partials/test", Commit: "Apply change",
WayOut: forms.Discard("Discard", "/partials/test"),
Fields: []forms.Field{{Name: "candidate", Label: "Candidate", Control: forms.Text}},
}
render := func(mutate func(*forms.FormView)) string {
view := forms.Render(spec, forms.Binding{Mode: forms.ModeRecord, Message: template.HTML("<p>Refused.</p>")})
mutate(&view)
return renderFormPart(t, view)
}
out := render(func(v *forms.FormView) { v.WayOutOnly = true })
if !strings.Contains(out, ">Discard</button>") {
t.Errorf("the way out must render:\n%s", out)
}
if strings.Contains(out, `type="submit"`) || strings.Contains(out, "disabled") || strings.Contains(out, "Apply change") {
t.Errorf("no commit of any kind may render:\n%s", out)
}
out = render(func(v *forms.FormView) {
v.WayOutOnly = true
v.CommitState = &forms.DisabledControlView{ID: "why-not", Label: "Apply change", Reason: "Refused."}
})
if !strings.Contains(out, ">Discard</button>") || strings.Contains(out, "disabled") || strings.Contains(out, "why-not") {
t.Errorf("WayOutOnly must win over CommitState:\n%s", out)
}
out = render(func(v *forms.FormView) {
v.WayOutOnly = true
v.CommitLink = &forms.CommitLinkView{Label: "Approve payment", URL: "https://invoice.stripe.com/i/test"}
})
if !strings.Contains(out, ">Discard</button>") || strings.Contains(out, "Approve payment") || strings.Contains(out, "invoice.stripe.com") {
t.Errorf("WayOutOnly must win over CommitLink:\n%s", out)
}
out = render(func(v *forms.FormView) {
v.WayOutOnly = true
v.CommitHidden = true
})
if strings.Contains(out, "Discard") || strings.Contains(out, `type="submit"`) {
t.Errorf("CommitHidden must suppress the whole row:\n%s", out)
}
}
@@ -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 and renders "Apply change" disabled with the sentence as its reason, as the destructive-controls requirement asks of an action 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. 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 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.
@@ -642,7 +642,7 @@ Each table lists what is committed, what the operator sees and how the act is ru
| Tier list read fails | nothing | "Failed to remove tier. Reference: {request id}."; logged | resubmit |
| The product is no longer a tier of the ladder (a repeated delete) | nothing: no act, no renumber | the existing success answer | n/a |
| Org types read fails | nothing (the guard fails closed) | "Failed to remove tier. Reference: {request id}."; logged | resubmit |
| Last tier of a ladder an org type defaults to | nothing | the existing last-tier refusal | add another tier first |
| Last tier of a ladder an org type defaults to | nothing | the existing last-tier refusal; the tier card renders Remove disabled with it as its reason | add another tier first |
| Act, delete, renumber or commit: lock wait or deadlock | nothing | the contention refusal | resubmit |
| Act, delete, renumber or commit: other error | nothing | "Failed to remove tier. Reference: {request id}."; logged | resubmit |
@@ -655,8 +655,8 @@ 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 "Apply change" disabled | 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 "Apply change" disabled | add another tier first |
| 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 |
| 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 |
| The 422 preview cannot be rebuilt after the rollback | nothing | the preview's own read failure: "Failed to load the tier's holders. Reference: {request id}." (copy 52) or copy 14; logged with the request id | resubmit |
@@ -759,7 +759,7 @@ Each table lists what is committed, what the operator sees and how the act is ru
| Failure path | What is committed | What the operator sees | How it is re-run |
|---|---|---|---|
| The open acts or their unmoved organizations cannot be read | nothing | "Pending plan changes could not be read. Reference: {request id}." in place of the lines; logged with the request id | reload |
| The org types that default to a ladder cannot be read (the tier card, the reorder preview) | nothing | "Failed to read the org types that default to this ladder. Reference: {request id}." in place of the badges and of the preview's consequence panel; logged with the request id | reload |
| The org types that default to a ladder cannot be read (the tier card, the reorder preview) | nothing | "Failed to read the org types that default to this ladder. Reference: {request id}." in place of the badges and of the preview's consequence panel; rank 0's Remove or Preview removal renders disabled with it as its reason, and the reorder preview offers Discard alone; logged with the request id | reload |
## Risks / Trade-offs
@@ -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 removal preview's disabled "Apply change" reason | |
| 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 |
| 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 | |
@@ -811,7 +811,7 @@ Every sentence a person reads that this change adds, changes, reuses or removes.
| 20 | summary part "N without a default pool" | the same toasts; counts the organizations whose record the act holds when the request answers, an organization that also holds an obligation of the act counting once, as an obligation | "Where" reworded to the specs' count |
| 43 | "Cannot add this tier; {Organization} already holds {Product} on this ladder." | the tier-add form's product field error when a pool among the product's holders already holds a live position on the ladder; the names are read under the add's pool locks, before any alignment | found before the alignment, inside the add's transaction, instead of read after the rollback |
| 44 | withdrawn | was the fallback for a colliding position that could not be read after the rollback | withdrawn: the collision is read inside the add's transaction, so its names are always read with it |
| 45 | "Failed to read the org types that default to this ladder. Reference: {request id}." | the tier card in place of the default-ladder badges, and the reorder preview in place of its consequence panel, when `ListOrgTypes` fails; the logged line carries the request id | |
| 45 | "Failed to read the org types that default to this ladder. Reference: {request id}." | the tier card in place of the default-ladder badges, the reason of rank 0's disabled Remove or Preview removal, and the reorder preview in place of its consequence panel (which then offers Discard alone), when `ListOrgTypes` fails; the logged line carries the request id | "Where" extended: the disabled Remove |
| 48 | "Cannot add this tier; {Organization} holds {Product} more than once, and an organization holds one position per ladder." | the tier-add form's product field error when a pool holds more than one live provision of the product being added, so the add would have to place both on the ladder | new |
| 62 | "Default change committed for {type}." and "{Product} is now the default plan for new {types} organizations." | the default change's and the reorder's success toasts when none of their counts could be read and nothing settled: the opening of the toast of copies 19 and 20 with its parts left out | added with tasks 6 and 7; approved |
@@ -819,7 +819,7 @@ Every sentence a person reads that this change adds, changes, reuses or removes.
| # | Sentence | New place | Since the previous draft |
|---|---|---|---|
| 21 | "This ladder's only remaining tier; {types} defaults new signups to it. Deleting it would leave those signups without a starting plan, so the removal is refused." | the removal's commit refusal, and the removal preview's disabled "Apply change" reason, under either disposition | |
| 21 | "This ladder's only remaining tier; {types} defaults new signups to it. Deleting it would leave those signups without a starting plan, so the removal is refused." | the plain delete's commit refusal, the removal's under either disposition, the refused removal preview, in place of its outcome lines and disposition choice, with Discard alone, and the reason of the tier card's disabled Remove or Preview removal on that tier | "Where" changed: Discard alone, the disabled Remove |
| 22 | "Already the default." | the default change's card error when a concurrent commit saved the same default first | |
| 23 | "Another change collided with this one; try again." | tier add, tier delete, tier removal, the reorder, the default change's card and Retry, and the rule change's Apply changes, on a lock wait past the timeout or a deadlock (#216); today the tier add's field error for a rank collision | |
| 24 | withdrawn | was the tier add's "Failed to add tier. Reference: {request id}." on its new failure paths | withdrawn: the tier add answers copy 50 on every failure path |
@@ -1,3 +1,14 @@
## ADDED Requirements
### Requirement: A preview with nothing to apply offers only its way out
A render SHALL be able to set `FormView.WayOutOnly` on a Preview-family form whose change cannot be applied: one the act is certain to refuse, or one whose outcome the preview could not read. The `form` part SHALL then render the commit row with the way out alone and no commit, enabled or disabled, so the pending preview can be discarded and nothing is offered that can only fail. `CommitHidden` SHALL win over `WayOutOnly`, and `WayOutOnly` SHALL win over `CommitState` and `CommitLink`.
#### Scenario: A refused preview offers Discard alone
- **WHEN** a removal preview renders for a removal the act would refuse
- **THEN** its commit row holds "Discard" and no "Apply change"
## MODIFIED Requirements
### Requirement: A preview at rest renders no commit row
@@ -246,7 +246,7 @@ Every log line written by a listing batch, a move, the failure recorder and the
### Requirement: Tier add and tier removal refuse above a measured holder limit
A tier add and a tier removal SHALL each stay one transaction that reconciles every holder with the tier change. Inside that transaction, before taking any pool row, each SHALL count its holders, the organizations holding a live (active or suspended) position on the tier or a live provision of the product being added, and how many of them hold it through a live default-sourced position on a ladder, and SHALL refuse, writing nothing, when the count exceeds a limit set from a timed run of their per-holder cost, recorded beside the constant with its measurement. The refusal SHALL name the count and the limit, and when more than half of the holders hold it through an org type's default it SHALL add that changing that default with Migrate moves them in the background, and, for a removal of the rank-0 tier of a ladder their type defaults to, that reordering this ladder with Migrate does too. The comparison with the limit, the more-than-half rule and the last-tier guard SHALL each be one function that reads no row, called by the preview and by the act. The removal's preview SHALL read the same counts and render its commit disabled with the same sentence as its reason.
A tier add and a tier removal SHALL each stay one transaction that reconciles every holder with the tier change. Inside that transaction, before taking any pool row, each SHALL count its holders, the organizations holding a live (active or suspended) position on the tier or a live provision of the product being added, and how many of them hold it through a live default-sourced position on a ladder, and SHALL refuse, writing nothing, when the count exceeds a limit set from a timed run of their per-holder cost, recorded beside the constant with its measurement. The refusal SHALL name the count and the limit, and when more than half of the holders hold it through an org type's default it SHALL add that changing that default with Migrate moves them in the background, and, for a removal of the rank-0 tier of a ladder their type defaults to, that reordering this ladder with Migrate does too. The comparison with the limit, the more-than-half rule and the last-tier guard SHALL each be one function that reads no row, called by the preview and by the act. The removal's preview SHALL read the same counts and, when either rule refuses, show the same sentence in place of its outcome lines and its disposition choice and offer Discard alone.
#### Scenario: A removal above the limit is refused before anything changes
@@ -267,7 +267,7 @@ A tier add and a tier removal SHALL each stay one transaction that reconciles ev
#### Scenario: The preview states the limit
- **WHEN** an operator previews the removal of a tier more organizations hold than the limit
- **THEN** "Apply change" renders disabled with the limit sentence as its reason
- **THEN** the preview shows the limit sentence and offers Discard alone, with no "Apply change"
### Requirement: Plan acts take their locks in one order
@@ -62,7 +62,7 @@ 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 no commit, and SHALL NOT
{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
@@ -107,14 +107,17 @@ 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 render its commit disabled with the refusal as its reason
(`plan-acts`). A removal, with or without holders, of the last tier of a ladder an
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 render
its commit disabled with that refusal as its reason. A removal preview that cannot
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 removed tier is
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
@@ -149,7 +152,7 @@ existing member between plans.
#### 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 no Commit action
- **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
@@ -267,9 +270,18 @@ existing member between plans.
- **WHEN** an operator previews and then commits the removal of a tier that more
organizations hold than the holder limit
- **THEN** the preview SHALL render "Apply change" disabled with the limit as its
reason, and the commit SHALL be refused with the tier, its ranks and every
position unchanged
- **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: A tier add above the holder limit is refused
@@ -377,18 +389,24 @@ attaches to a product whose set is empty is unfinished rather than stranded.
### Requirement: The last tier of a live default ladder cannot be deleted
Deleting a plan ladder tier, whether by the plain delete of a tier no organization holds or by the removal of a tier with holders under either disposition, SHALL be refused when it is the ladder's only remaining tier AND any org type's default plan ladder references that ladder, with copy explaining that new signups land on this ladder's bottom tier. The guard, and the plain delete's count of live holders, SHALL be read inside the transaction that would delete the tier, and a failed read of the org types SHALL refuse the deletion rather than allow it. Deleting the last tier of a ladder no org type defaults to SHALL remain allowed, because emptying a non-default ladder to rebuild it is a legitimate workflow.
Deleting a plan ladder tier, whether by the plain delete of a tier no organization holds or by the removal of a tier with holders under either disposition, SHALL be refused when it is the ladder's only remaining tier AND any org type's default plan ladder references that ladder, with copy explaining that new signups land on this ladder's bottom tier. The guard, and the plain delete's count of live holders, SHALL be read inside the transaction that would delete the tier, and a failed read of the org types SHALL refuse the deletion rather than allow it. The tier card SHALL render that tier's Remove or Preview removal disabled with the explanation as its reason, because the deletion is certain to be refused. Deleting the last tier of a ladder no org type defaults to SHALL remain allowed, because emptying a non-default ladder to rebuild it is a legitimate workflow.
#### Scenario: Last tier of the signup default is protected
- **WHEN** an operator attempts to delete the only tier of a ladder that is some org type's default plan ladder
- **THEN** the deletion is refused with an explanation naming the org type dependency
- **AND** the tier card renders that tier's Remove or Preview removal disabled with the same explanation as its reason
#### Scenario: A removal with holders cannot empty a default ladder
- **WHEN** an operator commits the removal, with `keep` or with `migrate`, of the only tier of a ladder an org type defaults to while organizations hold it
- **THEN** the removal is refused with the same explanation, and the tier and every position are unchanged
#### Scenario: The removal preview of a default ladder's last tier offers Discard alone
- **WHEN** an operator previews the removal of the only tier of a ladder an org type defaults to while organizations hold it
- **THEN** the preview shows the explanation in place of its outcome lines and its disposition choice, with Discard and no "Apply change"
#### Scenario: A failed read of the org types refuses the delete
- **WHEN** an operator deletes the only tier of a ladder and the org types cannot be read inside the delete's transaction
@@ -91,6 +91,7 @@ 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`.
## 9. Docs that describe the new structures