Operator pages show a failed read as an empty or settled answer instead of an error #207

Open
opened 2026-10-09 16:03:03 +00:00 by cgalo5758 · 5 comments
Owner

What happens

This is the operator side of #197. Several operator pages treat a failed database read as an empty answer. The section shows its empty state, or a verdict built from the missing rows, and nothing on the page says a read failed. Most of these reads leave no log line.

  • Products list and product page. buildProductListViewModels logs each failed batch read and builds the rows as if the read had returned nothing:

    • A failed product-shape read makes every published product read Incomplete, "Missing: entitlement set".
    • A failed price read makes a listed product read "Missing: Active price". A failed price-mapping read makes it read "Missing: payment processing".
    • A failed read of the entitlement sets' rules or of the resource keys leaves the check that the product's provider is configured with nothing to check. A product whose provider is not configured then reads Purchasable.

    The product page's readiness panel (loadProductEditDataResult, loadProviderReadinessRows) does the same with its own reads. After a failed mapping read it says the price is not registered with Stripe and offers Sync to Stripe. After a failed ladder read it says "Not a tier on any ladder."

  • Organization page. loadOrgEnrollmentData shows "Organization not found" for any error reading the organization, including a database fault.

    • Pools panel. A failed read of a pool's plan positions shows "No plan is delivering into this pool". A failed product, ladder or tier read shows a delivery with no product name, on a ladder with no name, at "rank 0", the ladder's bottom tier. A failed usage read drops the usage table.
    • Billing summary. A failed account read shows "No billing account exists for this organization yet." Failed subscription, invoice and balance reads show "No subscriptions", "No invoices issued" and a zero balance.
    • Issue grant form. A failed read in subscriptionHeldLadders drops the ⚠ warning that a grant would replace a tier a subscription pays for.

    None of these reads is logged.

  • Grants page and the organization's grants ledger. buildGrantViewModels drops name-read errors without logging them.

    • A failed person read shows "System" under Granted by, although a person issued the grant.
    • A failed product read leaves the product blank. The ledger shows "—", and the Grants page labels the row "Entitlement set".
    • A failed organization read leaves the Grants page's organization link with no text.
  • Plan topology and the plan ladders list. loadPlanTopologyData discards the errors of the reads it makes for each ladder:

    • A failed count shows "0 organizations".
    • A failed tier read empties the ladder's column and lists its products as off-ladder.
    • Failed shared-product and rank-0 reads show "No products are shared across ladders." and "No default (off-ladder)" for every org type.

    loadPlanLaddersPageData shows 0 tiers and 0 organizations when a ladder's reads fail, and offers Delete, which the delete handler then refuses after counting again.

  • Billing. The invoice page (loadOperatorInvoiceDetailData) answers 404, "There is no operator page at …", for any failed read of the invoice, not only a missing invoice, and logs nothing. The billing lists hide rows recorded in the Stripe mode (test or live) that the configured key is not in. When resolveEnvFilter fails to read which rows those are, it logs a warning and hides nothing. The other mode's rows then appear among the key's own, unmarked, and no line says they are there.

Some sections already report their own failure. On the organization page, Tier changes, Members, Grants and Entitlement changes show a load error in place of their content. The product page answers "Could not load this product right now. Try again." when the product read fails.

What should happen

When a read fails, the error is logged and the page reports the failure where the answer would have appeared:

  • A section shows its load error in place of its empty state.
  • A verdict or count that depends on the read says it could not be read; it does not state a result.
  • A name that could not be read does not turn into "System", "Entitlement set" or "rank 0".
  • A record named in the URL that cannot be read gets the answer the product page gives. Only a missing row answers "not found".

Where

  • internal/server/operator_products.go: buildProductListViewModels, loadProductEditDataResult, loadProviderReadinessRows
  • internal/server/operator_enrollment.go: loadOrgEnrollmentData, subscriptionHeldLadders
  • internal/server/operator_partials.go: buildGrantViewModels
  • internal/server/operator_topology.go: loadPlanTopologyData
  • internal/server/operator_plan_ladders.go: loadPlanLaddersPageData
  • internal/server/operator_billing.go: loadOperatorInvoiceDetailData, resolveEnvFilter

