Files
member-console/internal/server/operator_plan_ladders.go
T
cgalo5758 257955c9d3 Add operator list-scale contract and People directory
Governed operator lists (organizations, grants, people, billing×4) gain
server-side search, status filters, and 50-row pages with true totals
from count(*) OVER(); state is URL-addressable, out-of-range pages
clamp,
and no-match is distinct from true-empty.

People is the eighth flat sidebar entry: /operator/persons lists persons
newest-joined first (excluding the reserved system person), rows linking
to the existing detail.

Billing gains an operator invoice detail at
/operator/billing/invoices/{invoiceID} reusing the member projection;
open invoices past due present as Overdue (derived, filterable, stored
status untouched); all four views lead with the linked organization and
mute object IDs.

Grants filter over the derived Live/Superseded/Inactive state, the SQL
HAVING predicate pinned to the Go derivation by test. Embedded lists
(org composite ledger, Tier changes) adopt the shared controls under
namespaced params with sibling-state-preserving URLs and scoped htmx
swaps that hold the viewport.

Review corrections: blocked ladder Delete renders disabled with tooltip
and mutations fire toasts; collapse triggers paint their open state;
sections use outside headings; plan topology drops the orphan-product
check; domains policy collapses behind a disclosure.
2026-08-24 03:58:18 -05:00

1602 lines
66 KiB
Go

