Files
member-console/internal/server/operator_plan_ladders.go
T
cgalo5758 3727ff31d8 Add entitlement set rule change ledger and preview flow
Add an append-only ledger of entitlement set rule changes with per-pool
effect rows, a preview-and-commit rule change flow, and an automatic
drain that settles deferred recomputations. Rules gain a tier reduction
policy, resource keys declare over-limit behavior, and the materializer
now lowers limits when a rule stops applying.
Add entitlement set rule change ledger and preview flow

Add an append-only ledger of entitlement set rule changes with a
preview-and-commit operator flow. Rule writes now go through an enclosed
`core.commit_rule_change` function that files an act row and one
obligation per carrying pool, with a drain workflow settling deferred
recomputations. The preview dry-runs the materializer with a rule
overlay and renders per-pool buckets, reduction-policy disclosures, and
provider over-limit consequences. Materializing transactions take a
shared advisory rendezvous that rule changes hold exclusively, enforced
by a possession assertion. Add History and Entitlement changes surfaces,
a rule-less warning on five product-selection surfaces, and a
`tier_reduction_policy` column that gates FedWiki parking.
2026-09-15 03:53:28 -05:00

1696 lines
72 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"context"
"database/sql"
"errors"
"fmt"
"html/template"
"log/slog"
"net/http"
"sort"
"strconv"
"strings"
"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/forms"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
)
// PlanLadderViewModel represents a plan ladder for operator template rendering.
type PlanLadderViewModel struct {
PlanLadderID string
Name string
Description string
IsActive bool
// Key is the ladder's declarative address (entity-keys §5), shown
// read-only on the detail page when set and omitted entirely when NULL.
// The create form does not collect one, so most ladders have none; the
// ones that do were named by a seed or a configuration file, and an
// operator needs to see the string those name before renaming anything.
Key string
TierCount int
ActiveAttachmentCount int64
}
// DeleteControl builds the disabled "Delete" control and its reason for a
// ladder that still has organizations holding a position on it (design D10,
// ACC-8: the one shared disabled-control idiom, `ui_disabled_control.html`,
// instead of a hand-rolled `<span title="...">` wrapper). Call only from the
// ActiveAttachmentCount > 0 branch; the zero-attachment branch renders its
// own enabled delete button. ID is scoped by PlanLadderID so a page listing
// several ladders never collides on the aria-describedby target.
func (d PlanLadderViewModel) DeleteControl() DisabledControl {
return NewDisabledControl(
"delete-ladder-reason-"+d.PlanLadderID,
"Delete",
"btn btn-outline-danger btn-sm",
fmt.Sprintf("%d organization(s) hold a position; deletion is blocked until none remain.", d.ActiveAttachmentCount),
)
}
// PlanLadderTierViewModel represents a tier within a ladder.
type PlanLadderTierViewModel struct {
PlanLadderID string
ProductID string
ProductName string
Rank int32
HasActiveAttachments bool
// ActiveAttachmentCount is the live-holder count backing
// HasActiveAttachments, rendered inline beside the Remove control so its
// consequence is stated before any confirmation step.
ActiveAttachmentCount int64
}
// PlanLaddersData holds data for the plan ladders list partial. It carries
// no FieldErrors: creation moved to its own page (design D20, round 4;
// PlanLadderNewData), so no form on this page can fail validation.
type PlanLaddersData struct {
Ladders []PlanLadderViewModel
Success string
Error string
// Topology is the cross-ladder map (grid + structural-health strip)
// rendered at the top of the page (ux-ia-naming 2.2: plan ladders opens
// map-first). Hydrated by the same loader the formerly-standalone
// /operator/plan-topology page used (loadPlanTopologyData) so every
// state — issues / healthy / nothing-to-validate — behaves unchanged at
// its new home. See operator_plan_ladders.html, which includes
// operator_plan_topology.html as a partial against this field.
Topology PlanTopologyData
// LaddersNav is the ladder list's operator-list-scale governance (design
// D8 / plan-ladder-management "The ladder list is a scaffolded
// section"): search over name, true-total paging. No paged SQL query
// exists for plan ladders (a small catalog list, unlike persons or
// organizations), so loadPlanLaddersPageData filters and pages the
// already-loaded set in Go.
LaddersNav ListNav
}
// LaddersListHeader titles the ladder list section beneath the topology
// map. Defined here rather than anatomy.go (lane A's file) because it is a
// new section, not a change to an existing one.
func (d PlanLaddersData) LaddersListHeader() SectionHeader {
h := SectionHeader{
Title: "Ladders",
// design D20 (round 4) "record creation gets its own page": the
// action is this page's one filled call to action, a link to
// /operator/plan-ladders/new.
Action: &Link{Label: "New plan ladder", URL: "/operator/plan-ladders/new", Filled: true},
}
if d.LaddersNav.Total > 0 {
h.Count = countLabel(int(d.LaddersNav.Total), "ladder", "ladders")
}
return h
}
// PlanLadderEditData holds data for the plan ladder edit form.
type PlanLadderEditData struct {
Ladder PlanLadderViewModel
// Form is the ladder declaration rendered on its edit side, bound to
// this record or to a refused submission of it (form-library).
Form forms.FormView
Success string
Error string
}
// ProductOption represents a product for dropdown selection on the plan ladders page.
type ProductOption struct {
ProductID string
Name string
}
// PlanLadderTiersData holds data for the tier management partial.
type PlanLadderTiersData struct {
Ladder PlanLadderViewModel
Tiers []PlanLadderTierViewModel
AvailableProducts []ProductOption
// TierForm is the tier-add declaration (operator.plan-ladder.tier.
// add), bound to whatever is currently chosen: empty on a fresh
// visit, the submitted value on a refusal (design D9). Its own
// HasErrors() reopens the Add tier panel on a refusal (design D20).
TierForm forms.FormView
Success string
Error string
// DefaultForOrgTypeNames is the display names of org types whose configured
// default plan ladder is this one. When non-empty, new organizations of
// those types are provisioned onto this ladder's rank-0 tier — so the
// rank-0 row is badged accordingly. Empty means this ladder is no org
// type's default, and rank 0 is only the ladder's base tier (provisions
// no one). See orgTypesDefaultingToLadder.
DefaultForOrgTypeNames []string
// Pending is the server-rendered preview of a dropped-but-uncommitted tier
// order; nil when no reorder is in flight. See PendingTierReorder.
Pending *PendingTierReorder
// RemovalPending is the server-rendered preview of removing a tier that
// has live holders; nil when no removal is in flight. The card renders one
// pending state at a time. See PendingTierRemoval.
RemovalPending *PendingTierRemoval
}
// PendingTierRemoval is the server-rendered preview of removing a tier with
// live holders — nothing is written until the operator commits. Other-source
// positions always align-shrink (junction ends, delivery continues);
// default-sourced holders require a disposition (keep | migrate), because a
// kept off-ladder default cannot block the floor and would later stack with
// the current type default — the preview says so instead of letting the
// operator discover it months later.
type PendingTierRemoval struct {
ProductID string
ProductName string
DefaultSourced []OrgRef
OtherSource []OrgSourceRef
NeedsDisposition bool
// PromotedName is set when the removed tier is rank 0 with a successor:
// the product promoted to the new rank 0. PromotedForTypes names the org
// types whose new-org default that promotion changes (empty when the
// ladder is no type's default).
PromotedName string
PromotedForTypes []string
// RuleLessWarning names the promoted rank-0 product when its entitlement
// set carries no active rule (plan-ladder-management "A ladder preview
// that promotes a rank-0 product names a set with no active rule"). It
// sits beside the promoted-rank-0 note rather than replacing it, and
// disables nothing under any disposition.
RuleLessWarning template.HTML
// Form is the removal preview's commit form (operator.plan-ladder.
// tier.remove.commit), the preview idiom's own render (design D10;
// finding FA-13).
Form forms.FormView
}
// orgTypeRef names an org type (key + display name) whose default plan ladder
// is the ladder under edit: the tier view badges rank 0 with the display
// names, and the reorder preview/commit classify each type's population by
// key.
type orgTypeRef struct {
OrgType string
DisplayName string
}
// TierReorderTypePreview pairs one org type whose default is this ladder with
// its default-change classification against the pending rank-0 product.
type TierReorderTypePreview struct {
OrgType string
DisplayName string
Preview *OrgTypeChangePreview
}
// PendingTierReorder is the server-rendered preview of a dropped tier order —
// the drop itself writes nothing. Rank 0 is what an org type default resolves
// to at use-time, so when the pending order changes rank 0 of a ladder that is
// some org type's default, TypePreviews classifies each type's population
// (same classifier as the org-types card) and the commit requires a bucket-2
// disposition (NeedsDisposition). Pending state lives in the rendered card
// (the commit form re-submits OrderIDs), never browser-only.
type PendingTierReorder struct {
OrderIDs []string // pending product order, rank 0 first
Order []PlanLadderTierViewModel // OrderIDs hydrated with names/flags and pending ranks
Stale bool // submission reconciled against a changed ladder
RankZeroChanged bool
OldRankZeroName string
NewRankZeroName string
// RuleLessWarning names the product the pending order moves to rank 0
// when its entitlement set carries no active rule. A warning only: the
// commit stays enabled.
RuleLessWarning template.HTML
TypePreviews []TierReorderTypePreview
NeedsDisposition bool // some type's outgoing-default bucket is non-empty
// Form is the reorder preview's commit form (operator.plan-ladder.
// tier.reorder.commit), the preview idiom's own render (design D10;
// finding FA-13).
Form forms.FormView
}
// PlanLadderDetailData is the body data for the addressable per-ladder composite
// page (operator_plan_ladder_detail.html). It co-locates the edit form and the
// tier/rank manager on one URL; the composite template renders the edit and
// tiers sub-sections via their existing partials. The structural-validation
// view that once shared this page (and the entitlements-module query behind
// its one live check) was retired in acceptance-fixes round 2 (design D8,
// plan-ladder-management "Structural invariant validation view"): the check
// it ran is impossible under the database's exclusion constraint, so the
// query stays unexposed in internal/entitlements/queries/pool_provision_ladders.sql
// (CountMultiActiveLadderAttachments) instead of backing a page here.
type PlanLadderDetailData struct {
Edit PlanLadderEditData
Tiers PlanLadderTiersData
Error string
}
// CreatePlanLadder handles POST /partials/operator/plan-ladders, reading
// the body through the ladder declaration and never through r.FormValue.
func (h *OperatorPartialsHandler) CreatePlanLadder(w http.ResponseWriter, r *http.Request) {
values, errs := planLadderForm.ParseSide(r, forms.CreateOnly, nil)
if errs.Any() {
h.renderPlanLadderNewRefusal(w, r, values, errs)
return
}
// The form collects the display name once: a ladder carries no key
// (entity-keys; migration 00014 dropped the column).
name := values.String("name")
description := values.String("description")
ladder, err := h.BillingQ.CreatePlanLadder(r.Context(), billing.CreatePlanLadderParams{
Name: name,
Description: sql.NullString{String: description, Valid: description != ""},
IsActive: true,
})
if err != nil {
if fe, ok := web.FieldErrorsFromDB(err, web.ConstraintMessages{
"uq_plan_ladders_name_ci": {Field: "name", Message: "A plan ladder with this name already exists."},
}); ok {
for field, msg := range fe {
errs.Field(field, msg)
}
h.renderPlanLadderNewRefusal(w, r, values, errs)
return
}
h.Logger.Error("failed to create plan ladder", slog.Any("error", err))
errs.Form("Failed to create the plan ladder. Details are in the server logs.")
h.renderPlanLadderNewRefusal(w, r, values, errs)
return
}
// design D20 "Create flows: the create page, then land on the record":
// success is a full navigation to the new ladder's page, which renders
// the "Plan ladder created." notice for flash=created — the same
// mechanism as the existing flash=missing bounce
// (loadPlanLaddersPageData) and the product/entitlement-set composites'
// own create-then-land landings (redirectToRecord, operator_products.go).
redirectToRecord(w, r, "/operator/plan-ladders/"+ladder.PlanLadderID+"?flash=created")
}
// UpdatePlanLadder handles PUT /partials/operator/plan-ladders/{ladderID},
// reading the body through the same declaration the create page posts
// through, on its edit side.
func (h *OperatorPartialsHandler) UpdatePlanLadder(w http.ResponseWriter, r *http.Request) {
ladderID := r.PathValue("ladderID")
values, errs := planLadderForm.ParseSide(r, forms.EditOnly, nil)
if errs.Any() {
h.renderPlanLadderEditRefusal(w, r, ladderID, values, errs)
return
}
name := values.String("name")
description := values.String("description")
// The Active flag is not operator-editable: ladder retirement isn't wired
// up (an inactive ladder still sells and provisions), so the edit form no
// longer renders the toggle and the stored value is preserved verbatim.
ladder, err := h.BillingQ.GetPlanLadderByID(r.Context(), ladderID)
if err != nil {
h.Logger.Error("failed to load plan ladder", slog.Any("error", err))
errs.Form("Plan ladder not found.")
h.renderPlanLadderEditRefusal(w, r, ladderID, values, errs)
return
}
// Ladder display order (sort_order) is deliberately not editable here — it is
// adjusted via the move-left/move-right controls on the topology overview,
// where sibling order is visible. UpdatePlanLadder no longer touches it.
_, err = h.BillingQ.UpdatePlanLadder(r.Context(), billing.UpdatePlanLadderParams{
PlanLadderID: ladderID,
Name: name,
Description: sql.NullString{String: description, Valid: description != ""},
IsActive: ladder.IsActive,
})
if err != nil {
if fe, ok := web.FieldErrorsFromDB(err, web.ConstraintMessages{
"uq_plan_ladders_name_ci": {Field: "name", Message: "A plan ladder with this name already exists."},
}); ok {
for field, msg := range fe {
errs.Field(field, msg)
}
h.renderPlanLadderEditRefusal(w, r, ladderID, values, errs)
return
}
h.Logger.Error("failed to update plan ladder", slog.Any("error", err))
errs.Form("Failed to update the plan ladder. Details are in the server logs.")
h.renderPlanLadderEditRefusal(w, r, ladderID, values, errs)
return
}
// Stay on the ladder's composite detail page (the boosted nav already set
// the URL); re-render in place reflecting the saved changes.
h.renderPlanLadderDetailBody(w, r, ladderID, "Plan ladder updated successfully.", "")
}
// DeletePlanLadder handles DELETE /partials/operator/plan-ladders/{ladderID}
func (h *OperatorPartialsHandler) DeletePlanLadder(w http.ResponseWriter, r *http.Request) {
ladderID := r.PathValue("ladderID")
// Guard: reject deletion if any active pool_provision_ladders exist
count, err := h.EntitlementsQ.CountActiveAttachmentsByLadder(r.Context(), ladderID)
if err != nil {
h.Logger.Error("failed to count active attachments", slog.Any("error", err))
h.renderPlanLaddersPage(w, r, "", "Failed to check active positions")
return
}
if count > 0 {
h.renderPlanLaddersPage(w, r, "", "Cannot delete ladder: "+strconv.FormatInt(count, 10)+" active position(s) exist")
return
}
err = h.BillingQ.DeletePlanLadder(r.Context(), ladderID)
if err != nil {
if _, ok := web.FieldErrorsFromDB(err, nil); ok {
h.renderPlanLaddersPage(w, r, "", "Cannot delete this ladder: something still references it (a tier, or an org type using it as its default). Remove those references first.")
return
}
h.Logger.Error("failed to delete plan ladder", slog.Any("error", err))
h.renderPlanLaddersPage(w, r, "", "Failed to delete plan ladder; see server logs.")
return
}
// Signal dependent tabs (Org Types) to re-fetch — they show plan ladder dropdowns
h.renderPlanLaddersPage(w, r, "Plan ladder deleted successfully.", "")
}
// CreatePlanLadderTier handles POST /partials/operator/plan-ladders/{ladderID}/tiers,
// reading the body through the tier-add declaration.
func (h *OperatorPartialsHandler) CreatePlanLadderTier(w http.ResponseWriter, r *http.Request) {
ladderID := r.PathValue("ladderID")
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Unauthorized")
return
}
products := h.availableTierProducts(r.Context(), ladderID)
values, errs := planLadderTierAddForm.ParseWith(r, planLadderTierAddFormOptions(products))
if errs.Any() {
h.renderPlanLadderTierAddRefusal(w, r, ladderID, values, errs)
return
}
productID := values.String("product_id")
// Any published product may be a tier (Doc 41: display_category is
// presentation-only and never gates ladder membership).
product, err := h.BillingQ.GetProductByID(r.Context(), productID)
if err != nil {
errs.Field("product_id", "Product not found.")
h.renderPlanLadderTierAddRefusal(w, r, ladderID, values, errs)
return
}
if product.LifecycleStatus != "published" {
errs.Field("product_id", "Only published products can be tiers. This product is "+product.LifecycleStatus+".")
h.renderPlanLadderTierAddRefusal(w, r, ladderID, values, errs)
return
}
// Tier creation and the shape-alignment of every live provision of this
// product run in one tx: adding the tier grew the product's conferral shape,
// so existing deliveries must gain the new rung atomically.
ctx := r.Context()
tx, err := entitlements.BeginMaterializing(ctx, h.Database, nil)
if err != nil {
h.Logger.Error("failed to begin add-tier tx", slog.Any("error", err))
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to add tier; see server logs.")
return
}
defer tx.Rollback()
bq := billing.New(tx)
entQ := entitlements.New(tx)
// Rank self-assigns to MAX+1 inside the INSERT — new tiers append at the
// bottom; the operator drags them into position (that is also the only way
// the rank-0 provisioning default changes, which is the right affordance).
if _, err = bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{
PlanLadderID: ladderID,
ProductID: productID,
}); err != nil {
if fe, ok := web.FieldErrorsFromDB(err, web.ConstraintMessages{
"plan_ladder_tiers_pkey": {Field: "product_id", Message: "This product is already a tier in this ladder."},
"plan_ladder_tiers_plan_ladder_id_rank_key": {Field: "product_id", Message: "Another change collided with this one; try again."},
}); ok {
for field, msg := range fe {
errs.Field(field, msg)
}
h.renderPlanLadderTierAddRefusal(w, r, ladderID, values, errs)
return
}
h.Logger.Error("failed to create plan ladder tier", slog.Any("error", err))
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to add tier; see server logs.")
return
}
// Align every live provision of the product to the grown shape; CF003 names
// the provision already holding the new rung and blocks the add.
provisions, err := entQ.GetLivePoolProvisionsByProductID(ctx, productID)
if err != nil {
h.Logger.Error("failed to list live provisions for tier product", slog.Any("error", err))
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to add tier; see server logs.")
return
}
for _, p := range provisions {
// The operator-actor transition CHECK requires actor_id; omitting it
// made every provision-bearing induction roll back (found 2026-07-12
// by the tier-reorder walkthrough).
if _, err := entQ.AlignConferralShape(ctx, entitlements.AlignConferralShapeParams{
ProvisionID: p.ProvisionID,
ActorType: "operator",
ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
Reason: sql.NullString{String: "tier added to ladder " + ladderID, Valid: true},
}); err != nil {
if errors.Is(err, entitlements.ErrConferralShapeCollision) {
errs.Field("product_id", "Cannot add this tier; a rung is already held: "+err.Error())
h.renderPlanLadderTierAddRefusal(w, r, ladderID, values, errs)
return
}
h.Logger.Error("failed to align provision to new tier shape", slog.Any("error", err), slog.String("provision_id", p.ProvisionID))
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to add tier; see server logs.")
return
}
if err := entitlements.MaterializePoolEntitlements(ctx, entQ, p.PoolID); err != nil {
h.Logger.Error("failed to materialize after tier align", slog.Any("error", err))
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to add tier; see server logs.")
return
}
}
if err := tx.Commit(); err != nil {
h.Logger.Error("failed to commit add-tier tx", slog.Any("error", err))
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to add tier; see server logs.")
return
}
// Signal dependent tabs (Org Types) to re-fetch — they show plan ladder dropdowns
h.renderPlanLadderTiersPage(w, r, ladderID, "Tier added at the bottom; drag it into position.", "")
}
// reconcileTierOrder merges a submitted product order with the ladder's
// current tiers: submitted products that are no longer tiers are dropped, and
// tiers absent from the submission are appended in their current relative
// order (tiers arrive rank ASC), so the result always covers the whole ladder
// and renumbering can never collide with an untouched tier that still holds a
// target rank (audit finding #42). Returns the merged order, the product
// currently at rank 0, and whether the submission was stale (something was
// dropped or appended). Shared by the reorder preview and commit so the order
// the operator saw is the order the commit gates on.
// splitCommaList splits the reorder-commit form's comma-joined "order"
// field back into its product ids: a single Hidden field carrying a list,
// rather than repeated same-named hidden inputs, which the library's Field
// model does not represent (design D6, "a multi-valued control does not
// exist"). A blank field splits to no ids, not one empty one.
func splitCommaList(joined string) []string {
if joined == "" {
return nil
}
return strings.Split(joined, ",")
}
func reconcileTierOrder(tiers []billing.PlanLadderTier, submitted []string) (final []string, prevTop string, stale bool) {
valid := make(map[string]bool, len(tiers))
for _, t := range tiers {
valid[t.ProductID] = true
if t.Rank == 0 {
prevTop = t.ProductID
}
}
taken := make(map[string]bool, len(submitted))
final = make([]string, 0, len(tiers))
for _, pid := range submitted {
if valid[pid] && !taken[pid] {
taken[pid] = true
final = append(final, pid)
}
}
stale = len(final) != len(submitted) // a submitted product was not a valid tier
for _, t := range tiers {
if !taken[t.ProductID] {
final = append(final, t.ProductID)
stale = true
}
}
return final, prevTop, stale
}
// buildPendingTierReorder reconciles a submitted product order into a fully
// hydrated preview: pending rows with pending ranks, whether rank 0 changes,
// and — when it does on a ladder that is some org type's default — each
// affected type's default-change classification with the pending rank-0
// product as candidate. Read-only. A (nil, "") return means there was nothing
// to preview (empty submission or a fully-stale one); callers re-render the
// persisted order.
func (h *OperatorPartialsHandler) buildPendingTierReorder(ctx context.Context, ladderID string, submitted []string) (*PendingTierReorder, string) {
if len(submitted) == 0 {
return nil, ""
}
tiers, err := h.BillingQ.ListTiersByLadder(ctx, ladderID)
if err != nil {
h.Logger.Error("failed to list tiers for reorder preview", slog.Any("error", err))
return nil, "Failed to load tiers for the reorder preview."
}
final, prevTop, stale := reconcileTierOrder(tiers, submitted)
if len(final) == 0 {
return nil, ""
}
withProducts, err := h.BillingQ.ListTiersByLadderWithProducts(ctx, ladderID)
if err != nil {
h.Logger.Error("failed to load tier products for reorder preview", slog.Any("error", err))
return nil, "Failed to load tiers for the reorder preview."
}
names := make(map[string]string, len(withProducts))
for _, t := range withProducts {
names[t.ProductID] = t.ProductName
}
pending := &PendingTierReorder{
OrderIDs: final,
Stale: stale,
RankZeroChanged: final[0] != prevTop,
OldRankZeroName: names[prevTop],
NewRankZeroName: names[final[0]],
}
pending.Order = make([]PlanLadderTierViewModel, len(final))
for i, pid := range final {
count, _ := h.EntitlementsQ.CountActiveAttachmentsByTier(ctx, entitlements.CountActiveAttachmentsByTierParams{
PlanLadderID: ladderID,
ProductID: pid,
})
pending.Order[i] = PlanLadderTierViewModel{
PlanLadderID: ladderID,
ProductID: pid,
ProductName: names[pid],
Rank: int32(i),
HasActiveAttachments: count > 0,
ActiveAttachmentCount: count,
}
}
// design D8: display order is rank ascending (rank 0 first), the same
// order this loop already built pending.Order in — no reversal needed.
// OrderIDs stays canonical (rank-0-first) since the pending preview's
// commit form re-submits it verbatim.
if pending.RankZeroChanged {
pending.RuleLessWarning = h.ruleLessWarning(ctx, final[0])
for _, ref := range h.orgTypesDefaultingToLadder(ctx, ladderID) {
preview, err := h.buildOrgTypeChangePreview(ctx, ref.OrgType, ladderID, final[0])
if err != nil {
h.Logger.Error("failed to classify for reorder preview", slog.Any("error", err), slog.String("org_type", ref.OrgType))
return nil, "Failed to classify affected organizations. Details are in the server logs."
}
pending.TypePreviews = append(pending.TypePreviews, TierReorderTypePreview{
OrgType: ref.OrgType,
DisplayName: ref.DisplayName,
Preview: preview,
})
if len(preview.OutgoingDefault) > 0 {
pending.NeedsDisposition = true
}
}
}
ladderURL, err := web.RouteURL("/operator/plan-ladders/{ladderID}", ladderID)
if err != nil {
ladderURL = "/operator/plan-ladders"
}
pending.Form = planLadderTierReorderPreview(ladderID, ladderURL,
planLadderTierReorderValues(pending.OrderIDs, pending.NeedsDisposition, ""), nil,
h.Templates.Fragment("operator_plan_ladder_tier_reorder_message.html", pending))
return pending, ""
}
// PreviewPlanLadderTiersReorder handles
// POST /partials/operator/plan-ladders/{ladderID}/tiers/reorder/preview — the
// drop half of the two-step reorder. It writes nothing: the submitted order is
// reconciled and re-rendered as pending state (server-rendered, so no swap can
// silently discard it) together with the consequences of the pending rank 0,
// and the card offers Commit/Discard. A second drag on the previewed card
// simply re-previews.
func (h *OperatorPartialsHandler) PreviewPlanLadderTiersReorder(w http.ResponseWriter, r *http.Request) {
ladderID := r.PathValue("ladderID")
if err := r.ParseForm(); err != nil {
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Invalid reorder request.")
return
}
// design D8: the sortable tbody renders rank ascending (rank 0 first),
// the same order the reorder pipeline works in, so a drop's raw DOM
// order (collected via hx-include="this") arrives already canonical.
submitted := r.Form["product"]
pending, errMsg := h.buildPendingTierReorder(r.Context(), ladderID, submitted)
if errMsg != "" {
h.renderPlanLadderTiersPage(w, r, ladderID, "", errMsg)
return
}
h.renderPlanLadderTiersPending(w, r, ladderID, pending, "")
}
// ReorderPlanLadderTiers handles POST /partials/operator/plan-ladders/{ladderID}/tiers/reorder
// — the commit half of the two-step reorder (the drop itself only previews;
// see PreviewPlanLadderTiersReorder). The request carries the pending
// product-ID order as the declaration operator.plan-ladder.tier.reorder.
// commit's single comma-joined `order` field (design D6: the form library
// has no repeated-name control; splitCommaList reverses the join) plus,
// when the pending rank 0 differs on a ladder that is some org type's
// default and organizations hold the outgoing rank-0 through
// default-sourced positions, a required `bucket2_disposition`
// (`grandfather` | `migrate`). Enactment mirrors the
// org-type default-change commit: validation gate (nothing written, not even
// ranks), then renumber (the moment the effective default changes), then
// per-pool dispositions with in-tx re-derivation. A tier's rank is display
// order plus the rank-0 provisioning default — member provisions bind by
// product_id (pool_provision_ladders), so reordering never re-plans a member.
func (h *OperatorPartialsHandler) ReorderPlanLadderTiers(w http.ResponseWriter, r *http.Request) {
ladderID := r.PathValue("ladderID")
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Unauthorized")
return
}
values, errs := planLadderTierReorderForm.Parse(r)
submitted := splitCommaList(values.String("order"))
disposition := values.String("bucket2_disposition")
ctx := r.Context()
pending, errMsg := h.buildPendingTierReorder(ctx, ladderID, submitted)
if errMsg != "" {
h.renderPlanLadderTiersPage(w, r, ladderID, "", errMsg)
return
}
if pending == nil {
h.renderPlanLadderTiersPage(w, r, ladderID, "", "")
return
}
// Validation gate: an outgoing-default population anywhere requires an
// explicit disposition. Rejecting here writes nothing — not even ranks.
// pending.NeedsDisposition is freshly re-derived from the current
// database state, never the submitted needs_disposition marker (that
// marker only decides whether the radio rendered; a stale marker
// re-previews here rather than trusting an out-of-date "not needed").
if pending.NeedsDisposition && disposition == "" && !errs.Has("bucket2_disposition") {
outgoing := 0
for _, tp := range pending.TypePreviews {
outgoing += len(tp.Preview.OutgoingDefault)
}
errs.Field("bucket2_disposition", fmt.Sprintf("Choose whether to grandfather or migrate the %d organization(s) on the outgoing default.", outgoing))
}
if errs.Any() {
h.renderPlanLadderTiersReorderRefusal(w, r, ladderID, pending,
planLadderTierReorderValues(pending.OrderIDs, pending.NeedsDisposition, disposition), errs)
return
}
tx, err := entitlements.BeginMaterializing(ctx, h.Database, nil)
if err != nil {
h.Logger.Error("failed to begin tier reorder tx", slog.Any("error", err))
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to reorder tiers.")
return
}
defer tx.Rollback()
qtx := billing.New(tx)
if err := renumberTiers(ctx, qtx, ladderID, pending.OrderIDs); err != nil {
h.Logger.Error("failed to renumber tiers", slog.Any("error", err))
// Renumbering the whole ladder removes the stale-collision failure mode;
// a residual error here rolls back and re-renders the true current order,
// so tell the operator their page was out of date instead of emitting an
// opaque generic failure (audit finding #42).
h.renderPlanLadderTiersPage(w, r, ladderID, "", "This page was out of date; showing the current tier order. Try again.")
return
}
if err := tx.Commit(); err != nil {
h.Logger.Error("failed to commit tier reorder tx", slog.Any("error", err))
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to reorder tiers.")
return
}
msg := "Tier order updated."
if pending.Stale {
msg = "Tier order saved. The page was out of date, so this reflects the current order."
}
if pending.RankZeroChanged && len(pending.TypePreviews) > 0 {
// The effective default changed: enact per-pool dispositions for every
// affected type's orgs, exactly like the org-type default-change commit
// (shared enactOrgDisposition — per-pool serializable transactions,
// in-tx bucket re-derivation, per-org failure isolation).
// The reason is composed here and stored on the transition rows, so
// it is a write-time snapshot: it records the ladder's name as it
// read at the moment of the reorder, and a later rename does not
// rewrite history. The ladder UUID stands in when the row cannot be
// loaded.
ladderLabel := ladderID
if ladder, err := h.BillingQ.GetPlanLadderByID(ctx, ladderID); err == nil {
ladderLabel = ladder.Name
}
actor := entitlements.Actor{
ActorType: "operator",
ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
Reason: fmt.Sprintf("plan-ladder tier reorder (%s)", ladderLabel),
}
result := &OrgTypeChangeResult{}
var typeNames []string
for _, tp := range pending.TypePreviews {
typeNames = append(typeNames, tp.DisplayName)
orgs, err := h.OrgQ.ListOrganizationsByType(ctx, tp.OrgType)
if err != nil {
h.Logger.Error("failed to list orgs for reorder enactment", slog.Any("error", err), slog.String("org_type", tp.OrgType))
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Tier order saved, but affected organizations could not be listed for enactment. Re-drop the same order and commit again.")
return
}
for _, org := range orgs {
h.enactOrgDisposition(ctx, org, disposition, pending.OrderIDs[0], session.PersonID, actor, result)
}
}
msg = fmt.Sprintf("%s is now the default plan for new %s organizations: %s.", pending.NewRankZeroName, strings.Join(typeNames, ", "), result.Summary())
if len(result.Failures) > 0 {
parts := make([]string, 0, len(result.Failures))
for _, f := range result.Failures {
parts = append(parts, fmt.Sprintf("%s: %s", f.OrgName, f.Reason))
}
h.renderPlanLadderTiersPage(w, r, ladderID, "", msg+" Failed organizations were skipped; fix the cause, re-drop the same order, and commit again: "+strings.Join(parts, "; "))
return
}
} else if pending.RankZeroChanged {
msg = fmt.Sprintf("%s is now this ladder's rank-0 tier. The ladder is not any org type's default, so no organizations were affected.", pending.NewRankZeroName)
}
h.renderPlanLadderTiersPage(w, r, ladderID, msg, "")
}
// renumberTiers assigns each product in `ordered` a contiguous rank 0..N-1 by
// position, in two phases inside the caller's transaction: UNIQUE
// (plan_ladder_id, rank) is non-deferrable, so a single-pass assignment can
// transiently collide (e.g. setting one tier to 0 while another still holds 0).
// Phase 1 parks every tier at a unique negative rank (disjoint from any
// non-negative existing value); phase 2 assigns the final contiguous ranks.
// Shared by drag-and-drop reorder and renumber-on-delete.
func renumberTiers(ctx context.Context, qtx *billing.Queries, ladderID string, ordered []string) error {
for i, pid := range ordered {
if _, err := qtx.UpdateTierRank(ctx, billing.UpdateTierRankParams{
PlanLadderID: ladderID, ProductID: pid, Rank: int32(-(i + 1)),
}); err != nil {
return fmt.Errorf("park tier rank: %w", err)
}
}
for i, pid := range ordered {
if _, err := qtx.UpdateTierRank(ctx, billing.UpdateTierRankParams{
PlanLadderID: ladderID, ProductID: pid, Rank: int32(i),
}); err != nil {
return fmt.Errorf("set tier rank: %w", err)
}
}
return nil
}
// DeletePlanLadderTier handles DELETE /partials/operator/plan-ladders/{ladderID}/tiers/{productID}
func (h *OperatorPartialsHandler) DeletePlanLadderTier(w http.ResponseWriter, r *http.Request) {
ladderID := r.PathValue("ladderID")
productID := r.PathValue("productID")
// A tier with live holders is removed via the preview → commit flow
// (CommitPlanLadderTierRemoval), never this plain delete — this DELETE is
// only reachable with holders from a stale page, so re-render with the
// current state (whose Remove button now posts the preview).
count, err := h.EntitlementsQ.CountActiveAttachmentsByTier(r.Context(), entitlements.CountActiveAttachmentsByTierParams{
PlanLadderID: ladderID,
ProductID: productID,
})
if err != nil {
h.Logger.Error("failed to count active tier attachments", slog.Any("error", err))
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to check active positions")
return
}
if count > 0 {
h.renderPlanLadderTiersPage(w, r, ladderID, "", "This tier has "+strconv.FormatInt(count, 10)+" live holder(s); Remove now previews the reconciled removal before committing.")
return
}
// Refuse deleting a live default ladder's only remaining tier: new
// signups land on this ladder's rank-0 tier, so emptying it would leave
// every org type that defaults to it without a starting plan. Emptying a
// ladder no org type defaults to stays legal — the 2026-07-03
// remediation explicitly rejected a blanket last-tier guard.
tiers, err := h.BillingQ.ListTiersByLadder(r.Context(), ladderID)
if err != nil {
h.Logger.Error("failed to list tiers for delete guard", slog.Any("error", err))
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to check the ladder's tiers")
return
}
if len(tiers) == 1 && tiers[0].ProductID == productID {
if refs := h.orgTypesDefaultingToLadder(r.Context(), ladderID); len(refs) > 0 {
names := make([]string, len(refs))
for i, ref := range refs {
names[i] = ref.DisplayName
}
h.renderPlanLadderTiersPage(w, r, ladderID, "",
"This ladder's only remaining tier; "+strings.Join(names, ", ")+" defaults new signups to it. Deleting it would leave those signups without a starting plan, so the removal is refused.")
return
}
}
// Delete + renumber the remaining tiers to contiguous 0..N-1 in one tx, so
// ranks never gap and the ladder always has a rank-0 provisioning default
// (deleting the base tier promotes the next tier — the confirm dialog says so).
ctx := r.Context()
tx, err := entitlements.BeginMaterializing(ctx, h.Database, nil)
if err != nil {
h.Logger.Error("failed to begin tier delete tx", slog.Any("error", err))
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove tier; see server logs.")
return
}
defer tx.Rollback()
qtx := billing.New(tx)
if err := qtx.DeleteTier(ctx, billing.DeleteTierParams{
PlanLadderID: ladderID,
ProductID: productID,
}); err != nil {
h.Logger.Error("failed to delete tier", slog.Any("error", err))
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove tier; see server logs.")
return
}
remaining, err := qtx.ListTiersByLadder(ctx, ladderID)
if err != nil {
h.Logger.Error("failed to list remaining tiers", slog.Any("error", err))
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove tier; see server logs.")
return
}
ordered := make([]string, len(remaining))
for i, t := range remaining {
ordered[i] = t.ProductID
}
if err := renumberTiers(ctx, qtx, ladderID, ordered); err != nil {
h.Logger.Error("failed to renumber tiers after delete", slog.Any("error", err))
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove tier; see server logs.")
return
}
if err := tx.Commit(); err != nil {
h.Logger.Error("failed to commit tier delete tx", slog.Any("error", err))
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove tier; see server logs.")
return
}
// Signal dependent tabs (Org Types) to re-fetch — they show plan ladder dropdowns
h.renderPlanLadderTiersPage(w, r, ladderID, "Tier removed; remaining tiers renumbered.", "")
}
// buildTierRemovalPreview classifies the live holders of (ladder, product)
// for the removal preview: default-sourced positions (disposition required)
// vs other-source positions (align-shrunk, named, never force-ended), plus
// the promoted-rank-0 note when removing the top tier. Read-only. A (nil, "")
// return means the tier has no live holders — callers fall back to the plain
// modal-confirmed delete.
func (h *OperatorPartialsHandler) buildTierRemovalPreview(ctx context.Context, ladderID, productID string) (*PendingTierRemoval, string) {
rows, err := h.EntitlementsQ.ListLiveTierHoldersByLadderProduct(ctx, entitlements.ListLiveTierHoldersByLadderProductParams{
PlanLadderID: ladderID,
ProductID: productID,
})
if err != nil {
h.Logger.Error("failed to list tier holders for removal preview", slog.Any("error", err))
return nil, "Failed to load the tier's holders. Details are in the server logs."
}
if len(rows) == 0 {
return nil, ""
}
pending := &PendingTierRemoval{ProductID: productID, ProductName: productID}
if prod, err := h.BillingQ.GetProductByID(ctx, productID); err == nil {
pending.ProductName = prod.Name
}
seenDefault := make(map[string]bool)
seenOther := make(map[string]bool)
for _, row := range rows {
ref := OrgRef{OrgID: row.OrgID, Name: row.Name}
if row.GrantReason.Valid && row.GrantReason.String == "default" {
if !seenDefault[row.OrgID] {
seenDefault[row.OrgID] = true
pending.DefaultSourced = append(pending.DefaultSourced, ref)
}
continue
}
if !seenOther[row.OrgID] {
seenOther[row.OrgID] = true
pending.OtherSource = append(pending.OtherSource, OrgSourceRef{
OrgRef: ref,
Source: otherSourceLabel([]entitlements.GetLivePlanAttachmentsWithSourceByPoolRow{{
ProductID: row.ProductID,
GrantID: row.GrantID,
SubscriptionID: row.SubscriptionID,
PurchaseID: row.PurchaseID,
GrantReason: row.GrantReason,
}}),
})
}
}
pending.NeedsDisposition = len(pending.DefaultSourced) > 0
// Promoted-rank-0 note: removing the top tier of a ladder makes the next
// tier the resolution target for new-org provisioning.
if tiers, err := h.BillingQ.ListTiersByLadderWithProducts(ctx, ladderID); err == nil && len(tiers) > 1 &&
tiers[0].ProductID == productID && tiers[0].Rank == 0 {
pending.PromotedName = tiers[1].ProductName
pending.RuleLessWarning = h.ruleLessWarning(ctx, tiers[1].ProductID)
for _, ref := range h.orgTypesDefaultingToLadder(ctx, ladderID) {
pending.PromotedForTypes = append(pending.PromotedForTypes, ref.DisplayName)
}
}
ladderURL, err := web.RouteURL("/operator/plan-ladders/{ladderID}", ladderID)
if err != nil {
ladderURL = "/operator/plan-ladders"
}
pending.Form = planLadderTierRemovalPreview(ladderID, productID, ladderURL,
planLadderTierRemovalValues(pending.NeedsDisposition, ""), nil,
h.Templates.Fragment("operator_plan_ladder_tier_removal_message.html", pending))
return pending, ""
}
// renderPlanLadderTiersRemoval re-renders the ladder composite with a pending
// tier removal attached to the tier card. No toast — nothing has happened yet.
func (h *OperatorPartialsHandler) renderPlanLadderTiersRemoval(w http.ResponseWriter, r *http.Request, ladderID string, pending *PendingTierRemoval, errMsg string) {
edit, ok := h.loadPlanLadderEditData(r, ladderID)
if !ok {
h.renderPlanLaddersPage(w, r, "", "Plan ladder not found")
return
}
tiersData := h.loadPlanLadderTiersData(r, ladderID)
tiersData.RemovalPending = pending
h.Templates.Render(w, "operator_plan_ladder_detail.html", PlanLadderDetailData{
Edit: edit,
Tiers: tiersData,
Error: errMsg,
})
}
// renderPlanLadderTiersRemovalRefusal re-renders the composite at 422 with
// 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").
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)
if !ok {
h.renderPlanLaddersPage(w, r, "", "Plan ladder not found")
return
}
ladderURL, err := web.RouteURL("/operator/plan-ladders/{ladderID}", ladderID)
if err != nil {
ladderURL = "/operator/plan-ladders"
}
pending.Form = planLadderTierRemovalPreview(ladderID, pending.ProductID, ladderURL, values, errs,
h.Templates.Fragment("operator_plan_ladder_tier_removal_message.html", pending))
tiersData := h.loadPlanLadderTiersData(r, ladderID)
tiersData.RemovalPending = pending
h.Templates.Render(w, "operator_plan_ladder_detail.html", PlanLadderDetailData{
Edit: edit,
Tiers: tiersData,
})
}
// PreviewPlanLadderTierRemoval handles
// 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.
func (h *OperatorPartialsHandler) PreviewPlanLadderTierRemoval(w http.ResponseWriter, r *http.Request) {
ladderID := r.PathValue("ladderID")
productID := r.PathValue("productID")
pending, errMsg := h.buildTierRemovalPreview(r.Context(), ladderID, productID)
if errMsg != "" {
h.renderPlanLadderTiersPage(w, r, ladderID, "", errMsg)
return
}
if pending == nil {
// The holders vanished since the page rendered: the plain delete flow
// applies again.
h.renderPlanLadderTiersPage(w, r, ladderID, "", "This tier no longer has live holders; use Remove directly.")
return
}
h.renderPlanLadderTiersRemoval(w, r, ladderID, pending, "")
}
// CommitPlanLadderTierRemoval handles
// POST /partials/operator/plan-ladders/{ladderID}/tiers/{productID}/remove —
// the commit half of removing a tier with live holders. Atomic by design
// (design D3): tier deletion, renumbering, and every holder reconciliation
// run in ONE transaction with affected pools locked in deterministic order —
// once the tier row is gone there is no entry point to re-run a
// half-reconciled removal, and a shape-mismatched provision would block
// future same-product confers, so a failure rolls back everything including
// the deletion. Other-source positions align-shrink (junction ends, delivery
// continues). Default-sourced positions follow the required disposition:
// keep = align-shrink (the preview carried the floor caveat); migrate =
// end + floor-guarded restoration, resolved after renumbering, so removing a
// default ladder's rank 0 migrates holders onto the promoted tier.
func (h *OperatorPartialsHandler) CommitPlanLadderTierRemoval(w http.ResponseWriter, r *http.Request) {
ladderID := r.PathValue("ladderID")
productID := r.PathValue("productID")
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Unauthorized")
return
}
values, parseErrs := planLadderTierRemoveForm.Parse(r)
disposition := values.String("default_disposition")
if parseErrs.Has("default_disposition") {
pending, errMsg := h.buildTierRemovalPreview(r.Context(), ladderID, productID)
if errMsg != "" || pending == nil {
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier; nothing was changed.")
return
}
h.renderPlanLadderTiersRemovalRefusal(w, r, ladderID, pending,
planLadderTierRemovalValues(pending.NeedsDisposition, disposition), parseErrs)
return
}
ctx := r.Context()
productName := productID
if prod, err := h.BillingQ.GetProductByID(ctx, productID); err == nil {
productName = prod.Name
}
// Composed here and stored on the transition rows: a write-time snapshot
// of the ladder's name as it read at the moment of the removal. The
// ladder UUID stands in when the row cannot be loaded.
ladderLabel := ladderID
if ladder, err := h.BillingQ.GetPlanLadderByID(ctx, ladderID); err == nil {
ladderLabel = ladder.Name
}
actor := entitlements.Actor{
ActorType: "operator",
ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
Reason: fmt.Sprintf("tier removal (%s from %s)", productName, ladderLabel),
}
tx, err := entitlements.BeginMaterializing(ctx, h.Database, nil)
if err != nil {
h.Logger.Error("failed to begin tier removal tx", slog.Any("error", err))
h.renderPlanLadderTiersPage(w, r, ladderID, "", tierRemovalRefusal(err))
return
}
defer tx.Rollback()
qtx := entitlements.New(tx)
bq := billing.New(tx)
// Lock affected pools in deterministic order (concurrent per-pool commits
// of the default-change flows lock single pools; sorted acquisition avoids
// deadlocking against them), then re-derive the population from the locked
// state — the preview was a projection.
rows, err := qtx.ListLiveTierHoldersByLadderProduct(ctx, entitlements.ListLiveTierHoldersByLadderProductParams{
PlanLadderID: ladderID,
ProductID: productID,
})
if err != nil {
h.Logger.Error("failed to list tier holders for removal", slog.Any("error", err))
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier; nothing was changed.")
return
}
var pools []string
seenPool := make(map[string]bool)
for _, row := range rows {
if !seenPool[row.PoolID] {
seenPool[row.PoolID] = true
pools = append(pools, row.PoolID)
}
}
sort.Strings(pools)
for _, poolID := range pools {
if _, err := tx.ExecContext(ctx, "SELECT 1 FROM core.resource_pools WHERE pool_id = $1 FOR UPDATE", poolID); err != nil {
h.renderPlanLadderTiersPage(w, r, ladderID, "", tierRemovalRefusal(err))
return
}
}
rows, err = qtx.ListLiveTierHoldersByLadderProduct(ctx, entitlements.ListLiveTierHoldersByLadderProductParams{
PlanLadderID: ladderID,
ProductID: productID,
})
if err != nil {
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier; nothing was changed.")
return
}
type poolWork struct {
incumbents []defaultIncumbent // default-sourced positions (disposition applies)
aligns []string // provision IDs to align-shrink
}
work := make(map[string]*poolWork)
defaultHolders := 0
for _, row := range rows {
pw := work[row.PoolID]
if pw == nil {
pw = &poolWork{}
work[row.PoolID] = pw
}
if row.GrantReason.Valid && row.GrantReason.String == "default" {
defaultHolders++
pw.incumbents = append(pw.incumbents, defaultIncumbent{
ProvisionID: row.ProvisionID,
GrantID: row.GrantID.UUID.String(),
ProductID: row.ProductID,
})
continue
}
pw.aligns = append(pw.aligns, row.ProvisionID)
}
// Validation gate: default-sourced holders require an explicit
// disposition. Rejecting here writes nothing — the tier stays.
if defaultHolders > 0 && disposition == "" {
pending, _ := h.buildTierRemovalPreview(ctx, ladderID, productID)
if pending == nil {
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier; nothing was changed.")
return
}
errs := forms.NewErrors()
errs.Field("default_disposition", fmt.Sprintf(
"Choose whether to keep or migrate the %d organization(s) that hold this tier through the org type default.", defaultHolders))
h.renderPlanLadderTiersRemovalRefusal(w, r, ladderID, pending,
planLadderTierRemovalValues(pending.NeedsDisposition, disposition), errs)
return
}
// Delete + renumber first: alignment matches the product's CURRENT shape,
// so the shape must already be shrunk, and the floor restoration below
// must resolve rank 0 against the promoted order.
if err := bq.DeleteTier(ctx, billing.DeleteTierParams{PlanLadderID: ladderID, ProductID: productID}); err != nil {
h.Logger.Error("failed to delete tier", slog.Any("error", err))
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier; nothing was changed.")
return
}
remaining, err := bq.ListTiersByLadder(ctx, ladderID)
if err != nil {
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier; nothing was changed.")
return
}
if len(remaining) > 0 {
ordered := make([]string, len(remaining))
for i, t := range remaining {
ordered[i] = t.ProductID
}
if err := renumberTiers(ctx, bq, ladderID, ordered); err != nil {
h.Logger.Error("failed to renumber tiers after removal", slog.Any("error", err))
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier; nothing was changed.")
return
}
}
// Enact per pool: end incumbents first (migrate), then align every
// remaining position of the product, then restore — so a pool that still
// holds another live position after alignment is correctly not re-floored.
ended, migrated := 0, 0
for _, poolID := range pools {
pw := work[poolID]
if pw == nil {
continue
}
aligns := pw.aligns
if len(pw.incumbents) > 0 && disposition == "migrate" {
if err := endDefaultIncumbents(ctx, qtx, pw.incumbents, actor); err != nil {
h.Logger.Error("tier removal: migrate end failed", slog.String("pool_id", poolID), slog.Any("error", err))
h.renderPlanLadderTiersPage(w, r, ladderID, "", tierRemovalRefusalOr(err, "Removal failed and nothing was changed: could not end a default-sourced position. Details are in the server logs."))
return
}
migrated++
} else {
for _, inc := range pw.incumbents {
aligns = append(aligns, inc.ProvisionID) // keep: align-shrink like any other holder
}
}
for _, provisionID := range aligns {
if _, err := qtx.AlignConferralShape(ctx, entitlements.AlignConferralShapeParams{
ProvisionID: provisionID,
ActorType: "operator",
ActorID: actor.ActorID,
Reason: sql.NullString{String: actor.Reason, Valid: true},
}); err != nil {
h.Logger.Error("tier removal: align failed", slog.String("provision_id", provisionID), slog.Any("error", err))
h.renderPlanLadderTiersPage(w, r, ladderID, "", tierRemovalRefusalOr(err, "Removal failed and nothing was changed: could not reconcile a holder's position. Details are in the server logs."))
return
}
ended++
}
if len(pw.incumbents) > 0 && disposition == "migrate" {
if _, err := entitlements.ReapplyDefaultsIfVacant(ctx, tx, poolID, actor); err != nil {
h.Logger.Error("tier removal: migrate restore failed", slog.String("pool_id", poolID), slog.Any("error", err))
h.renderPlanLadderTiersPage(w, r, ladderID, "", tierRemovalRefusalOr(err, "Removal failed and nothing was changed: could not apply the current default. Details are in the server logs."))
return
}
}
if err := entitlements.MaterializePoolEntitlements(ctx, qtx, poolID); err != nil {
h.Logger.Error("tier removal: materialize failed", slog.String("pool_id", poolID), slog.Any("error", err))
h.renderPlanLadderTiersPage(w, r, ladderID, "", tierRemovalRefusalOr(err, "Removal failed and nothing was changed: could not materialize entitlements. Details are in the server logs."))
return
}
}
if err := tx.Commit(); err != nil {
h.Logger.Error("failed to commit tier removal tx", slog.Any("error", err))
h.renderPlanLadderTiersPage(w, r, ladderID, "", tierRemovalRefusal(err))
return
}
parts := []string{}
if ended > 0 {
parts = append(parts, fmt.Sprintf("%d position(s) on this ladder ended; delivery continues", ended))
}
if migrated > 0 {
parts = append(parts, fmt.Sprintf("%d organization(s) migrated to the current default", migrated))
}
msg := fmt.Sprintf("%s removed from the ladder: %s.", productName, strings.Join(parts, "; "))
h.renderPlanLadderTiersPage(w, r, ladderID, msg, "")
}
func (h *OperatorPartialsHandler) renderPlanLaddersPage(w http.ResponseWriter, r *http.Request, success string, errMsg string) {
fireSuccessToast(w, success)
// The in-body banner alone is invisible when the mutation was triggered
// from a scrolled-down row (maintainer 2026-08-23: a ladder delete gave
// "no feedback"); the toast is the visible half.
fireErrorToast(w, errMsg)
data := h.loadPlanLaddersPageData(r, "", errMsg)
h.Templates.Render(w, "operator_plan_ladders.html", data)
}
// loadPlanLaddersPageData hydrates the plan-ladders listing. Shared by the
// legacy partial endpoint (renderPlanLaddersPage) and the new MPA page
// handler (GetPlanLaddersPage).
func (h *OperatorPartialsHandler) loadPlanLaddersPageData(r *http.Request, success string, errMsg string) PlanLaddersData {
data := PlanLaddersData{
Success: success,
Error: errMsg,
}
// A stale-detail GET redirects here with ?flash=missing; surface it as the
// page banner (finding #53) without clobbering an explicit mutation-guard
// errMsg.
if data.Error == "" && r.URL.Query().Get("flash") == "missing" {
data.Error = "That plan ladder no longer exists; it may have been deleted."
}
// ux-ia-naming 2.2: plan ladders opens map-first. Loaded independently of
// (and before) the list below, mirroring finding #54's always-hydrate
// rule — a failure in one section never blanks the other; each renders
// its own error state.
data.Topology = h.loadPlanTopologyData(r)
// Finding #54: always hydrate the table and create form, even when a banner
// is set, so an unrecognized error never wipes the whole management surface.
ladders, err := h.BillingQ.ListPlanLadders(r.Context())
if err != nil {
h.Logger.Error("failed to list plan ladders", slog.Any("error", err))
if data.Error == "" {
data.Error = "Failed to load plan ladders"
}
return data
}
// design D8 / operator-list-scale: search over name, true-total paging.
// No paged SQL query backs plan ladders (a small catalog list), so
// filtering and windowing run in Go over the already-loaded set; the
// per-ladder tier/attachment counts are then computed only for the rows
// on the current page.
params := ParseListParams(r, "") // Ladders has no status facet.
filtered := ladders
if params.Q != "" {
q := strings.ToLower(params.Q)
filtered = make([]billing.PlanLadder, 0, len(ladders))
for _, l := range ladders {
if strings.Contains(strings.ToLower(l.Name), q) {
filtered = append(filtered, l)
}
}
}
page, total, _ := FetchPage(&params, func(limit, offset int32) ([]billing.PlanLadder, int64, error) {
lo := int(offset)
if lo > len(filtered) {
lo = len(filtered)
}
hi := lo + int(limit)
if hi > len(filtered) {
hi = len(filtered)
}
return filtered[lo:hi], int64(len(filtered)), nil
})
data.Ladders = make([]PlanLadderViewModel, len(page))
for i, l := range page {
tiers, _ := h.BillingQ.ListTiersByLadder(r.Context(), l.PlanLadderID)
attachments, _ := h.EntitlementsQ.CountActiveAttachmentsByLadder(r.Context(), l.PlanLadderID)
desc := ""
if l.Description.Valid {
desc = l.Description.String
}
data.Ladders[i] = PlanLadderViewModel{
PlanLadderID: l.PlanLadderID,
Name: l.Name,
Description: desc,
IsActive: l.IsActive,
TierCount: len(tiers),
ActiveAttachmentCount: attachments,
}
}
data.LaddersNav = ListNav{
BasePath: "/operator/plan-ladders",
SearchPlaceholder: "Search ladders by name",
Q: params.Q,
Page: params.Page,
Total: total,
}
return data
}
// renderPlanLadderTiersFormErrors re-renders the tiers page (with inline
// add-tier + reorder-rank forms) with 422 + FieldErrors populated. The
// load logic mirrors renderPlanLadderTiersPage's hydration since we need
// to write the 422 header before any body bytes.
// renderPlanLadderTierAddRefusal re-renders the composite at 422 with the
// tier-add declaration in submission mode (design D9).
func (h *OperatorPartialsHandler) renderPlanLadderTierAddRefusal(w http.ResponseWriter, r *http.Request, ladderID string, values forms.Values, errs *forms.Errors) {
w.WriteHeader(http.StatusUnprocessableEntity)
edit, ok := h.loadPlanLadderEditData(r, ladderID)
if !ok {
h.renderPlanLaddersPage(w, r, "", "Plan ladder not found")
return
}
tiers := h.loadPlanLadderTiersData(r, ladderID)
tiers.TierForm = planLadderTierAddFormView(ladderID, values, errs, tiers.AvailableProducts,
h.ruleLessWarning(r.Context(), values.String("product_id")))
h.Templates.Render(w, "operator_plan_ladder_detail.html", PlanLadderDetailData{
Edit: edit,
Tiers: tiers,
})
}
// renderPlanLadderEditRefusal re-renders the composite at 422 with the
// ladder declaration in submission mode (design D9).
func (h *OperatorPartialsHandler) renderPlanLadderEditRefusal(w http.ResponseWriter, r *http.Request, ladderID string, values forms.Values, errs *forms.Errors) {
w.WriteHeader(http.StatusUnprocessableEntity)
edit, ok := h.loadPlanLadderEditData(r, ladderID)
if !ok {
h.renderPlanLaddersPage(w, r, "", "Plan ladder not found")
return
}
edit.Form = planLadderEditForm(ladderID, values, errs)
h.Templates.Render(w, "operator_plan_ladder_detail.html", PlanLadderDetailData{
Edit: edit,
Tiers: h.loadPlanLadderTiersData(r, ladderID),
})
}
// loadPlanLadderEditData hydrates the edit-form view model for one ladder.
// ok=false means the ladder does not exist.
func (h *OperatorPartialsHandler) loadPlanLadderEditData(r *http.Request, ladderID string) (PlanLadderEditData, bool) {
data, found, err := h.loadPlanLadderEditDataResult(r, ladderID)
return data, found && err == nil
}
// loadPlanLadderEditDataResult is loadPlanLadderEditData with the not-found vs.
// transient-DB-error distinction preserved: found=false with err=nil means the
// ladder does not exist; err != nil means the load itself failed. The full-page
// GET (GetPlanLadderDetailPage) uses this to redirect on the former and return a
// full-shell 500 on the latter, rather than conflating the two (finding #53).
func (h *OperatorPartialsHandler) loadPlanLadderEditDataResult(r *http.Request, ladderID string) (PlanLadderEditData, bool, error) {
ladder, err := h.BillingQ.GetPlanLadderByID(r.Context(), ladderID)
if errors.Is(err, sql.ErrNoRows) {
return PlanLadderEditData{}, false, nil
}
if err != nil {
h.Logger.Error("failed to get plan ladder", slog.Any("error", err))
return PlanLadderEditData{}, false, err
}
desc := ""
if ladder.Description.Valid {
desc = ladder.Description.String
}
vm := PlanLadderViewModel{
PlanLadderID: ladder.PlanLadderID,
Name: ladder.Name,
Description: desc,
IsActive: ladder.IsActive,
Key: ladder.Key.String,
}
return PlanLadderEditData{
Ladder: vm,
// The edit form is the ladder declaration bound to this record:
// the same field list the create page renders unbound (design D5).
Form: planLadderEditForm(ladderID, planLadderEditValues(vm), nil),
}, true, nil
}
// GetPlanLadderDetailPage handles GET /operator/plan-ladders/{ladderID} — the
// addressable, bookmarkable per-ladder composite. Renders the operator.html
// shell with the edit form, tier/rank manager, and this ladder's structural
// validation co-located on one URL. Unknown ladderID degrades to the ladders
// browse page with an error.
func (h *OperatorPartialsHandler) GetPlanLadderDetailPage(w http.ResponseWriter, r *http.Request) {
ladderID, ok := h.pathUUID(w, r, "ladderID")
if !ok {
return
}
edit, found, err := h.loadPlanLadderEditDataResult(r, ladderID)
if err != nil {
// Transient load failure — keep the operator inside the shell (nav, CSS)
// with a 500 instead of mislabeling it "not found" (finding #53).
errPage := h.buildOperatorPageData(r)
errPage.IAPosition = "catalog:plan-ladders"
errPage.ActiveCapability = "plan-ladders"
errPage.BodyTemplate = "operator_plan_ladders.html"
errPage.BodyData = h.loadPlanLaddersPageData(r, "", "Could not load this plan ladder right now. Try again.")
w.WriteHeader(http.StatusInternalServerError)
h.Templates.Render(w, "operator.html", errPage)
return
}
if !found {
// Stale/unknown ID — normalize the URL back to the browse route and carry
// a flash so the operator understands the bounce (finding #53).
http.Redirect(w, r, "/operator/plan-ladders?flash=missing", http.StatusSeeOther)
return
}
page := h.buildOperatorPageData(r)
// design D20, round 2 ("a success toast, not a banner"): a fresh
// ?flash=created landing shows a success toast.
if r.URL.Query().Get("flash") == "created" {
page.FlashSuccess = "Plan ladder created."
}
page.IAPosition = "catalog:plan-ladders:" + ladderID
page.ActiveCapability = "plan-ladders"
page.BodyTemplate = "operator_plan_ladder_detail.html"
page.BodyData = PlanLadderDetailData{
Edit: edit,
Tiers: h.loadPlanLadderTiersData(r, ladderID),
}
h.Templates.Render(w, "operator.html", page)
}
// renderPlanLadderDetailBody re-renders the composite ladder detail body into
// #operator-body after an in-page mutation. The operator stays on
// /operator/plan-ladders/{ladderID}; unknown ladderID degrades to the browse page.
func (h *OperatorPartialsHandler) renderPlanLadderDetailBody(w http.ResponseWriter, r *http.Request, ladderID string, success string, errMsg string) {
fireSuccessToast(w, success)
fireErrorToast(w, errMsg)
edit, ok := h.loadPlanLadderEditData(r, ladderID)
if !ok {
h.renderPlanLaddersPage(w, r, "", "Plan ladder not found")
return
}
// Finding #41: set only the composite-level Error so the alert renders once
// at page top; setting edit.Error too stacked an identical banner inside the
// Edit card, visually blaming the wrong form.
h.Templates.Render(w, "operator_plan_ladder_detail.html", PlanLadderDetailData{
Edit: edit,
Tiers: h.loadPlanLadderTiersData(r, ladderID),
Error: errMsg,
})
}
// renderPlanLadderEditPage re-renders the ladder composite in place. Retained as
// a thin alias so the mutation call sites (UpdatePlanLadder) keep working; the
// edit form now lives on the composite detail page.
func (h *OperatorPartialsHandler) renderPlanLadderEditPage(w http.ResponseWriter, r *http.Request, ladderID string, success string, errMsg string) {
h.renderPlanLadderDetailBody(w, r, ladderID, success, errMsg)
}
// renderPlanLadderTiersPage re-renders the ladder composite in place. Retained
// as a thin alias so the tier mutation call sites (add/reorder/remove) keep
// working; the tier manager now lives on the composite detail page.
func (h *OperatorPartialsHandler) renderPlanLadderTiersPage(w http.ResponseWriter, r *http.Request, ladderID string, success string, errMsg string) {
h.renderPlanLadderDetailBody(w, r, ladderID, success, errMsg)
}
// renderPlanLadderTiersPending re-renders the ladder composite with a pending
// (uncommitted) tier reorder attached to the tier card: pending rows and ranks,
// consequence panel, Commit/Discard. No toast — nothing has happened yet. A nil
// pending falls back to the persisted order.
func (h *OperatorPartialsHandler) renderPlanLadderTiersPending(w http.ResponseWriter, r *http.Request, ladderID string, pending *PendingTierReorder, errMsg string) {
edit, ok := h.loadPlanLadderEditData(r, ladderID)
if !ok {
h.renderPlanLaddersPage(w, r, "", "Plan ladder not found")
return
}
tiersData := h.loadPlanLadderTiersData(r, ladderID)
tiersData.Pending = pending
h.Templates.Render(w, "operator_plan_ladder_detail.html", PlanLadderDetailData{
Edit: edit,
Tiers: tiersData,
Error: errMsg,
})
}
// renderPlanLadderTiersReorderRefusal re-renders the composite at 422 with
// the reorder 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").
func (h *OperatorPartialsHandler) renderPlanLadderTiersReorderRefusal(w http.ResponseWriter, r *http.Request, ladderID string, pending *PendingTierReorder, values forms.Values, errs *forms.Errors) {
w.WriteHeader(http.StatusUnprocessableEntity)
edit, ok := h.loadPlanLadderEditData(r, ladderID)
if !ok {
h.renderPlanLaddersPage(w, r, "", "Plan ladder not found")
return
}
ladderURL, err := web.RouteURL("/operator/plan-ladders/{ladderID}", ladderID)
if err != nil {
ladderURL = "/operator/plan-ladders"
}
pending.Form = planLadderTierReorderPreview(ladderID, ladderURL, values, errs,
h.Templates.Fragment("operator_plan_ladder_tier_reorder_message.html", pending))
tiersData := h.loadPlanLadderTiersData(r, ladderID)
tiersData.Pending = pending
h.Templates.Render(w, "operator_plan_ladder_detail.html", PlanLadderDetailData{
Edit: edit,
Tiers: tiersData,
})
}
// loadPlanLadderTiersData hydrates the tier-management view model for one ladder:
// the existing tiers (with active-attachment flags) and the products still
// available to add as tiers. Best-effort — a query failure yields a partial
// result rather than aborting, since this feeds the composite detail page where
// the ladder's existence is already established.
// orgTypesDefaultingToLadder returns the org types (key + display name) whose
// configured default plan ladder is ladderID. New organizations of those types
// are auto-provisioned onto this ladder's rank-0 tier; for any ladder that is
// no org type's default, rank 0 is merely the base tier and provisions no one.
// Best-effort: a query failure logs and returns nil (the badge simply doesn't
// render; the reorder preview treats the ladder as no type's default) rather
// than failing the whole tier view.
func (h *OperatorPartialsHandler) orgTypesDefaultingToLadder(ctx context.Context, ladderID string) []orgTypeRef {
lid, err := uuid.Parse(ladderID)
if err != nil {
return nil
}
orgTypes, err := h.OrgQ.ListOrgTypes(ctx)
if err != nil {
h.Logger.Error("failed to list org types for default-ladder badge", slog.Any("error", err))
return nil
}
var refs []orgTypeRef
for _, ot := range orgTypes {
if ot.DefaultPlanLadderID.Valid && ot.DefaultPlanLadderID.UUID == lid {
refs = append(refs, orgTypeRef{OrgType: ot.OrgType, DisplayName: ot.DisplayName})
}
}
return refs
}
func (h *OperatorPartialsHandler) loadPlanLadderTiersData(r *http.Request, ladderID string) PlanLadderTiersData {
data := PlanLadderTiersData{}
if ladder, err := h.BillingQ.GetPlanLadderByID(r.Context(), ladderID); err == nil {
data.Ladder = PlanLadderViewModel{
PlanLadderID: ladder.PlanLadderID,
Name: ladder.Name,
IsActive: ladder.IsActive,
}
}
for _, ref := range h.orgTypesDefaultingToLadder(r.Context(), ladderID) {
data.DefaultForOrgTypeNames = append(data.DefaultForOrgTypeNames, ref.DisplayName)
}
// Load tiers with product info
tiers, err := h.BillingQ.ListTiersByLadderWithProducts(r.Context(), ladderID)
if err != nil {
h.Logger.Error("failed to list tiers", slog.Any("error", err))
} else {
data.Tiers = make([]PlanLadderTierViewModel, len(tiers))
for i, t := range tiers {
count, _ := h.EntitlementsQ.CountActiveAttachmentsByTier(r.Context(), entitlements.CountActiveAttachmentsByTierParams{
PlanLadderID: ladderID,
ProductID: t.ProductID,
})
data.Tiers[i] = PlanLadderTierViewModel{
PlanLadderID: ladderID,
ProductID: t.ProductID,
ProductName: t.ProductName,
Rank: t.Rank,
HasActiveAttachments: count > 0,
ActiveAttachmentCount: count,
}
}
// 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.
}
data.AvailableProducts = h.availableTierProducts(r.Context(), ladderID)
// The panel's fresh (unrefused) state: closed, nothing chosen. A
// refusal's caller (renderPlanLadderTierAddRefusal) overwrites this
// with the submission bound back.
data.TierForm = planLadderTierAddFormView(ladderID, forms.NewValues(), nil, data.AvailableProducts, "")
return data
}
// availableTierProducts lists the products still available to add as a
// tier of ladderID: any active, published product not already a tier of
// this ladder (Doc 41: display_category never gates tier membership).
// Shared by the composite's initial render and the tier-add form's option
// set, so a submitted product id is checked against the same list the
// control offered.
func (h *OperatorPartialsHandler) availableTierProducts(ctx context.Context, ladderID string) []ProductOption {
products, err := h.BillingQ.ListActiveProducts(ctx)
if err != nil {
h.Logger.Error("failed to list products", slog.Any("error", err))
return nil
}
existingTiers, _ := h.BillingQ.ListTiersByLadder(ctx, ladderID)
existingProductIDs := make(map[string]bool, len(existingTiers))
for _, t := range existingTiers {
existingProductIDs[t.ProductID] = true
}
var out []ProductOption
for _, p := range products {
if p.LifecycleStatus != "published" {
continue // skip non-published products
}
if existingProductIDs[p.ProductID] {
continue // skip already-attached products
}
out = append(out, ProductOption{ProductID: p.ProductID, Name: p.Name})
}
return out
}
// The structural-invariant validation view that once lived here (orphan
// products, multi-active attachments, malformed rank sequences) was retired
// in acceptance-fixes round 2 (design D8, plan-ladder-management "Structural
// invariant validation view"): the one check tied to a real database
// guarantee — a pool holding two active positions on one ladder — is
// impossible under the exclusion constraint, so its query stays unexposed as
// entitlements.CountMultiActiveLadderAttachments
// (internal/entitlements/queries/pool_provision_ladders.sql) instead of
// backing a page or health strip here.
// tierRemovalRefusal is the copy for a tier removal that did not commit: a
// lock wait past the timeout or a broken deadlock is a change in progress
// and retryable; anything else is the plain failure.
func tierRemovalRefusal(err error) string {
return tierRemovalRefusalOr(err, "Failed to remove the tier; nothing was changed.")
}
// tierRemovalRefusalOr keeps the caller's message unless the error is lock
// contention, which renders as the retryable refusal.
func tierRemovalRefusalOr(err error, message string) string {
if entitlements.IsLockContention(err) {
return "Another entitlement set change is in progress. Try again."
}
return message
}