The batch name reads #10 asks for would replace the per-row name reads on the grants and organization pages, so the name cases can be fixed with that work.

Why it matters

During a passing database fault, an operator can be told any of these:

  • a product is purchasable when nothing checked its provider
  • an organization does not exist, or has no plan or billing account
  • the system issued a grant that a person issued

The operator can act on these. For example, they can issue a grant over a tier a subscription pays for without seeing the warning that the subscription keeps billing. Most of these reads leave no log line, so nothing records the fault behind the claim.

## What happens This is the operator side of #197. Several operator pages treat a failed database read as an empty answer. The section shows its empty state, or a verdict built from the missing rows, and nothing on the page says a read failed. Most of these reads leave no log line. - **Products list and product page.** `buildProductListViewModels` logs each failed batch read and builds the rows as if the read had returned nothing: - A failed product-shape read makes every published product read Incomplete, "Missing: entitlement set". - A failed price read makes a listed product read "Missing: Active price". A failed price-mapping read makes it read "Missing: payment processing". - A failed read of the entitlement sets' rules or of the resource keys leaves the check that the product's provider is configured with nothing to check. A product whose provider is not configured then reads Purchasable. The product page's readiness panel (`loadProductEditDataResult`, `loadProviderReadinessRows`) does the same with its own reads. After a failed mapping read it says the price is not registered with Stripe and offers Sync to Stripe. After a failed ladder read it says "Not a tier on any ladder." - **Organization page.** `loadOrgEnrollmentData` shows "Organization not found" for any error reading the organization, including a database fault. - **Pools panel.** A failed read of a pool's plan positions shows "No plan is delivering into this pool". A failed product, ladder or tier read shows a delivery with no product name, on a ladder with no name, at "rank 0", the ladder's bottom tier. A failed usage read drops the usage table. - **Billing summary.** A failed account read shows "No billing account exists for this organization yet." Failed subscription, invoice and balance reads show "No subscriptions", "No invoices issued" and a zero balance. - **Issue grant form.** A failed read in `subscriptionHeldLadders` drops the ⚠ warning that a grant would replace a tier a subscription pays for. None of these reads is logged. - **Grants page and the organization's grants ledger.** `buildGrantViewModels` drops name-read errors without logging them. - A failed person read shows "System" under Granted by, although a person issued the grant. - A failed product read leaves the product blank. The ledger shows "—", and the Grants page labels the row "Entitlement set". - A failed organization read leaves the Grants page's organization link with no text. - **Plan topology and the plan ladders list.** `loadPlanTopologyData` discards the errors of the reads it makes for each ladder: - A failed count shows "0 organizations". - A failed tier read empties the ladder's column and lists its products as off-ladder. - Failed shared-product and rank-0 reads show "No products are shared across ladders." and "No default (off-ladder)" for every org type. `loadPlanLaddersPageData` shows 0 tiers and 0 organizations when a ladder's reads fail, and offers Delete, which the delete handler then refuses after counting again. - **Billing.** The invoice page (`loadOperatorInvoiceDetailData`) answers 404, "There is no operator page at …", for any failed read of the invoice, not only a missing invoice, and logs nothing. The billing lists hide rows recorded in the Stripe mode (test or live) that the configured key is not in. When `resolveEnvFilter` fails to read which rows those are, it logs a warning and hides nothing. The other mode's rows then appear among the key's own, unmarked, and no line says they are there. Some sections already report their own failure. On the organization page, Tier changes, Members, Grants and Entitlement changes show a load error in place of their content. The product page answers "Could not load this product right now. Try again." when the product read fails. ## What should happen When a read fails, the error is logged and the page reports the failure where the answer would have appeared: - A section shows its load error in place of its empty state. - A verdict or count that depends on the read says it could not be read; it does not state a result. - A name that could not be read does not turn into "System", "Entitlement set" or "rank 0". - A record named in the URL that cannot be read gets the answer the product page gives. Only a missing row answers "not found". ## Where - `internal/server/operator_products.go`: `buildProductListViewModels`, `loadProductEditDataResult`, `loadProviderReadinessRows` - `internal/server/operator_enrollment.go`: `loadOrgEnrollmentData`, `subscriptionHeldLadders` - `internal/server/operator_partials.go`: `buildGrantViewModels` - `internal/server/operator_topology.go`: `loadPlanTopologyData` - `internal/server/operator_plan_ladders.go`: `loadPlanLaddersPageData` - `internal/server/operator_billing.go`: `loadOperatorInvoiceDetailData`, `resolveEnvFilter` The batch name reads #10 asks for would replace the per-row name reads on the grants and organization pages, so the name cases can be fixed with that work. ## Why it matters During a passing database fault, an operator can be told any of these: - a product is purchasable when nothing checked its provider - an organization does not exist, or has no plan or billing account - the system issued a grant that a person issued The operator can act on these. For example, they can issue a grant over a tier a subscription pays for without seeing the warning that the subscription keeps billing. Most of these reads leave no log line, so nothing records the fault behind the claim.
Author
Owner