package server
import (
"context"
"database/sql"
"errors"
"fmt"
"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/web"
)
// PlanLadderViewModel represents a plan ladder for operator template rendering.
type PlanLadderViewModel struct {
PlanLadderID string
LadderKey string
Name string
Description string
IsActive bool
TierCount int
ActiveAttachmentCount int64
}
// 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.
type PlanLaddersData struct {
Ladders []PlanLadderViewModel
FieldErrors web.FieldErrors
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
}
// PlanLadderEditData holds data for the plan ladder edit form.
type PlanLadderEditData struct {
Ladder PlanLadderViewModel
FieldErrors web.FieldErrors
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
FieldErrors web.FieldErrors
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
}
// 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
TypePreviews []TierReorderTypePreview
NeedsDisposition bool // some type's outgoing-default bucket is non-empty
}
// PlanLadderDetailData is the body data for the addressable per-ladder composite
// page (operator_plan_ladder_detail.html). It co-locates the edit form, the
// tier/rank manager, and this ladder's structural validation on one URL; the
// composite template renders the edit and tiers sub-sections via their existing
// partials.
type PlanLadderDetailData struct {
Edit PlanLadderEditData
Tiers PlanLadderTiersData
Validation PlanLadderValidationData
Error string
}
// PlanLadderValidationData holds data for the structural invariant validation view.
type PlanLadderValidationData struct {
OrphanProducts []ProductOption
MultiActivePools []MultiActivePoolViewModel
MalformedRankLadders []MalformedRankLadderViewModel
Success string
Error string
}
// MalformedRankLadderViewModel is a ladder whose tier ranks are not
// 0-based contiguous (0..N-1). The schema only enforces per-ladder rank
// uniqueness, but rank 0 is load-bearing — org-type default resolution
// joins on rank = 0 and the member catalog branches on it — and every UI
// authoring path (append at MAX+1, reorder/removal renumber) maintains
// 0..N-1, so a gap or nonzero base means data written outside the UI.
type MalformedRankLadderViewModel struct {
LadderID string
Name string
MinRank int64
MaxRank int64
TierCount int64
}
// MultiActivePoolViewModel represents a pool with multiple active attachments on the same ladder.
type MultiActivePoolViewModel struct {
PoolID string
LadderID string
LadderKey string
ActiveCount int64
}
// CreatePlanLadder handles POST /partials/operator/plan-ladders
func (h *OperatorPartialsHandler) CreatePlanLadder(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.renderPlanLaddersPage(w, r, "", "Invalid request")
return
}
ladderKey := r.FormValue("ladder_key")
name := r.FormValue("name")
description := r.FormValue("description")
errs := web.New()
if ladderKey == "" {
errs.Set("ladder_key", "Slug is required.")
}
if name == "" {
errs.Set("name", "Display name is required.")
}
if errs.Any() {
h.renderPlanLaddersFormErrors(w, r, errs)
return
}
_, err := h.BillingQ.CreatePlanLadder(r.Context(), billing.CreatePlanLadderParams{
LadderKey: ladderKey,
Name: name,
Description: sql.NullString{String: description, Valid: description != ""},
IsActive: true,
})
if err != nil {
if fe, ok := web.FieldErrorsFromDB(err, web.ConstraintMessages{
"plan_ladders_ladder_key_key": {Field: "ladder_key", Message: "A ladder with this key already exists — pick a different key."},
}); ok {
h.renderPlanLaddersFormErrors(w, r, fe)
return
}
h.Logger.Error("failed to create plan ladder", slog.Any("error", err))
h.renderPlanLaddersPage(w, r, "", "Failed to create 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 created successfully.", "")
}
// UpdatePlanLadder handles PUT /partials/operator/plan-ladders/{ladderID}
func (h *OperatorPartialsHandler) UpdatePlanLadder(w http.ResponseWriter, r *http.Request) {
ladderID := r.PathValue("ladderID")
if err := r.ParseForm(); err != nil {
h.renderPlanLadderEditPage(w, r, ladderID, "", "Invalid request")
return
}
name := r.FormValue("name")
description := r.FormValue("description")
errs := web.New()
if name == "" {
errs.Set("name", "Display name is required.")
}
if errs.Any() {
h.renderPlanLadderEditFormErrors(w, r, ladderID, errs)
return
}
// 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))
h.renderPlanLadderEditPage(w, r, ladderID, "", "Plan ladder not found.")
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 {
h.Logger.Error("failed to update plan ladder", slog.Any("error", err))
h.renderPlanLadderEditPage(w, r, ladderID, "", "Failed to update plan ladder — see server logs.")
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
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
}
if err := r.ParseForm(); err != nil {
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Invalid request")
return
}
productID := r.FormValue("product_id")
errs := web.New()
if productID == "" {
errs.Set("product_id", "Product is required.")
}
if errs.Any() {
h.renderPlanLadderTiersFormErrors(w, r, ladderID, errs)
return
}
// 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.Set("product_id", "Product not found.")
h.renderPlanLadderTiersFormErrors(w, r, ladderID, errs)
return
}
if product.LifecycleStatus != "published" {
errs.Set("product_id", "Only published products can be tiers. This product is "+product.LifecycleStatus+".")
h.renderPlanLadderTiersFormErrors(w, r, ladderID, 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 := h.Database.BeginTx(ctx, 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 {
h.renderPlanLadderTiersFormErrors(w, r, ladderID, fe)
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.Set("product_id", "Cannot add this tier — a rung is already held: "+err.Error())
h.renderPlanLadderTiersFormErrors(w, r, ladderID, 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.
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,
}
}
if pending.RankZeroChanged {
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
}
}
}
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
}
pending, errMsg := h.buildPendingTierReorder(r.Context(), ladderID, r.Form["product"])
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 repeated `product` form values 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
}
if err := r.ParseForm(); err != nil {
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Invalid reorder request.")
return
}
disposition := r.FormValue("bucket2_disposition")
if disposition != "" && disposition != "grandfather" && disposition != "migrate" {
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Invalid disposition for organizations on the outgoing default.")
return
}
ctx := r.Context()
pending, errMsg := h.buildPendingTierReorder(ctx, ladderID, r.Form["product"])
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.
if pending.NeedsDisposition && disposition == "" {
outgoing := 0
for _, tp := range pending.TypePreviews {
outgoing += len(tp.Preview.OutgoingDefault)
}
h.renderPlanLadderTiersPending(w, r, ladderID, pending,
fmt.Sprintf("%d organization(s) hold the outgoing rank-0 default. Choose whether to grandfather or migrate them before committing.", outgoing))
return
}
tx, err := h.Database.BeginTx(ctx, 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. Please 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).
ladderKey := ladderID
if ladder, err := h.BillingQ.GetPlanLadderByID(ctx, ladderID); err == nil {
ladderKey = ladder.LadderKey
}
actor := entitlements.Actor{
ActorType: "operator",
ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
Reason: fmt.Sprintf("plan-ladder tier reorder (%s)", ladderKey),
}
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) — %s", f.OrgName, f.OrgSlug, 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 is the ladder's only remaining tier, and "+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 := h.Database.BeginTx(ctx, 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, Slug: row.Slug}
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
for _, ref := range h.orgTypesDefaultingToLadder(ctx, ladderID) {
pending.PromotedForTypes = append(pending.PromotedForTypes, ref.DisplayName)
}
}
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,
Validation: h.loadLadderScopedValidation(r.Context(), ladderID),
Error: errMsg,
})
}
// 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
}
if err := r.ParseForm(); err != nil {
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Invalid removal request.")
return
}
disposition := r.FormValue("default_disposition")
if disposition != "" && disposition != "keep" && disposition != "migrate" {
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Invalid disposition for default-sourced holders.")
return
}
ctx := r.Context()
productName := productID
if prod, err := h.BillingQ.GetProductByID(ctx, productID); err == nil {
productName = prod.Name
}
ladderKey := ladderID
if ladder, err := h.BillingQ.GetPlanLadderByID(ctx, ladderID); err == nil {
ladderKey = ladder.LadderKey
}
actor := entitlements.Actor{
ActorType: "operator",
ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
Reason: fmt.Sprintf("tier removal (%s from %s)", productName, ladderKey),
}
tx, err := h.Database.BeginTx(ctx, nil)
if err != nil {
h.Logger.Error("failed to begin tier removal tx", slog.Any("error", err))
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier — nothing was changed.")
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, "", "Failed to remove the tier — nothing was changed.")
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
}
h.renderPlanLadderTiersRemoval(w, r, ladderID, pending, fmt.Sprintf(
"%d organization(s) hold this tier through the org type default. Choose whether to keep or migrate them before committing.", defaultHolders))
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, "", "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, "", "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, "", "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, "", "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, "", "Failed to remove the tier — nothing was changed.")
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, "")
}
// GetPlanLadderValidation handles GET /partials/operator/plan-ladders/validation
func (h *OperatorPartialsHandler) GetPlanLadderValidation(w http.ResponseWriter, r *http.Request) {
h.renderPlanLadderValidationPage(w, r, "", "")
}
// GetPlanLadderValidationPage handles GET /operator/plan-ladders/validation —
// the addressable, bookmarkable full-page structural-validation view, rendered
// inside the operator.html shell. The ladders-list "Validate Structure" and
// topology "View detail" links point here (a boosted full-page nav) so a new
// tab or refresh lands on a styled, addressable page instead of the shell-less
// fragment the bare partial endpoint returns (audit finding #43).
func (h *OperatorPartialsHandler) GetPlanLadderValidationPage(w http.ResponseWriter, r *http.Request) {
page := h.buildOperatorPageData(r)
page.IAPosition = "catalog:plan-ladders:validation"
page.ActiveCapability = "plan-ladders"
page.BodyTemplate = "operator_plan_ladder_validation.html"
page.BodyData = h.computePlanLadderValidation(r.Context())
h.Templates.Render(w, "operator.html", page)
}
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
}
data.Ladders = make([]PlanLadderViewModel, len(ladders))
for i, l := range ladders {
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,
LadderKey: l.LadderKey,
Name: l.Name,
Description: desc,
IsActive: l.IsActive,
TierCount: len(tiers),
ActiveAttachmentCount: attachments,
}
}
return data
}
// renderPlanLaddersFormErrors re-renders the ladders list page (with inline
// create-ladder form) with 422 + FieldErrors populated.
func (h *OperatorPartialsHandler) renderPlanLaddersFormErrors(w http.ResponseWriter, r *http.Request, errs web.FieldErrors) {
w.WriteHeader(http.StatusUnprocessableEntity)
data := h.loadPlanLaddersPageData(r, "", "")
data.FieldErrors = errs
h.Templates.Render(w, "operator_plan_ladders.html", 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.
func (h *OperatorPartialsHandler) renderPlanLadderTiersFormErrors(w http.ResponseWriter, r *http.Request, ladderID string, errs web.FieldErrors) {
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.FieldErrors = errs
h.Templates.Render(w, "operator_plan_ladder_detail.html", PlanLadderDetailData{
Edit: edit,
Tiers: tiers,
Validation: h.loadLadderScopedValidation(r.Context(), ladderID),
})
}
// renderPlanLadderEditFormErrors re-renders the edit form with 422 +
// FieldErrors populated. Convention per docs/operator-ux-conventions.md §6+§8.
func (h *OperatorPartialsHandler) renderPlanLadderEditFormErrors(w http.ResponseWriter, r *http.Request, ladderID string, errs web.FieldErrors) {
w.WriteHeader(http.StatusUnprocessableEntity)
edit, ok := h.loadPlanLadderEditData(r, ladderID)
if !ok {
h.renderPlanLaddersPage(w, r, "", "Plan ladder not found")
return
}
edit.FieldErrors = errs
h.Templates.Render(w, "operator_plan_ladder_detail.html", PlanLadderDetailData{
Edit: edit,
Tiers: h.loadPlanLadderTiersData(r, ladderID),
Validation: h.loadLadderScopedValidation(r.Context(), 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
}
return PlanLadderEditData{
Ladder: PlanLadderViewModel{
PlanLadderID: ladder.PlanLadderID,
LadderKey: ladder.LadderKey,
Name: ladder.Name,
Description: desc,
IsActive: ladder.IsActive,
},
}, 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 := r.PathValue("ladderID")
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. Please 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)
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),
Validation: h.loadLadderScopedValidation(r.Context(), 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),
Validation: h.loadLadderScopedValidation(r.Context(), 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)
}
// loadLadderScopedValidation filters the global structural-invariant checks to
// the violations that concern this one ladder (non-plan tiers attached here, and
// any multi-active pool attachments on this ladder). Orphan products are global
// (they belong to no ladder), so they are intentionally excluded from a ladder
// page. Reuses computePlanLadderValidation so the checks never diverge.
func (h *OperatorPartialsHandler) loadLadderScopedValidation(ctx context.Context, ladderID string) PlanLadderValidationData {
all := h.computePlanLadderValidation(ctx)
scoped := PlanLadderValidationData{}
for _, p := range all.MultiActivePools {
if p.LadderID == ladderID {
scoped.MultiActivePools = append(scoped.MultiActivePools, p)
}
}
return scoped
}
// 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,
Validation: h.loadLadderScopedValidation(r.Context(), ladderID),
Error: errMsg,
})
}
// 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,
LadderKey: ladder.LadderKey,
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,
}
}
}
// Load available products: any active, published product not already in this
// ladder (Doc 41: display_category never gates tier membership).
products, err := h.BillingQ.ListActiveProducts(r.Context())
if err != nil {
h.Logger.Error("failed to list products", slog.Any("error", err))
} else {
existingTiers, _ := h.BillingQ.ListTiersByLadder(r.Context(), ladderID)
existingProductIDs := make(map[string]bool)
for _, t := range existingTiers {
existingProductIDs[t.ProductID] = true
}
for _, p := range products {
if p.LifecycleStatus != "published" {
continue // skip non-published products
}
if existingProductIDs[p.ProductID] {
continue // skip already-attached products
}
data.AvailableProducts = append(data.AvailableProducts, ProductOption{
ProductID: p.ProductID,
Name: p.Name,
})
}
}
return data
}
func (h *OperatorPartialsHandler) renderPlanLadderValidationPage(w http.ResponseWriter, r *http.Request, success string, errMsg string) {
fireSuccessToast(w, success)
fireErrorToast(w, errMsg)
if errMsg != "" {
h.Templates.Render(w, "operator_plan_ladder_validation.html", PlanLadderValidationData{Error: errMsg})
return
}
h.Templates.Render(w, "operator_plan_ladder_validation.html", h.computePlanLadderValidation(r.Context()))
}
// computePlanLadderValidation runs the three structural-invariant checks
// (orphan plan products, non-plan tiers, multi-active attachments). It is the
// single source of truth shared by the validation page
// (renderPlanLadderValidationPage) and the topology overview's health strip
// (loadPlanTopologyData), so the two never disagree.
func (h *OperatorPartialsHandler) computePlanLadderValidation(ctx context.Context) PlanLadderValidationData {
var data PlanLadderValidationData
// Orphan products: published products on no ladder. Doc 41 dissolved the
// plan/non-plan distinction (display_category is presentation-only), so this
// is now purely informational — an off-ladder product is legitimate.
products, err := h.BillingQ.ListAllProducts(ctx)
if err != nil {
h.Logger.Error("failed to list products", slog.Any("error", err))
} else {
for _, p := range products {
if p.LifecycleStatus == "published" {
ladders, _ := h.BillingQ.ListLaddersByProduct(ctx, p.ProductID)
if len(ladders) == 0 {
data.OrphanProducts = append(data.OrphanProducts, ProductOption{
ProductID: p.ProductID,
Name: p.Name,
})
}
}
}
}
// Multi-active attachments: pools with multiple active attachments on the
// same ladder. Impossible under the GiST exclusion, checked defensively.
rows, err := h.Database.QueryContext(ctx, `
SELECT pool_id, plan_ladder_id, COUNT(*) as cnt
FROM core.pool_provision_ladders
WHERE status = 'active'
GROUP BY pool_id, plan_ladder_id
HAVING COUNT(*) > 1
`)
if err != nil {
h.Logger.Error("failed to query multi-active attachments", slog.Any("error", err))
} else {
defer rows.Close()
for rows.Next() {
var poolID, planLadderID string
var cnt int64
if err := rows.Scan(&poolID, &planLadderID, &cnt); err == nil {
ladder, _ := h.BillingQ.GetPlanLadderByID(ctx, planLadderID)
data.MultiActivePools = append(data.MultiActivePools, MultiActivePoolViewModel{
PoolID: poolID,
LadderID: planLadderID,
LadderKey: ladder.LadderKey,
ActiveCount: cnt,
})
}
}
rows.Close()
}
// Malformed rank sequences: ladders whose tier ranks are not 0-based
// contiguous. Impossible via the UI (append at MAX+1, reorder/removal
// renumber); rank 0 is load-bearing (org-type default resolution joins
// on rank = 0, member catalog branches on it), so surface any drift
// here instead of letting a defaultless ladder read as mysteriously
// broken elsewhere (2026-07-23 finding).
rankRows, err := h.Database.QueryContext(ctx, `
SELECT l.plan_ladder_id, l.name, MIN(t.rank), MAX(t.rank), COUNT(*)
FROM core.plan_ladder_tiers t
JOIN core.plan_ladders l ON l.plan_ladder_id = t.plan_ladder_id
GROUP BY l.plan_ladder_id, l.name
HAVING MIN(t.rank) <> 0 OR MAX(t.rank) <> COUNT(*) - 1
ORDER BY l.name, l.plan_ladder_id
`)
if err != nil {
h.Logger.Error("failed to query malformed rank ladders", slog.Any("error", err))
} else {
defer rankRows.Close()
for rankRows.Next() {
var vm MalformedRankLadderViewModel
if err := rankRows.Scan(&vm.LadderID, &vm.Name, &vm.MinRank, &vm.MaxRank, &vm.TierCount); err == nil {
data.MalformedRankLadders = append(data.MalformedRankLadders, vm)
}
}
rankRows.Close()
}
return data
}