More operator pages show a failed read the same way:

  • Billing lists. The billing accounts, subscriptions, invoices and payments lists (internal/server/operator_billing.go) read each row's organization and Stripe mapping one row at a time. A failed organization read leaves the row without the organization's name, and a failed mapping read shows the row as not mapped. The customer lookup in internal/server/operator.go loses a candidate's organization name the same way.
  • Setup step and entitlement sets. The setup step (internal/server/setup_state.go) skips a set whose rules cannot be read, so it can read complete when every rule read fails. The entitlement sets list (internal/server/operator_entitlement_sets.go) shows 0 rules for a set whose rules could not be read.
  • Product prices. The prices table on the product page (internal/server/operator_billing.go) shows a price as not mapped when its mapping read fails.
More operator pages show a failed read the same way: - **Billing lists.** The billing accounts, subscriptions, invoices and payments lists (`internal/server/operator_billing.go`) read each row's organization and Stripe mapping one row at a time. A failed organization read leaves the row without the organization's name, and a failed mapping read shows the row as not mapped. The customer lookup in `internal/server/operator.go` loses a candidate's organization name the same way. - **Setup step and entitlement sets.** The setup step (`internal/server/setup_state.go`) skips a set whose rules cannot be read, so it can read complete when every rule read fails. The entitlement sets list (`internal/server/operator_entitlement_sets.go`) shows 0 rules for a set whose rules could not be read. - **Product prices.** The prices table on the product page (`internal/server/operator_billing.go`) shows a price as not mapped when its mapping read fails.
Author
Owner

Three more places that read a failure as an answer.

The tier reorder's commit treats a failed ListOrgTypes as "this ladder is no type's default": it saves the new order, enacts nothing, and answers that no organizations were affected, while the organizations stay on the outgoing default.

The Stripe sync action reads a failed price list as "no active price" and answers "Add an active price before syncing to Stripe." Its probe for a dead-lettered sync reads a failed query as "not failed", so a dead-lettered sync answers "A Stripe sync is already in progress" and the product's readiness panel shows no failure.

When the sync action's read of the product fails, the page it re-renders reads the product again and answers "Product not found" whatever that second read's failure was; "Failed to load the product." shows only when the second read succeeds. The full product page tells the two apart; this re-render does not.

Three more places that read a failure as an answer. The tier reorder's commit treats a failed `ListOrgTypes` as "this ladder is no type's default": it saves the new order, enacts nothing, and answers that no organizations were affected, while the organizations stay on the outgoing default. The Stripe sync action reads a failed price list as "no active price" and answers "Add an active price before syncing to Stripe." Its probe for a dead-lettered sync reads a failed query as "not failed", so a dead-lettered sync answers "A Stripe sync is already in progress" and the product's readiness panel shows no failure. When the sync action's read of the product fails, the page it re-renders reads the product again and answers "Product not found" whatever that second read's failure was; "Failed to load the product." shows only when the second read succeeds. The full product page tells the two apart; this re-render does not.
Author
Owner

The fix has two parts.

Each section carries its own error. Five sections of the organization page already do: OrgEnrollmentData holds ProductsError, MembersError, GrantsError, TransitionsError and EntitlementChangesError (internal/server/operator_enrollment.go:529-533), and loadOrgGrants (line 1600) sets the grants or the grants error. These are the sections that already show a load error. #121 proposes this shape for any page made of sections: one loader per section, each with its own error field. This fix gives it to every section listed above. Each loader returns the section's data and its error as one value, and the section's template shows the error state whenever the error is set. A failed read then has no path to the empty state.

A test-kit querier that fails one named read. TestOrgEnrollmentReadFailures (internal/server/operator_enrollment_read_failures_db_test.go:308) builds one organization, then loads the page once per row of enrollmentReadFailures() (line 338), each time with a querier that fails one named method, and checks what the section shows. Its rows pin today's answers ("a failed rank read shows rank 0", "a failed balance read shows a zero balance"), so the fix flips them on purpose. TestProductsListReadFailures, TestPlanLaddersReadFailures and TestBillingListsKeepTheirFallbacksWhenAReadFails have the same shape for three more pages here.

Each of these tests writes its failing queriers by hand, one method at a time: the tests in internal/server hold 21 failing… querier types across nine files. A test-kit querier that fails whichever method it is given would let every loader this issue names get the same table cheaply: one row per read, each checking that section's error state.

The fix has two parts. **Each section carries its own error.** Five sections of the organization page already do: `OrgEnrollmentData` holds `ProductsError`, `MembersError`, `GrantsError`, `TransitionsError` and `EntitlementChangesError` (`internal/server/operator_enrollment.go:529-533`), and `loadOrgGrants` (line 1600) sets the grants or the grants error. These are the sections that already show a load error. #121 proposes this shape for any page made of sections: one loader per section, each with its own error field. This fix gives it to every section listed above. Each loader returns the section's data and its error as one value, and the section's template shows the error state whenever the error is set. A failed read then has no path to the empty state. **A test-kit querier that fails one named read.** `TestOrgEnrollmentReadFailures` (`internal/server/operator_enrollment_read_failures_db_test.go:308`) builds one organization, then loads the page once per row of `enrollmentReadFailures()` (line 338), each time with a querier that fails one named method, and checks what the section shows. Its rows pin today's answers ("a failed rank read shows rank 0", "a failed balance read shows a zero balance"), so the fix flips them on purpose. `TestProductsListReadFailures`, `TestPlanLaddersReadFailures` and `TestBillingListsKeepTheirFallbacksWhenAReadFails` have the same shape for three more pages here. Each of these tests writes its failing queriers by hand, one method at a time: the tests in `internal/server` hold 21 `failing…` querier types across nine files. A test-kit querier that fails whichever method it is given would let every loader this issue names get the same table cheaply: one row per read, each checking that section's error state.
Author
Owner

A note from fixing #153. The product page now logs a failed read of the product's ladders (product edit: tier read failed), and its Entitlement set field no longer treats that failure as the product being on no ladder: it offers no "None" and saves nothing. The page's "Plan ladders" section still answers "Not a tier on any ladder." after a failed read; that answer stays with this issue.

A note from fixing #153. The product page now logs a failed read of the product's ladders (`product edit: tier read failed`), and its Entitlement set field no longer treats that failure as the product being on no ladder: it offers no "None" and saves nothing. The page's "Plan ladders" section still answers "Not a tier on any ladder." after a failed read; that answer stays with this issue.
cgalo5758 referenced this issue from a commit 2026-10-27 00:03:58 +00:00
Author
Owner

One more read of the same kind, on a plan ladder's page. The Add tier panel builds its product list in availableTierProducts (internal/server/operator_plan_ladders.go), which reads the ladder's tiers to leave them out of the list and drops that read's error without logging it (existingTiers, _ := h.BillingQ.ListTiersByLadder(...)). After a failed tier read the panel offers the products that are already tiers of the ladder; choosing one is then refused at the insert with "This product is already a tier in this ladder.", so nothing wrong is written, but the list is not what the ladder allows and the fault leaves no log line.

One more read of the same kind, on a plan ladder's page. The Add tier panel builds its product list in `availableTierProducts` (`internal/server/operator_plan_ladders.go`), which reads the ladder's tiers to leave them out of the list and drops that read's error without logging it (`existingTiers, _ := h.BillingQ.ListTiersByLadder(...)`). After a failed tier read the panel offers the products that are already tiers of the ladder; choosing one is then refused at the insert with "This product is already a tier in this ladder.", so nothing wrong is written, but the list is not what the ladder allows and the fault leaves no log line.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: wiki-cafe/member-console#207