Commit Graph
36 Commits
Author SHA1 Message Date
cgalo5758 2f1803d1e6 Mark a subscription that delivers nothing with a badge, on the organization page and in the subscriptions list
Section 8 of #212's change, after review: the Billing card's muted line
under the Subscriptions row becomes an amber "Not delivering" badge on
the row's value line, after the state badge, with a title that names
the ladders and the holder ("Billing continues while a grant holds Demo
Ladder."; with several subscriptions "Billing continues on <k> of <n>
subscriptions while ..."). The operator's subscriptions list marks each
displaced subscription's row with the same badge and title, read with
one grouped statement per page and turned into titles by the same steps
the card uses. A failed read on the list says "Failed to load
deliveries." once above the table and marks no row. A paused
subscription no longer counts: it generates no invoice, so "Billing
continues" would be false; it is marked once it is active again.

The grant form's warning now reads "Products marked ⚠ would supersede a
tier a subscription pays for; the subscription keeps billing and
delivers nothing on that ladder until the grant ends." The billing view
it named before can change nothing. A Stripe-paused subscription's tier
is still marked, an accepted edge until #269's change, which pauses a
displaced subscription's billing and rewords the badge and the warning.

Goldens: 16 change, each as item 8.9 lists: the organization page's
JSON goldens rename DisplacementNote to DisplacementTitle, the two
subscriptions-list goldens gain the title field, three HTML goldens
carry the new warning, and grant-over-subscription.html the badge in
place of the line. No other golden changed.

Breaks (scripts/mutation-check, 31 of 31 CAUGHT):
- paused restored
- not_delivering removed from badgeMap
- badge tone
- list badge without title
- card title never set
- card template without badge
- account form never chosen
- n read as displaced count
- subscription holder as grant
- several holders as one
- ladders joined with commas
- sort removed
- ladders not deduplicated
- holders not deduplicated
- status filter dropped (card)
- status filter dropped (list)
- list row in account form
- list row in account form (list vs card)
- list titles never set (the field set to strings.TrimPrefix of the
  title by itself, an empty string, because removing the line leaves
  titles unused and does not compile)
- delivering row given the displaced title
- list template without badge
- list pool not default
- list reads moved into the row loop (emulated: an inline closure calls
  the title read once per row; a helper only the break called would fail
  deadcode)
- list DisplacementError assignment and L4 removed
- O4 template line removed
- O4 line moved under the table
- rows dropped on a failed read
- badge label
- card badge without title
- delivering row given an entry
- old warning restored

Hand capture on the Stripe sandbox (API 2025-02-24.acacia): a member
subscribed to a $20/month tier through Checkout; an operator issued a
complimentary grant of the tier above; an edit to the subscription at
Stripe then reconciled it, and the reconcile logged "reconcile: product
not conferred; another plan holds its ladder". The Billing card and the
subscription's list row each showed Active and Not delivering at 1280
and at the 390 mobile width, with no sideways page scroll; the badge's
title read by script: "Billing continues while a grant holds Demo
Ladder." Captures under test/screens/displaced-subscription-waits/.

The suite on reset databases passed on this section's tree, 4711 tests,
14 skipped; make screens changed no screen this section owns, and every
registered form was captured. The review's last fixes (the list test's
row checks now fail when a row is missing, two comment rewraps, task
wording) came after it; the list test and its five breaks were rerun on
them: pass, 5 of 5 CAUGHT. make lint passes; openspec validate --strict
passes.
2026-10-26 17:38:12 -05:00
cgalo5758 8fdecf0e0e Stop tiering products with no entitlement set in tests, and leave them out of the tier picker
First section of the change that makes a product with no entitlement set
unable to be a plan tier (#153). The change's proposal, design, tasks and
spec deltas come in with it.

Fixtures: seven tests that made a product with no set a ladder tier give
it one (productWithSet in internal/db, mkTierProduct in internal/server,
tierProduct in the merchant-disclosure sheet). The sheet is a capture
make test does not run; its edit was checked by reading and go vet. The
test kit's WithoutSet comment says that from migration 00035 on a tier's
set cannot be cleared.

The failure the plan-act goldens inject moves from a product with no set
to a product made draft after it is laddered, which core.confer refuses
at the same statement and step. Four goldens under
internal/server/testdata/plan_acts change only there: the product reads
Drafted instead of NoSet, and the driver text reads "is draft and not
conferrable". TestResumption_ARefusedConferralIsRolledBackAlone drafts
Plus the same way.

The tier picker: tierCandidates keeps a product that is published, has
an entitlement set and is not on the ladder, in the order given;
availableTierProducts calls it. The Add tier panel's empty state keeps
its first sentence and says "A tier needs a published product with an
entitlement set that is not on this ladder yet." A posted product the
list left out is refused with "Choose a product from the list."

make screens: operator-plan-ladder-detail's open and invalid captures
change at both widths. The demo's three products on no ladder have no
set, so the demo ladder has nothing to add and the open panel shows the
empty state. The other changed screens are the known data-only
differences and the host's time zone (#274).

Read-only counts on the test stack's app database before any reset:
no default ladder starts on a product with no set, no plan act waits for
one, and the two tiers with no set sat on a ladder the browser
walkthroughs had left behind.

Breaks (scripts/mutation-check, 11 of 11 CAUGHT):
- default-initiate-fails without its draft
- default-migrate-restore-fails without its draft
- remove-second-pool-fails without its draft
- reorder-an-organization-fails without its draft
- the draft removed from the refused conferral test
- the tier filter without the set
- the tier candidates in reverse order
- the empty state's old second sentence restored
- the tier add accepts a product the list left out
- the tier filter without the lifecycle
- the tier filter keeps a product on the ladder

The seven fixture edits and the WithoutSet comment carry no break: they
remove a state section 2's triggers forbid, and section 2's run is their
test.

make lint passes; openspec validate --strict passes; the suite on reset
databases passes, 4694 tests, 15 skipped.
2026-10-26 16:08:09 -05:00
cgalo5758 04862abe9c Say on the organization page which ladder a subscription delivers nothing on
An operator could not see that a subscription bills while another plan
holds the ladder it pays for. The organization page's Billing card now
says it under the subscriptions row, from the same rule the reconcile
uses: the page reads the account's displacements, the subscriptions'
current items and their rungs once per account, and calls
fulfillment.RungsByProduct and DisplacedProducts over them, so a
subscription whose item moved off the ladder reads no line.

The line, for one subscription: "Delivers nothing on <ladders>, which a
grant holds" (or "which another subscription holds", "which another
plan holds", "which other plans hold"); for several: "<k> delivers
nothing on ..." or "<k> deliver nothing on ...". Ladders are distinct,
sorted by name, joined with joinWithAnd. A canceled or incomplete
subscription reads no line. A failed read of the displacements, the
items or their shapes logs and shows "Failed to load deliveries." in the
line's place, styled as the page's other errors, never an absent line.

Queries: ListAccountSubscriptionDisplacements (entitlements, one
statement per account with ladder names by join) and
ListCurrentSubscriptionItemsByBillingAccountID (billing). The page's
read count stays fixed per account (a read-count row grows displaced
subscriptions), and TestOrgEnrollmentReadFailures fails each new read.
The template uses trim markers, so every existing HTML golden is
unchanged; the new state grant-over-subscription shows the line, and
eight JSON goldens gain the two empty fields. plan-management.md,
stripe.md and plan-ladders-transitions.md now say the organization page
reads the rule.

Tests: TestDisplacementNote, TestDisplacedOnPage,
TestSubscriptionMayBeDisplaced, TestDisplacementLineFor (a canceled
subscription reads no line), the golden state, the read-count and
read-failure rows.

Breaks (scripts/mutation-check, 14 of 14 CAUGHT):
- DisplacementNote never set
- the template without the line
- a subscription holder worded as a grant
- several holders worded as one
- the ladders joined with commas only
- the plural form never chosen
- the sort removed
- the ladders not deduplicated
- the holders not deduplicated
- canceled admitted
- displacedOnPage without the current products
- the displacement read in a loop over the subscriptions
- the DisplacementError assignment and the L3 log line removed
- the status filter at the call site removed

make screens: no screen shows the line (the demo seed holds no
displaced subscription). Fourteen screens differ from the baseline, none
from this change: the host's time zone moved from Eastern to Central,
and the captures render pinned times in the host's zone, so every time
column reads an hour earlier; the Stripe integration page's wall-clock
"last read back" line and the five data-only differences seen on every
run tonight are the rest.

The suite on reset databases passes, 4686 tests, 15 skipped. make lint
passes; openspec validate --strict passes.
2026-10-26 15:03:55 -05:00
cgalo5758 dc2fd46aa0 Show an operator a suspended delivery, and say a suspended plan's end
The organization page's Pools panel listed only active deliveries, so a
plan whose payment is due vanished from it and the Issue grant form did
not warn that a grant would supersede it. The panel now lists a
suspended delivery with a Suspended badge, taken from the delivery's
first row (the status trigger copies a provision's status to every rung
that has not ended), and subscriptionHeldLadders holds its ladder, so
the supersession warning shows. ExcludeSuspended is deleted: nothing
reads it any more.

The Billing card's subscription summary orders states unpaid, past_due,
paused, canceling, active, trialing, so the worst state names the card.
A suspending state drops "renews <date>", which names a period nobody
has paid for; one set to cancel at the period end keeps "ends <date>",
which is true, since Stripe cancels it then whatever its invoice says,
and is the card's only sign that the member already cancelled. The copy
table's row 14 says so. The subscription's Unpaid badge has its own key,
subscription_unpaid, in the danger tone, used by the card's
Subscriptions badge and the subscriptions list through one helper; the
invoice's and the balance's Unpaid keep the warning tone.

Rides along, listed in the proposal's quick fixes: a single
incomplete_expired subscription said "renews <date>"; like a canceled
one it now says nothing.

The design's S10 row now says the hourly invoice sweep re-reads an
invoice still open a day after its last read, so an uncollectible one
reads open for up to a day, not until its next event.

Tests: TestOrgEnrollmentGolden's suspended-subscription changes on
purpose (Small2 delivering with its badge, the supersession warning, no
renewal note), four JSON goldens gain "Suspended": false;
TestSummarizeSubscriptions gains the precedence, no-note, ends and
incomplete-expired rows; TestSubscriptionUnpaidBadgeIsDanger pins the
label and the tones.

Suite 4613 pass, 15 skip, 0 fail after the review's fixes. make screens:
only the known data-only differences (regenerated ids, a domain's age).

Breaks, all CAUGHT:
  pools panel loop filters suspended positions
  subscriptionHeldLadders skips suspended positions
  delivery Suspended never set
  precedence without unpaid ahead of past_due
  paused left out of the precedence
  subscriptions row unpaid badge through the shared key
  subscriptions list unpaid badge through the shared key
  note kept for a suspending state
  unpaid label through the Suspended word
  unpaid not a suspending state for the note
  paused not a suspending state for the note
  past due ranked below paused
  shared unpaid key drawn in danger
  past due drawn as unpaid
  suspending state says nothing even when canceling
  incomplete expired renews
2026-10-24 04:22:47 -04:00
cgalo5758 ddd57bf162 Show a suspended plan in the member's entitlements panel with its state
The "Your entitlements" panel listed only active plan positions, so a
plan whose payment is due read as no plan at all. It now lists every
live position, one line per ladder from the ladder's first row in query
order, which is its active row when it has one, and badges a suspended
line "Payment due" (danger) or "Paused" (secondary). An active row
never carries a badge, whatever its subscription's status says. The
badge comes from badgeMap through a new payment_due key, so the map
stays the one place a state becomes a label.

orgHasEntitlement counts a suspended plan too. Its only caller is the
dashboard's empty state, which grants no access, so a member whose
payment is due is no longer told they hold nothing.

Tests: TestLadderRowsYieldOneLinePerLadder (one line per ladder from its
active row, and an active row whose subscription is past_due carries no
badge); TestMemberEntitlementsGolden's past-due-plan changes on purpose
(the plan's line with Payment due, and Enrolled true, which the
template's empty-state check reads) and gains paused-plan; three JSON
goldens gain "State": "" and nothing else; TestOrgHasEntitlement's
payment-due row now wants true.

Suite 4605 pass, 15 skip, 0 fail after the review's fixes. make screens:
only the known data-only differences (regenerated ids, a domain's age).

Breaks, all CAUGHT:
  ladderRows keeps ExcludeSuspended
  State always payment_due
  the template without the badge
  orgHasEntitlement keeps ExcludeSuspended
  ladderRows without the one-line skip
  subscription status decides an active row
2026-10-24 03:43:12 -04:00
cgalo5758 a7bea2fadf Let a member pay a plan's unpaid invoice on Stripe's page
A plan whose payment is due shows Pay invoice as its card's first
control, and an open invoice's detail shows it too. Each opens a console
route in a new tab: GET /billing/plans/{ladderID}/unpaid-invoice and
GET /billing/invoices/{invoiceID}/hosted. The route checks that the plan
or invoice belongs to the member's organization, the way the invoice
detail does and through the same gate function, reads the invoice from
Stripe when the member opens it, and answers 303 to its hosted invoice
page. The URL is a bearer link: it is never stored, logged or written
into a body, the redirect has no body, only an absolute https URL is
followed, and every answer carries Cache-Control: no-store.

The by-plan route answers /billing without a Stripe call for a plan
that is not suspended, a mapping the key cannot reach or no Stripe
querier, and for a latest invoice that is already paid; the by-invoice
route redirects a paid invoice to its receipt. A failed Stripe read logs
"member hosted invoice: stripe read failed" and answers /billing. Both
routes answer a refusal with the styled error page, reusing "The page
you requested does not exist." and "An unexpected error occurred. Try
again.", which the Copy table now lists as reused; the invoice detail,
a partial, keeps its plain answers. Pay invoice counts as a paid control
for the Products page's billing line.

Tests: TestHostedInvoiceRoutes (18 rows, another organization's plan and
invoice among them), TestInvoiceDetailOffersPayInvoice,
TestPayInvoiceReferencesTheBillingLine, new rows in
TestPlansOfferPaidMoveGate. The test log capture now carries With
attributes and keeps a repeated key's values, so the check that no line
carries the URL cannot be bypassed. Goldens: seven payment-due
member-plans pages gain Pay invoice; routes.golden and
routes-not-ready.golden gain the two routes (anonymous: 302 to /login);
route_pages.golden gains them as styled 404s.

Not caught by a break of their own: the by-plan gate's pool scoping and
its billing-account check guard together, and the other-organization
row catches only their conjunction (no single edit removes the scoping,
and the test kit cannot back one organization's position with another's
subscription); that the detail and the by-invoice route call one gate is
read in review; pageRefused's 500 branch has no row, since no fixture
can fail a read on the real database.

Rides along: the change's proposal and design name #255 to #258, the
issues filed for its out-of-scope items, and the #150 note; the design
gains S11, a sandbox check of an abandoned upgrade's invoice after its
pending update expires.

Suite 4603 pass, 15 skip, 0 fail. make screens: only the known
data-only differences (regenerated ids, a domain's age).

Breaks, all CAUGHT:
  by-invoice route without the ownership gate
  by-invoice route without the environment check
  by-invoice route redirecting only an open invoice
  by-invoice route accepting a URL that is not https
  by-plan route without its suspended check
  by-plan route redirecting a paid latest invoice
  by-plan route without its environment check
  by-plan read without the expand of latest_invoice
  by-plan route answering a missing position 303 to /billing
  by-invoice handler without its own Cache-Control
  by-plan handler without its own Cache-Control
  the hosted URL logged on success
  failed invoice read without its log line
  failed subscription read without its log line
  card without Pay invoice
  Pay invoice on a paused plan
  Pay invoice drawn without the LadderID check
  plansOfferPaidMove not counting Pay invoice
  Pay invoice without its aria-describedby
  PayPath set for an invoice in the other environment
  PayPath set for a paid invoice
  by-plan route not registered
  refusal of a Pay invoice route as bare text
  the hosted URL logged through Logger.With
2026-10-24 03:12:37 -04:00
cgalo5758 996d6eee34 Hold a plan whose payment is due on the Products page and in the plan moves
Third section of #148, which fixes the issue's two points. A plan whose
subscription is past due, unpaid or paused is still the organization's:
the Products page shows it as the current plan and offers no Checkout
on its ladder, and the Checkout endpoint refuses one, so a second
Stripe subscription can no longer start while the first is retried.

The current card of such a plan reads "<price>/<interval>, payment due"
(or ", paused") in place of its renewal line, with "Entitlements are
suspended until the invoice is paid." (or "Entitlements are
suspended."). A past-due subscription may be a failed card or a renewal
waiting on the bank's 3-D Secure approval, so the words say what is
owed, not why. The badge tooltip that promised access until the period
end is dropped there, and the cancel confirmation names the end
("Pro ends on {date}.", or "..., then you move to {plan}.") followed by
"The unpaid invoice stays due."

The plan moves on that subscription:
- A switch, in either direction, and its preview are refused with
  "Pay the unpaid invoice before changing this plan." before any Stripe
  call, in the decision and in fulfillment, so no second invoice
  interleaves with the open one.
- Cancel plan at period end and Keep plan work as on an active plan.
  An immediate cancellation is refused: Stripe stops collecting every
  finalized invoice of the customer when a subscription is cancelled.
- Keep plan is refused once the scheduled downgrade has landed at
  Stripe, which the failed renewal usually means, so it cannot undo a
  change Stripe already bills; the page then draws no "moves to" line
  and no Keep plan. A landed downgrade is recognised by the schedule
  Stripe reports or, once Stripe has released that schedule, by the
  subscription already carrying the lower price, the test the reconcile
  uses; the second part comes from this section's review and the
  change's design says so.
- A paused plan offers no move at all and fulfillment refuses each one,
  with "A paused plan cannot be changed."; no console-made subscription
  reaches paused today.

HasActiveSubscriptionOnLadder becomes SubscriptionBacksLadder for its
wider meaning. The test kit gains ConferUnprojectedSuspended for a
subscription not yet projected. The member-plans goldens gain seven
states for these plans; the two characterization goldens of the
previous section now show the corrected page, and every other golden
gains only an empty Suspension field.

make screens: no changed screen comes from this section (the demo has
no suspended plan); the seven changed shots are the known data-only and
timing differences.

Full suite on reset databases: 4578 pass, 15 skip, none failed. make lint passes. Breaks,
each caught:
- resolveLadderSubscription keeps ExcludeSuspended (TestCancelASuspendedSubscriptionAtPeriodEnd)
- resolveLadderSubscription keeps ExcludeSuspended (TestSubscriptionBacksASuspendedLadder)
- SwitchPlan without its suspended refusal
- SwitchPlan refuses only after a Stripe read (the refusal after the first Stripe call of the downgrade)
- positionStatus never set
- the suspended refusal placed in resolveLadderSubscription instead of SwitchPlan
- KeepCurrentPlan without its landed-downgrade check
- the landed check without its price comparison
- the immediate branch without the check
- the immediate check placed after the schedule release
- SwitchPlan without the paused refusal
- KeepCurrentPlan without the paused refusal
- CancelSubscription without the paused refusal
- ErrSubscriptionSuspended returned for a paused subscription in SwitchPlan
- the suspended rule (15) removed (offer table)
- the suspended rule (15) removed (the page)
- the suspended rule applied before the held rung rule (8)
- the suspended rule applied before the free rung rule (14)
- the paused rule removed
- the paused rule placed after the held rung rule (8)
- onLadder without pos.Suspended
- positions keeps ExcludeSuspended (TestMemberPlansGolden)
- positions keeps ExcludeSuspended (TestCheckoutRefusesALadderWhosePlanIsSuspended)
- switchOffer without the suspended case
- the suspended case placed after the current-tier case
- ErrSubscriptionPaused never returned by switchOffer
- the refusal log line without subscription_status
- renderPlansAfterMove without its four cases
- renderPlansAfterMove without its row 17 case
- renderPlansAfterMove without its row 18 case
- KeepCurrentPlan without its paused refusal, seen through the page
- Suspension always payment_due (the paused branch removed)
- the payment-due price line in place of "ends"
- setCancelBodies without the appended sentence
- the sentence appended on the current card only (the loop narrowed to CanCancel)
- Cancel plan drawn on a paused plan
- Suspension payment_due only for past_due
- onLadder keeping a downgrade whose date is not after now
- onLadder dropping every downgrade of a suspended position
- setCancelBodies keeping "You keep {current} until {date}" on a payment-due plan
- setCancelBodies choosing the form of row 19 when another plan follows
- setCancelBodies choosing the form of row 20 when nothing follows
- the body of row 24 read as row 21
- the body of row 20 read as row 21
- CancelBadge keeps its Title on a suspended plan
- KeepCurrentPlan without rule 1's downgradeLanded test
- onLadder keeping a landed downgrade (the date check removed), seen through the refused Keep
- ConferUnprojectedSuspended without the suspension
- the paused rule removed (the page's move check)
2026-10-24 02:17:28 -04:00
cgalo5758 10057f8e3a Read suspended plan positions, and keep every reader's answer unchanged
Second section of #148. Goldens first pin what the member sees today for
a plan whose payment is due: the Products page treats the plan as not
held and offers Subscribe on every priced plan, the entitlements panel
reads "No active plan", and a plan of boolean entitlements reads as
none. Those are the wrong answers the later sections fix.

ListLadderPositionsByPool now reads live positions, active and
suspended, with the position's and the subscription's status, and puts
an active row before a suspended one on the same ladder so a suspended
row can never be chosen over an active one. Every reader filters
suspended rows out through one helper, entitlements.ExcludeSuspended:
the Products page's position loader, the entitlements panel, the
dashboard's entitlement check, the plan moves' subscription lookup, and
the operator's Pools panel and grant advisory. No existing answer,
golden or test result changes; each later section removes the filter
from the surfaces it releases. A test proves that a suspended position
confers nothing, because entitlements come only from active provisions.

The plan moves' lookup filters too, and no test of this section can see
it; its break runs with the section that releases those moves.

Also regenerated every sqlc package through make sqlc-generate, which
restores the license headers a bare sqlc generate drops: BRIDGEFILES
generated files that had lost their headers gain them back, with no
other change.

Full suite on reset databases before the review fixes: 4524 pass, 15 skip, none failed. The review fixes add a test arrangement and change comments and license headers; after them internal/entitlements, plans, fulfillment, server and the test kit pass. make lint passes. Breaks,
each caught:
- the position query keeps only active rows
- the position query without its active-first order
- the subscription status is NULL
- ExcludeSuspended keeps every row
- ExcludeSuspended keeps only active rows
- positionLoader.positions without ExcludeSuspended
- ladderRows without ExcludeSuspended
- orgHasEntitlement without ExcludeSuspended
- the Pools panel's loop without ExcludeSuspended
- subscriptionHeldLadders without ExcludeSuspended
- materialization reads suspended provisions
- the position query orders oldest first
2026-10-24 02:17:28 -04:00
cgalo5758 979e38b985 Suspend a test subscription the way the reconcile does
First section of #148. The test kit's Suspend suspended the provisions a
subscription confers and left the subscription itself active, a pair the
app never writes: the reconcile sets the subscription's Stripe status
(past_due, unpaid or paused) and suspends its provisions together.
Suspend now takes that status, writes it to the subscription row with
the row's own period, cancellation and end fields, and then suspends
and settles as before; any other status fails the test.

The three callers pass past_due. The organization page's golden for a
suspended subscription changes accordingly: its Billing card read
Active and now reads Past due. The other two callers read no
subscription status and keep their results.

This change also adds openspec/changes/suspended-plans-stay-held, the
plan for #148: a suspended plan stays the member's on every surface,
confers nothing, refuses a second Checkout and a switch, and offers a
way to pay the unpaid invoice.

Full suite on reset databases: 4517 pass, 15 skip, none failed. make
lint passes. Breaks, each caught:
- Suspend without the status write
- Suspend writes an empty period end
2026-10-24 02:17:28 -04:00
cgalo5758 bfc24e2c74 Serve before Temporal connects, and connect it in the background
Sixth section of #141, which closes the issue's defect: start no longer
waits on Temporal before it serves. Start derives the Stripe key's mode
and fingerprint, sets the SDK's key, opens the database, applies the
operator overrides and the boot data state, binds the listener, and only
then starts the Temporal connector and the identity provider's discovery
beside the server. A bind error returns before any dial or discovery
request. While Temporal is unreachable the console serves: sign-in, the
domain ask, the static files and every page that needs no workflow
answer as usual, and the Temporal-backed actions answer as the previous
section made them.

The connector runs the retry loop. Each attempt dials once (NewClient
no longer retries on its own; dialWithRetry and its budget are gone),
builds the worker and runs its precondition. A precondition that
refuses keeps the client and retries at the cap, so FedWiki's refusal
over open runs of a removed workflow type is read again on the same
connection; one that cannot read what it checks, a worker that fails to
build or start, closes the client and dials again. The attempt ends when
the worker starts: "connected to Temporal server" is logged with the
attempts and the time it took, then the holder is published and the
integrations' Startup hooks and the schedules run as before. "Temporal not ready yet, retrying" is a Warn until
temporal-connect-timeout (90 seconds by default), whose help now says
so, and an Error from then on, under the loop's decay. A Temporal OAuth
configuration that cannot be built still refuses at start.

start exits 1 when serving fails, at any point, and returns only after
the connector and discovery have returned. A failure before the
listener keeps the message it had (the overrides, the boot data state,
the integration connections); a serving failure logs "server failed to
start".

The Stripe webhook activity marks an event processing only from
received, processing or failed, so a run of an event already completed,
skipped or dead-lettered returns at once and dispatches nothing.

The handlers and drains that found no Temporal client logged that it
was not configured; with Temporal still connecting that was false, so
they log "Temporal is not connected" (the six plan-act goldens carry the
new drain line). Copies 13, 15 and 16 also carry the host and namespace.

Checked by hand on the test stack: with Temporal stopped, the console
served /login and the ask at once; the connector logged Warn, then
Error from the first attempt after 90 seconds; when Temporal came back
the same process logged "connected to Temporal server" after 14
attempts. With the issuer refusing, /login answered the styled 503 with
Retry-After: 5 and the ask answered.

Full suite on reset databases: 4513 pass, 15 skip, none failed. go test -race -p 1 over cmd,
auth, server, retryloop, workflows and the three integrations after
reset-test-db.sh: every package passes, no data race. make lint passes. Breaks, each caught
(section 1's UseKey break among them). Two depart from item 6.10's
first wording, which the item now carries: the synchronous-fetch break
sits between listen and the server's start (before listen the row would
fail waiting for the address, not at its one-second assertions), and the
"returns without waiting" break is caught by the fourth boot row, whose
fake dial holds for 200 ms of wall-clock time past the context's end,
the least that makes an early return observable.
- serveConsole without UseKey
- UseKey after the bound server's Serve
- Serve without go Discover
- Serve fetches the discovery document before serving
- serveConsole waits for connectTemporal before Serve
- connectTemporal started before Bind returns
- serveConsole returns without waiting for its goroutines
- serveConsole returns without waiting for its goroutines (race)
- skipSession without the ask's path
- connectTemporal publishes after afterConnect
- connectTemporal publishes before worker.Start
- connectTemporal returns after the escalation time
- the open-runs refusal closes the client
- ErrCannotCheck treated as a refusal
- worker.Start failure leaves the client open
- listRunsError without its Is method
- worker.Start failure logged from inside the attempt
- ProcessWebhookEvent without its status guard
- the guard without failed
- the guard without processing
- NewClient without the deadline
- connectTemporal under serveConsole's own context
- the attempt runs the hooks before copy 14
- Run logs copy 21 for a step failure
- the overrides failure is a plain error
- a Temporal authorization that cannot be built is not refused
2026-10-23 20:45:22 -04:00
cgalo5758 d1151dd6fe Wait for the identity provider's discovery document without blocking start
Third section of #141. start no longer fetches the identity provider's
discovery document before it serves. Setup returns a Config whose two
discovery values are empty, and Discover runs beside the server from
before the listener opens: it fetches the document, publishes the
verifier and then the sign-in endpoints, logs "identity provider
discovery succeeded; sign-in is available", and returns. Until it has,
/login and /callback answer a styled 503 "Sign-in unavailable" and
/register "Registration unavailable", each with Retry-After: 5 and a
"Try again" action, and a sign-in request wakes the loop's wait. A
document whose issuer is not the configured one, or that names no
end-session endpoint (or one that does not parse as an absolute URL),
publishes nothing that sign-in reads and is retried at the cap.

The two values sit behind two atomic.Pointers in auth.Config. The
sign-in value holds a pointer to the verifier value it was published
with, and is stored only after the verifier, from the same document, so
a request never pairs endpoints from one document with a verifier from
another. Re-derivation of roles reads the verifier alone, so it runs
while sign-in still waits for an end-session endpoint; POST /logout
before discovery ends the console's session, lands on /, and revokes
nothing. A Config built by hand, as most tests build theirs, reads its
exported fields as it did.

internal/retryloop is the loop both discovery and, in a later section,
the Temporal connection run: waits from 0.5 seconds doubling to a
15-second cap, plus up to half again of jitter; a wake ends a wait no
sooner than a second after the last attempt began; a failure marked
AtCap makes only the next wait's base the cap, and a wake does not end
that wait. Its log decay writes the first failure, a changed error at
most once per 30 seconds, otherwise one line per 5 minutes, Warn until
the escalation time and Error from it, with the line at the escalation
time written at once; every line's attempts is the attempts so far.
The htmx 5xx toast reads "Server error. Try again later."

Corrections to the change text, made while building: the wait after
an AtCap failure is the cap plus the jitter (the design's bound of
about 23 seconds after an outage already counted the jitter), and every
log line's attempts is the number of attempts so far (one meaning on
every line, matching elapsed), where the design said the cap alone and
the attempts since the last line.

internal/server/testdata/routes-not-ready.golden is new: it differs from
routes.golden in the four sign-in rows only. routes.golden and
route_pages.golden do not change.

Full suite on reset databases: 4422 pass, 15 skip, none failed. The console booted on the
test stack and logged the discovery success. go test -race -p 1 over
internal/retryloop, internal/auth and internal/server after
reset-test-db.sh: every package passes, no data race. make lint passes. Breaks, each caught:
- LoginHandler's gate after RenewToken
- the gate without the wake
- the gate answers through http.Error
- the gate answers 500 in place of 503
- the gate without Retry-After
- /register without the gate
- the sign-in value published from a document without an end-session endpoint
- the sign-in value stored outside the issuer check
- the verifier published only together with the sign-in value
- the verifier published from a document with another issuer
- an unusable document returned as a plain error
- the attempt on http.DefaultClient
- LogoutHandler before publication answers 500
- LogoutHandler before publication redirects without destroying the session
- LogoutHandler with the verifier and no end-session endpoint revokes nothing
- refreshIdentity ends the session when no verifier is published
- Run without the cap
- Run without the jitter
- a wake that ends an AtCap wait
- the wait ignores ctx.Done()
- the discovery holder is a plain field in place of an atomic.Pointer
- a Failure's level ignored
- the decay without its five-minute rule
- the escalation never reached
- the changed-text rule without its thirty-second floor
- a wake that ignores the one-second gap
- answer() without the Retry-After line
- re-derivation reads the exported fields in place of the holder
- a level Error line written at once whatever the last line
- an AtCap failure ratchets the doubling to the cap
- the discovery log lines without the issuer attribute
- the failure lines with a fixed attempts attribute
- an end-session endpoint that does not parse or is not absolute is accepted
- Run attempts once on a context that has ended
- a Failure returned by pointer drops its message and level
2026-10-23 17:15:45 -04:00
cgalo5758 35e14578a2 Disable a tier's removal on the card when the holder limit refuses it
The tier card checked the last-tier rule but not the holder limit, so
a tier more organizations hold than the limit offered an enabled
Preview removal whose preview could only say the removal is refused
(#187). The ladder controls' requirement already asks that a control
certain to be refused render disabled with its reason.

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

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

The full suite passes.
2026-10-20 14:20:57 -04:00
cgalo5758 9db9c107de Name the way past the last-tier refusal
The refusal of a default ladder's only tier said what was refused and
why, but not what to do instead (#187). It now reads, for one org type:
"New Personal organizations start on this tier, the only one in their
default plan. Add another tier first, or choose a new default for
Personal." With two or more types it names them all and ends "choose a
new default for each". LastTierRefusal writes it, so the plain delete,
the removal, the refused removal preview and the disabled Remove on the
tier card all read the same sentence. A table test covers one, two and
three types, and the three goldens that carry the sentence change only
in it.

Ride-along: three tests failed a page that leaked nothing whenever the
hex digits of an id on it contained "23505", because they looked for
the unique-violation code as a substring. requireNoDriverText matches
it as a word of its own.

The full suite passes.
2026-10-20 13:04:25 -04:00
cgalo5758 93cb55454f Name the org type by its display name in the default change's toast
The default change's success toast named the type by its key, as in
"Default change committed for personal: 4 migrated.", while the org
types page heads the same card "Personal" and the reorder's toast names
the types it reaches by their display names (#187).

CommitOrgTypeDefaultChange passes the display name from the type it
already reads to render the card, and falls back to the key only if the
name is empty. A handler test asserts the display name and the absence
of the key. The 18 plan-act goldens that carry the toast change only in
that line.

The full suite passes.
2026-10-19 22:26:22 -04:00
cgalo5758 eeded12c16 Remove a tier on the runner, under the holder limit and last-tier guard
A tier removal with holders ended, aligned and materialized every
holder's pool inside one transaction that held the exclusive
rendezvous, so its hold grew with the holders and had no limit (#187).
Under Keep it removed the last tier of a ladder an org type defaults
to, leaving that type's default plan empty; under Migrate the same
removal failed at a pool step. Its four step failures answered "Details
are in the server logs."

RemoveTier now runs on runPlanAct. Inside the exclusive transaction it:

- counts the live holders, reads the ladder's tiers and the org types
  defaulting to it, and refuses through TierRemovalRefusal before any
  pool row. That function refuses above planActHolderCap, then refuses
  the last tier of a default ladder under either disposition. A failed
  read refuses too.
- reads the product's and the ladder's names, refusing when they cannot
  be read;
- locks the holders' pools in ascending id and reads them again, then
  applies the disposition gate;
- records the act (tier_removed, with the disposition when
  default-sourced holders remain), deletes and renumbers, and
  reconciles each pool. The floor grants Migrate creates carry the
  act's id.

The limit refusal offers "changing that default, or reordering this
ladder, with Migrate" only when that advice is true: when the tier is
rank 0 of a ladder with another tier and every default-sourced holder's
type defaults to it. Otherwise it offers the default change alone.

The removal preview reads the same counts and calls the same function
with the same limit. A refusal renders "Apply change" disabled with the
sentence as its reason. A failed read of the org types or the tiers
answers "Failed to preview the removal. Reference: {request id}." in
place of the preview, and a failed holder list answers "Failed to load
the tier's holders. Reference: {request id}.". The promoted-default
note shares the preview's one org-types read.

CommitPlanLadderTierRemoval only parses, calls and renders. It answers
as follows:

- Every failing step is logged through the request's logger with the
  step, the ladder, the product and the pool or provision.
- The four step sentences end "Reference: {request id}.".
- A lock wait or deadlock answers "Another change collided with this
  one; try again.".
- A removal with no holder left to reconcile answers "{Product} removed
  from the ladder.".
- A 422 whose preview cannot be rebuilt answers the preview's own read
  failure.
- A gate that was true while the rebuilt preview finds no
  default-sourced holder answers the generic failure, and logs an Error
  line with the request id.

The design's removal table gains those two rows, and copies 14, 16 and
52 name their new places and condition. docs/database-locks.md lists
the six paths that render the collision sentence.
docs/plan-management.md gives the removal's limit, its last-tier
refusal and its repeated-removal no-op.

Goldens:

- Behaviour changes in remove-begin-fails, remove-second-pool-fails,
  remove-keep-rank-0, remove-migrate-pool-held-elsewhere,
  remove-other-source, remove-migrate-rank-0, remove-two-pools,
  remove-no-holders-left, remove-last-tier-keep (refused, where it used
  to empty the default ladder), remove-last-tier-migrate (refused
  before any pool step), remove-product-name-unread, contention, the
  four removal-against-default files and
  tier-change-during-conferral/a-tier-removal.
- New: remove-above-the-limit and remove-org-types-unread.
- remove-needs-disposition and remove-refused-disposition do not move:
  the gate writes nothing. Task 8.5's text is corrected to say so.

The full suite passes.
2026-10-19 21:55:29 -04:00
cgalo5758 a86547a434 Reorder tiers on obligations, with the ladder page's lines and Retry
A tier reorder that changed a default ladder's rank 0 renumbered the
tiers and then moved every organization of the types defaulting to the
ladder inside the request, from a list read after the renumbering. At
size it ran out of time, and a failure part way left the rest unmoved
with no way to resume (#187). The org types defaulting to the ladder
were read outside the transaction, and a failed read counted as none.

ReorderTiers now runs on the runner. Its first transaction holds the
exclusive rendezvous and does four things in order:

- It reads the tiers and reconciles the submitted order against them
  inside the transaction. A submission that names none of the ladder's
  tiers writes nothing and re-renders the page.
- It renumbers the tiers.
- It records the act. When rank 0 changed, core.record_plan_act reads
  the org types defaulting to the ladder and records them as reached.
- With no disposition given, it rolls back when an organization holds
  the outgoing default.

The listing, the moves and the drain then run as they do for a default
change. A move reads the current rank 0, so an organization already on
it is settled as already at the default and gets no legacy grant. The
toast reads its counts by act id across every type the reorder reaches.

The ladder page shows, below the tiers table, the lines of each reorder
that owns a type with open work, newest first in the order the acts
reached their types. They are read in two statements in one read-only
repeatable-read transaction. The page has its own Retry route, which
checks that the act is a reorder of this ladder.

The old enactment is gone. In entitlements: EnactDefault, enactDefault,
enactReorder, Enactment, EnactResult, EnactFailure, ReorderEnactment,
OrgLister, DefaultPools, LadderNames, their helpers and the steps only
they used. In the server: orgTypeChangeResult, enactFailureReason,
dispositionFailure, logEnactFailures, the reorder's own disposition and
reconciliation helpers, and OrgTypeChangeResult.Failures with the
toast's "failed" part.

Along the way:

- orgTypesDefaultingToLadder returns its error. The tier card and the
  reorder preview, and the preview a refused commit rebuilds, show
  "Failed to read the org types that default to this ladder. Reference:
  {request id}." with no commit, instead of treating the failed read as
  no org type.
- "Failed to classify affected organizations." takes the reference
  form, logged through the request's logger.
- One helper logs a gate that found an outgoing default when the
  rebuilt preview finds none, for both the default change and the
  reorder, with the request id.
- The timing test times EnactObligation once, rather than EnactDefault
  at each holder count; task 1.1's text says so.

With counts unread and nothing settled, the reorder's toast is its
opening alone, "{Product} is now the default plan for new {types}
organizations.", approved with the default change's as copy 62.
docs/database-locks.md, docs/plan-management.md and
docs/models/plan-ladders-transitions.md follow the code.

Goldens:

- Behaviour changes in reorder-an-organization-fails,
  reorder-begin-fails, reorder-cosmetic, reorder-grandfather,
  reorder-migrate, reorder-needs-disposition, reorder-rank-0-no-default,
  reorder-redrop, reorder-stale, reorder-two-types, stale-candidate,
  tier-change-during-conferral/a-reorder and contention (the reorder's
  collision sentence; later placeholders renumber). routes.golden gains
  the ladder Retry route's probes.
- Retired: reorder-list-fails, because no listing runs outside the act
  any more, replaced by reorder-listing-fails; reorder-org-types-unread,
  because that read moved into core.record_plan_act, replaced by
  reorder-record-fails. New: reorder-retry.
- The 7 add-*, 7 delete-* and 13 remove-* goldens change only because
  every ladder page render now reads its lines. Each gains that read's
  transaction statements. The three begin-fails among them also gain
  "Pending plan changes could not be read. Reference: {request id}."
  and its log line, since their closed database fails that read too.

The full suite passes.
2026-10-19 21:03:09 -04:00
cgalo5758 b0df28647f Move organizations to a new default as obligations an act owns
A default change saved the new default outside any lock, then moved
every organization of the type inside the request, from a list read
after the save. At size the request ran out of time. A failure part way
left the rest unmoved, with "Details are in the server logs." as the
only answer and committing again as the only re-run (#187).

Migration 00028 gives the default change work it can own and resume:

- core.plan_act_reached_org_types records which types an act reaches.
  core.plan_act_owns_org_type states once that the newest act to reach
  a type owns it, so a newer act takes over what an older one has not
  moved.
- core.plan_act_obligations holds one row per organization the act must
  move. core.plan_act_orgs_without_pool records an organization with no
  default pool, which is logged and counted, not owed.
- core.plan_act_listing_frontier states once which organizations the
  listing has not reached: the writer, the listing-done read, the
  poller's read and the card's count all call it, and PostgreSQL
  inlines it into each.
- Four enclosed writers list, settle, record a failure (failed after
  five attempts) and requeue. core.record_plan_act now writes the
  reached types and refuses a reorder detail that disagrees with the
  ladder. core.grants.act_id names the act a grant came from.

The commit's first transaction runs on the runner under the exclusive
rendezvous, inside a 5 s deadline. It locks the type and refuses a
reserved type, a candidate that is already the default ("Already the
default.") and a ladder with no rank 0. It then saves the default and
records the act. With no disposition given, it rolls back when an
organization holds the outgoing default, and the 422 preview is rebuilt
after the rollback through the capped read. A listing then writes the
obligations in batches of 500, in org type and organization id order.

Each move runs in its own transaction under the shared rendezvous. It
locks the pool, locks the obligation together with its type's owner, and
reads the current default and its rank 0. It then migrates or
grandfathers the organization, settles it as already at the default, or,
under an act with no disposition, leaves it untouched with a Warn line.
A failure is recorded on the obligation alone with its step, in a plain
transaction that takes no pool row. The request tries up to 50
obligations within 2 s. Its lock and transaction timeouts are 500 ms and
2 s, and a move they cut records no attempt. It then starts
PlanActDrainWorkflow for the rest; PollPlanActs starts a drain for any
act still owing work. The toast counts what is not yet reached and what
has no default pool, and leaves out any count it could not read.

The org type's card shows each open act's lines: organizations not yet
reached, and those not moved, each with its reason, with Retry. They
are read in two statements in one read-only repeatable-read
transaction, and "Pending plan changes could not be read. Reference:
{request id}." replaces them when that read fails. Retry is an act of
its own: it requeues the failed obligations on the types the act still
owns, and a retried organization is recorded under the operator who
pressed Retry. It answers a lock wait with the collision sentence and
any other failure with "Failed to retry. Reference: {request id}.".

Along the way:

- The default change's sentences ending "Details are in the server
  logs.", in six places, now end "Reference: {request id}.", logged
  through the request's logger.
- The select's label becomes "New default", so the form library's
  refusal reads "Choose a new default from the list." and its required
  message "Choose a new default.".

The change's specs now carry that label for org-types and form-library.
The design states the frontier function and that the request's cap
counts obligations tried, not settled. docs/database-locks.md,
docs/plan-management.md and the two docs quoting the label follow the
code.

Goldens:

- Behaviour changes in contention, default-already-at, default-migrate,
  default-none-migrate, default-needs-disposition, default-begin-fails,
  default-initiate-fails, default-migrate-restore-fails,
  default-grandfather, default-change-twice, default-current-refused,
  default-save-fails, the four removal-against-default files and the
  four tier-change-during-conferral files.
- The three default form goldens move with the label. routes.golden
  gains the Retry route's probes.
- default-list-fails is retired: no listing runs outside the act any
  more. New: default-listing-fails, default-superseded,
  default-request-cap, default-untouched-outgoing, default-retry,
  default-retry-nothing, default-preview-capped and
  default-gate-true-preview-empty.
- The other 40 plan-act goldens gain only the reached-type,
  without-pool and obligation sections and the grants' act column.

The full suite passes.
2026-10-19 19:54:16 -04:00
cgalo5758 0b48297b0e Add a tier on the runner, under a holder limit, with one act
A tier add aligns every organization holding a live provision of the
product inside one transaction that holds the exclusive rendezvous, so
its hold grew with the product's holders, and it locked their pools in
the order their provisions were created, the one catalog path that did
not lock in pool id order (#194). It checked the product outside its
transaction, answered "Product not found." for any failed read, showed
the database's text when a pool already held a rung, and answered a
lock wait with "see server logs" (#216).

AddTier now runs on runPlanAct. Inside the exclusive transaction it:

- reads the product and refuses one that is missing ("Product not
  found.") or not published; any other failed read is a failed add;
- counts the holders and refuses above planActHolderCap, naming the
  count and the limit, and, when more than half hold the product through
  an org type's default on a ladder, the way through: a default change
  with Migrate;
- locks the holders' pools in ascending id, reads their provisions
  again, records the act (tier_added, with names read inside) and
  inserts the tier;
- reads ListTierAddCollisions for the locked pools and refuses the first
  pool, in pool id order, that already holds a rung on the ladder or
  holds two live provisions of the product, naming the organization and
  the product, before any alignment;
- aligns each pool and materializes it once, with a reason that names
  the ladder rather than its id.

planActHolderCap is 200. A timed run on PostgreSQL 18.1, one run per
holder count from 50 to 3,200, measured a removal with migrate at 2.2
to 2.6 ms per holder and an add at 0.7 to 1.0 ms. The removal, which
takes the same cap in a later change, binds: 200 holders hold the
rendezvous for about half a second. The timing test lifts the cap so it
can still measure past it.

CreatePlanLadderTier only parses, calls and renders. A lock wait or a
deadlock at any step answers "Another change collided with this one;
try again." A collision answers under the product field. Every other
failure, including a CF003 the collision read should have prevented,
answers "Failed to add tier. Reference: {request id}." and logs the step
and the driver text through the request's logger; the driver text is
never shown.

Goldens: add-aligns-a-live-provision and tier-change-during-conferral/
a-tier-add gain the act row, the ascending pool locks and the ladder's
name in the reason; add-begin-fails answers with the reference and logs
the step; add-collides-with-a-held-rung is refused by the collision read
with no alignment statement; contention's add answers the collision
sentence. Placeholders renumber in three of them because a request id
or a provision id moved. New: add-above-the-limit,
add-above-the-limit-default-sourced, add-product-unread and
add-product-held-twice. The full suite passes.
2026-10-19 15:29:21 -04:00
cgalo5758 7382877ed9 Record a tier delete as an act, inside one transaction on the runner
A plan-wide change to the catalog left no record of itself, so nothing
could say which change moved an organization or offer to run it again
(#187). Migration 00027 adds that record: core.acts, one row per act
with its cause, actor, note and request id, and core.plan_act_details,
the act's plan-specific detail. Both tables are enclosed: only
core.record_plan_act writes them, under the exclusive rendezvous for a
catalog cause, and core_writer can only read them. core_reader reads
every column but the note, which scripts/db-reader-role.sql lists as
personal.

runPlanAct is the one shape the plan acts will share: begin, find the
pools, lock them in ascending id, read again under the locks, act and
commit, with every failure named by its step and a refusal returned as
it is. The tier delete is the first act on it. Inside its exclusive
transaction it counts the tier's live holders (active and suspended),
reads the ladder's tiers and the org types defaulting to it, applies the
last-tier guard, records the act and deletes and renumbers; before this,
the count and the guard's read ran outside the transaction, and a
failed read of the org types let the delete through. A delete of a
product that is no longer a tier of the ladder, a repeated confirm,
writes nothing. The handler only parses, calls and renders:

- A lock wait or a deadlock at any step answers "Another change
  collided with this one; try again." (#216).
- Every other failure answers "Failed to remove tier. Reference:
  {request id}." and logs the step through the request's logger.
- A tier held only by suspended positions is a live holder: the tier
  page routes it to Preview removal, and the plain delete refuses it.

The plan-act golden harness builds the act's queriers through the new
PlanActs seam, so a scenario can fail a statement inside the act's own
transaction, and every golden gains the act and detail sections. Most
gain them empty. Behaviour changes in delete-begin-fails (the log line
names the step and carries the request id; the answer carries the
reference), delete-bottom-tier, delete-top-tier and
tier-change-during-conferral/a-tier-delete (the act row),
delete-held-tier and delete-last-tier-of-a-default (the reads now run
inside the transaction; the answers are unchanged) and contention (the
delete answers the collision sentence; later placeholders renumber
because the delete's log line now carries a request id). New:
delete-org-types-unread and delete-suspended-holder.

The design's failure table for the tier delete now gives the reference
answer for a failed holder count and a failed tier read, as the
approved copy for the delete states, and a row for the repeated delete.
The full suite passes.
2026-10-19 13:35:57 -04:00
cgalo5758 ab1c0dbb69 Write the list search's hidden sibling inputs in sorted order
An embedded list's search form re-submits its sibling lists' state as
hidden inputs. listSearchForm added them by ranging over a map, and a
GET form submits its inputs in document order, so with two or more
siblings the parameters of the URL the search pushes came out in a
different order from one render to the next. They are now added in the
sorted order of their keys, so the order is the same on every render.
With one sibling, or none, the HTML is unchanged.

TestListSearchFormCarriesSiblingInputsInSortedOrder renders the form
20 times with three siblings and checks each render for key order. It
failed in 3 of 3 runs at the parent and passed in 3 of 3 at this
commit. The new file
internal/server/testdata/forms/per-request/list-search-three-siblings.html
pins the three-sibling HTML as the other per-request files do, and the
comment in forms_golden_test.go that explained why the list search was
rendered with one sibling is removed. No existing golden file changes.
No assertion of TestFormsHTML changed; it gains one case.

How it was shown to change no other output: the names and results of
./internal/server (the whole package, on the test databases) are equal
at the parent and at this commit apart from the two added lines, the
new test and the new TestFormsHTML/per-request/list-search-three-siblings
case, none failing. One break (the sorted keys walked backward) is
caught by the new test. The demo-state walkthroughs give 16 pass and
6 skip at the parent and at this commit.

Not probed: that a browser submits the inputs in document order. The
HTML specification says it does, and no test here drives a browser's
form submission.
2026-10-11 21:47:51 -05:00
cgalo5758 bc4a933a3d Pin the HTML of every registered form
TestFormsHTML renders each declaration in every binding the library
renders it in and compares the text, through golden.Compare, with a file
under internal/server/testdata/forms. A form that is not a rows form is
rendered unbound, on the edit side and on the create side, unbound with
values, and as a record (every field bound to a value its rules accept,
three cells per field, an instance and a message); a search is then
rendered boosted and with a target, a select and an out-of-band select,
and any other form is rendered refused (an empty POST parsed through the
declaration) with the refusals of a 600-character value in every field.
A rows form is rendered unbound, then with an open row, a staged delta
and the tray (or the row's own commit), each row field and one row
action. Runtime options are a fixed "Choose one", Alpha and Beta.

The subtests are grouped in three. registered/<name> holds the 19 forms
forms.All returns in the internal/server test binary; the files
number 26 in all. per-request/<case> holds the declarations built per
request: the settings form, the org type card's two copies, the rules
form, and the list search embedded (one sibling parameter) and
standalone. library-defaults holds a table's "Field" and "Value"
headings, a textarea's three rows and the htmx verb of GET, PUT, PATCH,
DELETE and POST on a form that is not a search.

TestFormsGoldenFilesAreAllRendered fails for any file under
testdata/forms that TestFormsHTML does not write, so a form that leaves
the registry leaves no stale file. TestPerRequestFormsSatisfyTheInvariants
runs CheckInvariants over the org type card's two copies and the rules
form's copy; the settings form has its own test, and the list search
builds its declaration inside listSearchForm and never returns it, so no
test can pass it to CheckInvariants and it stays unchecked.

Shown to change no output: only a test file and its goldens are added.
The names and results of ./internal/forms ./internal/server
./internal/integrations/... (2113 at the parent, 2144 here, none
failing, one skip on each side), taken with the test database reset and
the app down at the parent and at this commit, are equal apart from the
new tests and their 28 subtests.
The registered forms' files in name order followed by the defaults' file
are byte-equal to the output of a standalone program that renders the
same bindings, run at 0abbfac7; no file
under internal/forms, internal/server or internal/embeds that is not a
test or a golden has changed since. A separate render of the per-request
declarations, unbound only, equals the unbound section of the org type
card's files and of the rules form's file apart from the runtime options
it did not bind, and equals the list search's two files exactly. The
other bindings and the settings file were read against their
declarations, not compared with another render. Three runs in a row
produce the same files, with no -update.

22 breaks in the form library's render.go and the three server files of
the per-request cases, each edited once and each caught by the new
tests. The old side has no counterpart: every break is a new-side
record, since no old test names these. None dropped.

Pinned defects, so a later fix flips them by name: the list search's
hidden sibling inputs come out in map order with two or more siblings
(rendered here with one, so the file does not vary from run to run);
the library defaults no registered form reaches, which every earlier
test missed.

Not probed: the templates' own markup, because the files hold it for a
later template change to show while the breaks probe the view the
library builds; fieldAttrs, buildRows, RowField and resolveRowsFocus,
whose output is in the files; the list search's per-request declaration
against the invariants, for the reason above; the stale-file check,
which guards the test's own files. A dense checkbox's label position is
the one render break this net misses, since no registered form has a
dense checkbox; TestFormPartCheckboxLabelSitsAboveTheBox catches it.
2026-10-11 15:55:41 -05:00
cgalo5758 b0f7aa26d3 Add and delete a tier through entitlements.AddTier and DeleteTier
The tier add's transaction and the tier delete's transaction lived in the
HTTP handlers. They are now entitlements.AddTier and entitlements.DeleteTier.
AddTier takes the ladder, the product and the operator; it opens
BeginRuleChange and runs the same statements in the same order: the tier
insert, the product's live provisions in creation order, for each one
core.align_conferral_shape as the operator with the reason "tier added to
ladder <id>" and then the pool's materialization, and the commit. The pools
lock in provision order, as before. DeleteTier opens BeginRuleChange, runs
the tier delete, the list of the remaining tiers and the renumbering that
RemoveTier already shares, and commits. Each failure comes back as a
PlanActError naming the step (two new steps, creating the tier and reading
the provisions); a held rung's ErrConferralShapeCollision comes back inside
the alignment step's error, where errors.Is finds it.

CreatePlanLadderTier keeps the session, the form parse, the product read and
the published check. refuseTierAdd maps the insert's two constraint messages
and the held rung to the product field's error, and otherwise logs the line
the failed step logged. DeletePlanLadderTier keeps the held-tier count and
the last-tier guard before the transaction; logTierDeleteFailure logs the
line each step logged. Every sentence is unchanged.

The one departure from today: every refusal or failure answer the add and
the delete give from inside their transactions is now rendered after the
act's rollback, where the handler used to render it before its deferred
rollback. Only reads move: no write, Stripe call or sentence changes, and
the rendezvous and the pool locks are held for less time. Under concurrency,
a request queued on them can commit before the page is read. The one golden
line this moves is the rollback in the statements_with_reads view of
plan_acts/add-collides-with-a-held-rung.json, which now sits directly before
the refusal page's reads instead of after them; it was moved by hand before
the run and the golden test then passed without -update, so no other line
moved.

Docs: the two handler names in the tier add and tier delete lines of
docs/database-locks.md become AddTier and DeleteTier. Riding along as a
correction: the call-sites list in the package comment of
internal/entitlements names internal/entitlements/ladder_acts.go, not the
handler, for align_conferral_shape on tier add and tier removal.

No gocognit exception is deleted: neither handler had one. Measured scores:
CreatePlanLadderTier 21 to 6, DeletePlanLadderTier 16 to 11, AddTier 9,
DeleteTier under 4, refuseTierAdd 9.

No output changes beyond that departure. Names and results of
./internal/server, ./internal/entitlements and ./internal/testkit/... are
equal at the parent and at this commit after a database reset on each side:
1779 results on each side, 1778 passing and the one existing skip. Apart from
the one hand-moved golden line, no test, golden or testdata file is edited
and no test ran with -update. Screens of operator-plan-ladders,
plan-ladder-detail (and its open and invalid states) and org-types, desktop
and mobile, are byte-identical at the parent and at this commit; only the
masked operator-integration-stripe shots and the 2x28 pixel mask of the
invalid desktop shot differ. Every string literal of the plan-ladders and
org-types handlers is still found in a non-test file of internal/server.

16 breaks (28 records), each caught by the old and the new code where its
text exists on both sides: every live provision is aligned, the reason names
the ladder, each pool is materialized, a held rung refuses the add under the
product, the operator aligns, a failed begin of the add is logged, the add's
toast, an add in contention fails generically, the tiers left are renumbered,
the delete's toast, the last tier of a default ladder is refused, a held tier
is refused, a failed begin of the delete is logged, a delete in contention
fails generically, and the add and the delete take the exclusive rendezvous.
None was dropped.

Not probed: faults inside the transactions (no querier makes one fail), so
none of the failure answers that now render after the rollback is seen
moving, nor the add's two constraint messages; the held rung's golden is the
only file that shows the move. The delete guard's read of the organization
types, which skips the guard when it fails, is unchanged and stays in the
handler.
2026-10-11 04:18:40 -05:00
cgalo5758 c5408501f3 Characterize the Stripe sync action: one golden per branch, and its re-checks under the product lock
TestSyncProductToStripeAnswers presses the sync action once per branch
and writes testdata/operator_sync_stripe/<branch>.golden with the
answer (status, toast, alerts), the outbox entries of the product and
its prices, both mappings, and the ordered statement names of the
request, the page's reads included. The 14 branches: Stripe not
configured, unknown product, no active price, price read fails, price
of another product, default price with nothing mapped, requested price
of a reachable product, price already synced, price sync pending,
product create in flight, dead-lettered creates retried, product
create dead-lettered and another price pressed, transaction cannot
begin, and the readiness panel naming a dead-lettered sync. Pending and
dead-lettered states are made by pressing the action and then marking
the entries dead-lettered through the outbox activity, as the workflow
does. The first caller of world.MapProductIn outside its own test is
the reachable-product branch.

TestSyncProductToStripeRechecksUnderTheLock holds the product's
advisory lock in a side transaction, presses the action, waits until
the press queues on the lock, then commits what a competing sync
writes: a pending sync (the press answers "already in progress" and
adds no entry beyond the competitor's two) or a synced price (it
answers "already synced" and adds none).

What the goldens pin that is a defect or a surprise:
- An unknown product answers with the products page and "Product not
  found"; the handler's own "Failed to load the product." never shows.
- A failed read of the product's prices is answered as "Add an active
  price before syncing to Stripe.", the same as no price.
- A price that is already synced is answered after the price-mapping
  read alone: no outbox probe, no transaction.
- Pressing a second price while the product's own create is
  dead-lettered resets the product's entry ("Retrying ...") and
  enqueues nothing for the second price, which has no mapping or entry
  afterwards.
- A press that finds the product's create in flight rolls back after
  the page's reads.

No production file changes and no output changes. The test names of
./internal/server and ./internal/testkit/... are the parent's 1526
plus these 18 (2 tests, 16 subtests), all passing, each side run after
a reset of the test database. The new test files pass three runs in a
row, and a run without -update leaves the goldens unchanged.

25 breaks of operator_billing.go and operator_products.go, each caught
by the new tests. One record was corrected to compile before the
counting run: the check that refuses another product's price left its
variable unused, so the break keeps the variable in use.

Unprobed, with the reason:
- the seven "sync-stripe: ..." log lines: no test reads a log;
- a failure after a successful begin (the mapping writes, the enqueue,
  the commit, the reset of dead-lettered entries): it needs a
  statement to fail inside the transaction and no hook exists;
- "Failed to load the product.": it renders only when the page's own
  read of the product then succeeds, after the handler's read failed;
- a failed read of the price mapping before the lock: it reads as no
  mapping and is not distinguished;
- the stale and other-environment mappings, which
  TestSyncProductToStripeCreatesAgainWhenUnreachable pins.
2026-10-10 21:44:01 -05:00
cgalo5758 340f19e21c List same-instant feed events by kind, then the later-written row first
Events of the recent-activity feed that share one timestamp now list by
kind (grant, invoice, payment, tier change, as before) and, within a kind,
the one the console recorded later first. Invoice and payment events had no
Seq, so two of them at one instant fell through to their summary text and
organization id: "$100.00" listed above "$20.00" and Zeta above Alpha
whichever was recorded later. They now carry their own row's id, as grant
and tier events already did, and activityLess breaks the tie on it, so
every event has one place in the order. The comment on ActivityEvent.Seq
and on activityLess say so. The requirement "Invoice and payment events
are placed when they happened" now states the order of events that share
one timestamp, through the change same-instant-feed-order, archived in
this commit with the delta synced into the main spec.

How it was shown to change only that: TestRecentActivityTiesListTheLaterWrittenFirst
(invoices of two organizations, invoices of one organization, rows of one
kind in any input order) fails in all three subtests at the parent and
passes here. The feed's golden diff is exactly two pairs of rows swapped in
feed.json and in feed.html: Alpha's invoice above Zeta's, and "$20.00"
above "$100.00" for the invoices of one organization. Names and results of
internal/server are equal to the parent's (1473 at the parent, 1477 here:
the new test and its three subtests), 0 failures. Four breaks (the invoice
Seq, the payment Seq, the id direction, the kind direction) are each caught
by the new test or the feed's golden. The lint snapshot is unchanged
(0 added, 0 removed). Walkthroughs are equal at the parent and here (16
pass, 6 skip); screens at the parent and here differ from the baseline only
in the masked regions, so operator-overview is unchanged: the demo feed
holds no same-kind ties.

Unprobed: payments tied in the database. The database subtests tie
invoices only; ties of payments, and of three rows of one kind, are checked
without a database through the comparator, because the tie rests on the
payment row carrying its id, which the comparator check and the payment
break cover.
2026-10-10 20:42:09 -05:00
cgalo5758 a304124215 Order an operator's tier changes against every conferral
A conferral reads a ladder's ranks and a product's conferral shape in
several statements at READ COMMITTED, and the reorder, the tier delete,
the tier removal and the tier add took the rendezvous in shared mode, so
one of them could commit between those reads. A grandfathered
organization that keeps its product was then recorded as upgraded or
downgraded between two ranks of the same product (#213), and
TestTierReorder_CommitRacesDefaultChangeCommit failed intermittently on
it. The four handlers now open their transactions with BeginRuleChange,
which takes the rendezvous in exclusive mode, so each finishes before a
conferral starts or starts after it. The default change and the
reorder's per-organization enactments keep the shared mode.

The cost, accepted for #213: a tier change waits for every materializing
transaction in flight, up to the 5 s lock timeout, and new ones wait
behind it while it runs; the removal and the add hold the lock for their
whole act. A wait past the timeout answers as a rule change in progress
does today: the removal with its refusal sentence, the add, the delete
and the reorder with their generic failure copy; #216 is the generic
timeout answer this leaves.

Behaviour changed on purpose, and only here: the rendezvous statement
the four acts record, in 25 act golden files (26 lines; the
held-rung file records it in two views), shared to exclusive, one line
each in the same place; the renumbering-race files, which moved to
tier-change-during-conferral/ and now record a transfer at one rank
(a fourth file records the tier add); and stale-candidate.json, which
loses the second reorder's answer. Every other golden, answer, status
and row is byte-equal to the parent.

How no other output changed: TestPlanActsGolden was rewritten with
-update and the diff read line by line (25 files, 26 lines, each
pg_advisory_xact_lock_shared to pg_advisory_xact_lock and nothing else),
then run three times in a row without -update on a freshly reset
database. At the parent, with this commit's test file and goldens copied
over, TestTierChangesWaitForAConferral fails in all four cases and
TestTierRemovalAgainstDefaultChange in its two queued cases, each on the
10 s wait for the rendezvous statement, and TestPlanActsGolden fails in
the same 25 subtests, on their recorded statements;
TestReorderEnactsAgainstTheRankZeroItSaved passes there. Over
./internal/server, ./internal/entitlements and ./internal/testkit/...
the names and results at the parent and at the commit are equal, apart
from TestRenumberingCommitsDuringAConferral and its three subtests,
renamed TestTierChangesWaitForAConferral, and the new subtest "a tier
add" (1746 names before, 1747 after; no failure on either side).

Tests: TestTierChangesWaitForAConferral starts the tier change while the
conferral is paused and waits for it to queue on the exclusive
rendezvous before releasing, so the tier change commits after the
conferral. The two queued cases of TestTierRemovalAgainstDefaultChange
wait on the rendezvous for the second request instead of on the pool
row. TestReorderEnactsAgainstTheRankZeroItSaved is rebuilt: a second
renumbering would otherwise queue behind the first reorder's enactment,
so the test holds a tier row, queues a renumbering on the rendezvous and
releases, which still reaches the window between a reorder's renumbering
and its enactment. Its stale-candidate.json keeps the first answer and
the rows and loses the second answer, which makes that comparison
NARROWER; the second reorder's answer is unprobed. The race test keeps
its assertions and gets the comment that says why they now hold.

11 breaks, each caught by the new tests (each of the four openers
reverted to the shared mode is caught by the interleaving test and by a
golden, the removal's also by the two queued cases, and a reorder that
enacts against the wrong rank 0 by the rebuilt stale-candidate test);
the old side is not run because the new tests are what show the fix. The
race test passed 500 of 500 idle and 500 of 500 under eleven busy loops
on a 12-core machine (it failed 2 and 19 of 500 before), and a probe
written for this race, which commits a renumbering inside a conferral,
now ends in a transfer from rank 0 to rank 0 with the reorder waiting
for the conferral.

Unprobed:
- The removal's second read of its holders under the pool locks: the
  exclusive lock is taken before the first read, so no console
  transaction can change a holder between them and no test tells the
  two reads apart.
- The tier add's failures without the fix (the exclusion-constraint
  failure, a conferral committing without the new rung, the refusal
  about a held rung) were read from the code and not run; the
  before run shows only that the add did not queue on the rendezvous.
- The 5 s timeout answers of the add, the delete and the reorder behind
  a long shared holder: the held-rule-change test already pins them,
  and a wait behind a shared holder takes the same path.
- The time a removal of a tier with many holders, or an add of a widely
  held product, holds the lock: not measured.
- A conferral that does not materialize would escape the order; none
  exists, and the database function does not assert the rendezvous.
- The plan-management HTTP test, which adds tiers through the changed
  opener, was not run: the end-to-end packages are outside this
  commit's checks. The browser screens and the walkthroughs were not
  run: no template or sentence changed.
- The demonstration seed adds its ladder's tiers on the plain handle,
  outside the rendezvous; the spec, the comments and the docs name the
  operator's four tier changes, and the seed is left as it is.

The entitlements spec's requirement says which transactions take the
exclusive lock and gains two scenarios ordering a tier change against a
conferral; the change is archived here and the spec synced.
docs/database-locks.md and docs/go-conventions.md name the four
openers as exclusive.
2026-10-10 19:32:15 -05:00
cgalo5758 6f65c744fe Pin how the plan-wide acts interleave with each other, with a rule change and with a conferral
The plan-wide acts (tier removal, default change, reorder, tier add and
delete) are now sent against each other and against a conferral, each
interleaving chosen by a lock a side transaction holds and releases once
the requests wait on it. dbtest.AwaitLockWait proves a request is queued,
and PostgreSQL grants a row lock to its waiters in the order they queued,
so no test sleeps. The kit gains Backend.Waiters, how many backends must
wait at once, with a test of its own; its first caller is these tests.

TestTierRemovalAgainstDefaultChange runs a removal and a default change
on one pool in each order: queued on the pool's row, and one after the
other. Every case ends with no live position on a deleted tier.
TestDefaultChangeTwiceSerializesOnThePool sends one default change twice
and shows one request grandfathering the pool and the other finding the
legacy grant the first wrote. TestPlanActsRefuseWhileARuleChangeHoldsTheRendezvous
holds the exclusive rendezvous while all five acts run, and pins each
act's contention answer: the removal's and the organization's "Another
entitlement set change is in progress. Try again." and the generic
sentence of the reorder, the add and the delete. It waits out the 5 s
lock timeout once. TestRenumberingCommitsDuringAConferral commits a
reorder, a tier delete and a tier removal while a default change's
conferral waits between its rank reads; the files pin the transition as
it is recorded today (upgrade 0 to 1 for the reorder where a transfer
belongs; downgrade 1 to 0 for the delete and the removal), because a
renumbering takes the rendezvous in shared mode only. Pinned with #187:
TestReorderEnactsAgainstTheRankZeroItSaved sends two reorders whose
enactments queue on one pool; the first enacts against the rank 0 it
saved after the second has put the other product back at rank 0, so it
grandfathers the holder onto a legacy grant of the product that is rank 0
again, and the second finds the holder on that grant and leaves it.

No output changed: the commit adds tests, their golden files and one field
of the test kit, and touches no production file. The names and results of
./internal/server and ./internal/testkit/..., run after a database reset at
the parent and at this commit, differ by the 13 new tests and subtests, all
passing; the new tests passed three runs in a row and 20 runs of the four
that do not wait out the timeout. The edit to the plan-wide acts' golden
test is its JSON encoder, split so the new tests encode their files the same
way; its 44 files are unchanged. The kit's wait.go and wait_test.go and the
AwaitLockWait bullet of docs/testing.md change beside it. 14 breaks, each
caught by its new test (new side only): 12 edit production code unchanged
from the parent and 2 edit this commit's wait.go. The section's tests take
about 6 s, 5 s of it the one wait.

Unprobed:
- Interleavings the tests do not choose, such as a removal that arrives
  between the default change's save and its pool lock; the cases cover the
  two queue orders on the pool and the two serial orders.
- A conferral other than the default change's during a renumbering (a
  reconcile, a grant act, the floor); it is the same database function,
  which the reorder case reaches.
- The default change's own stale candidate: a renumbering between its save
  and its enactment. It uses the same fixed product the reorder case
  reaches; no case pauses the default change there.
- The two new failure messages of AwaitLockWait for a count above one;
  they are text, read only when a wait fails.
2026-10-10 16:42:02 -05:00
cgalo5758 7cfcb62f26 Characterize the plan-wide acts: answers, log lines, writes and locks, and the rows they leave
TestPlanActsGolden sends one request to the handler of each plan-wide act
(tier removal with holders, tier delete, tier add, an organization type's
default change, a ladder's tier reorder) on a fixture of its own and
compares what came of it with testdata/plan_acts/<scenario>.json, 44
scenarios in all. A file holds the answer (status, toast, alert and
field-error text, checked radios), the handler's log lines, the statements
that open, lock, write or close a transaction in order (reads left out,
because a refactor may move them), and the fixture's tiers, type defaults,
grants, provisions, positions, transitions and materialized entitlements
afterwards, with the fixture's ids named and the ids the act wrote numbered
by first appearance. add-collides-with-a-held-rung also holds every
statement of the request, reads included, to show where the rollback falls
against the refusal page's reads. Alert and field-error regions that hold no
text are left out of the answer, because the page carries one for each form
it shows and which forms it shows depends on the products the rest of the
database holds.

The files pin these defects as the code has them today: a removal that
empties a default ladder, and one that rolls back because the restoration
finds no rank 0; the removal's toast when nobody is left to count, which
ends in ": ."; the driver's own text (SQLSTATE and all) in a failed
organization's reason, in the toasts and in the logs; the commit re-runs the
answers advertise that cannot run, because the form refuses the current
default and a re-dropped order enacts nothing (#187); the reorder that reads
a failed listing of the organization types as a ladder no type defaults to
(#207); the products with no entitlement set that the edit form can leave
(#153) and that make a conferral fail; and the tier add that stores the
ladder's id in the provisions' transition reason.

No output changed: the commit adds one test file and its files and touches no
production file. The names and results of ./internal/server, run after a
database reset at the parent and at this commit, differ by the new test and
its 44 subtests, all passing; the test passed 30 runs in a row and then on a
database the rest of the package had written to. 64 breaks, each caught by
its scenario's file (new side only; the code they edit is the parent's).

Unprobed:
- A fault inside a transaction. No test can make a statement fail after the
  begin, so the sentence and log line of each such failure is not read: the
  removal's holder reads, pool locks, delete, listing, renumbering, endings,
  alignments, materialization and commit; the add's provision read,
  alignment, materialization and commit; the delete's statements; each
  organization's position read, endings, materialization and commit; and the
  reorder's renumbering and commit. Three removal failures log nothing. The
  failures that are read come from database states, a closed handle and
  refusing queriers.
- The removal's atomicity as code: PostgreSQL aborts a transaction at its
  first error, so a break that commits after a failed step cannot write. The
  scenario with a failing second pool shows the result, not the code.
- Which reads each act runs, and where, apart from the one scenario named
  above.
- The add's two constraint messages, reachable only when another add commits
  between the form's option read and the insert; no scenario pauses there.
- The order in which organizations of the same name are enacted; the
  fixtures' names differ.
- The default change's other form refusals (an unknown ladder, a reserved
  type), which the existing handler tests read.
- A reorder enacted against a rank 0 that changed after the order was saved;
  it needs a pause between two acts.
- Markup outside the answer's regions, which the existing goldens of the
  ladder page pin for the tiers table, the preview's rows being read through
  the same builders; and whether an alert region is empty, because which
  forms the page shows depends on the products the rest of the database
  holds.
2026-10-10 16:42:02 -05:00
cgalo5758 944e77f744 Drive every page, Checkout and the member pages through the router
No production file changes. internal/server/new_handler_db_test.go builds the
router on the test database with a Stripe mock and adds three tests.

TestNewHandlerPages signs in a member and an operator of the World and GETs
every route of the route table that is outside the sign-in flow and is not
the panicking fake route, as the operator under /operator and
/partials/operator and as the member elsewhere. It writes status, Location
whole and the page's heading (or a fragment's first opening tag) to
testdata/route_pages.golden, plus the certificate ask for an unknown domain.
The golden was written once with -update and read against the code. A Stripe
mock refuses every call, so no page reaches Stripe. The two member lists
that print what the database holds (plans and add-ons) record status and
location only, because their first element changes with the ladders and
products other tests leave in the shared database.

TestCheckoutThroughTheRouter posts a price through the router as the buyer:
303 to the session's URL, the key mode asked once, and the session request
returning to the configured base URL.

TestMemberPagesThroughTheRouter has nine subtests on one router and a
recording mock: the workspace region from two workspaces, each region equal
to what its partial answers, only pending claims listed, the checkout banner
for success, cancel and other, a successful return reconciling and a
canceled one not, the reconcile conferring a rung before the page reads the
organization's holdings and before it includes the regions that show them
(one organization is read with no card, another with the entitlements
partial as the only card), no reconcile without a Stripe key, the billing
facts repeated on a successful return, and the empty state following the
organization's holdings.

Shown to change no output: no production file is touched, and the names and
results of internal/server and internal/testkit equal the parent's apart
from the three new tests and their nine subtests. The three pass three runs
in a row on a reset database. Of the 28 breaks of the plan (27, and one that
moves the reconcile after the workspaces include and the cards loop), each
applied to the production code after a green control run, 26 are caught; two
were missed by TestNewHandlerPages and are dropped: the operator domains
page reading the registry, and the operator partials reading the
integrations' configuration (the page answers the same status and heading
when the read is absent).

The reconcile's write before the holdings read is probed by "a successful
return shows what the reconcile conferred" on the organization read with no
card, and its write before the includes by the same subtest on the card that
shows the ladder; the break that moves the reconcile after both is caught.

Left unprobed:
- The reconcile's order against the workspaces include alone: the region
  shows the organization's workspaces, not what it holds, so a reconcile
  placed between that include and the cards loop changes nothing a test
  reads.
- The dashboard's root-only check: the route-aware dispatch answers every
  unknown path before the dashboard runs, so only an include reaches it, and
  without the check the dashboard would include itself without end.
- The dashboard's billing-facts gate on a successful return: the template
  prints the facts only inside the success banner, so the gate changes
  nothing a member sees.
- The member shell's fields (the rail's active entry, the operator switch,
  the account menu): every member page renders them and the screens capture
  them; no subtest reads them.
- Wiring the pages golden does not reach: the Stripe mode, the key
  fingerprint, the dashboard URL, the configured flag, the Temporal client,
  the integration configurations, both registries' policy, the external-claim
  gate, the connect target, the ask fallback URL, each logger and the
  member domains handler's domain queries. A page fact per field would be
  needed, and the headings do not hold one.
- Each page's own answer beyond its status and heading.
2026-10-09 21:23:00 -05:00
cgalo5758 eb4a7552bd Pin every route's pattern and answer through the router
No production file changes. authtest gains Cookie, which commits a session
and returns its cookie, for a handler wrapped in the session middleware,
such as the whole router, where Get and Post cannot put the session on the
request's context. docs/testing.md names it and the kit table's authtest
row says "signed-in sessions, their cookies, and in-process requests".

internal/server/new_handler_test.go (no database) builds the router from a
configured profile and a bare one (no Stripe key), with a fake route mount
and two UI mounts, and adds five tests. TestNewHandlerRouteTable writes one
block per registered pattern (the 97 literal HandleFunc patterns the lint
scan finds in internal/server and internal/auth, plus the two static-file
Handle registrations and the fake mount's three routes) to
testdata/routes.golden: the pattern the mux resolves a probe path to, and
the answer to an anonymous request, a cross-site request for a method that
changes state, and a signed-in member where the handler answers before any
read; then eight extra probes and the bare profile's checkout row. The
golden was written once with -update and read against the code: every
operator row answers 302 /login anonymously and 403 Forbidden to the
member, the cross-site writes answer the CSRF refusal except the fake
webhook (204), the two wrong methods and the bare checkout resolve to / and
the styled 404, and the body one byte over 1 MiB answers 413.
TestNewHandlerTrustsTheBaseURLOrigin, TestNewHandlerRefusedSignInIsStyled,
TestNewHandlerRefusesAMountTemplateOutsideItsKey and
TestNewHandlerPassesDepsToMounts cover the base URL's origin, the styled
refused sign-in, the key-prefix refusal and the Deps a mount is handed.

Shown to change no output: no production file is touched, and the names and
results of internal/server and internal/testkit equal the parent's apart
from the five new tests, which pass three runs in a row. The 47 breaks of
the plan, each applied to the new side after a green control run, are all
caught.

Left unprobed:
- The answers the golden records that code outside the router produces
  (each handler's own answer, the auth handlers, the operator role guard,
  each middleware's behaviour): each has its own tests, and no edit to the
  router changes them; the golden holds them so a change shows.
- middleware.Timeout(32s): a short value races fast handlers, and a probe of
  the real value waits 32 seconds.
- middleware.Logging and the order of RequestID before it: they write a log
  line no test reads.
- The http.Server fields: Start keeps them, and nothing builds the server in
  a test.
- The dashboard's root-only check: a later commit probes it.
- The real integrations' routes (FedWiki, the Stripe webhook, Discourse):
  they mount from cmd/start.go, and the fake mount stands in.
- The Config cmd/start.go builds (including StripeKeyMode and BaseURL) and
  the Start call with its exit path: no test builds the command's Config;
  these tests build their own.
2026-10-09 21:23:00 -05:00
cgalo5758 2fd63eaca2 Characterize the recent-activity feed: its rows, its failed reads, its reads per event and the landing's paging
No production file changes. The feed's output is unchanged because nothing
the page runs was edited; the commit adds tests, one kit function and the
files the golden compares with.

TestRecentActivityGolden builds nine organizations on one World, for ten
cases (the floor, an upgrade and a revocation on a plan the type defaults
to; an upgrade by a subscription that then ends and resumes the grant; a
live extension; an extension of a grant on hold; a ladder of its own; a
reorder after the tier changes; the console's own removal of a tier, which
writes the only end row with no rank; invoices and payments around one
instant; two organizations with one invoice each at one instant). It
compares the events the feed lists for them, in order, as JSON, and the
activity panel's render of those events, with files under
testdata/operator_activity. Instants are named t01 and on, newest first,
because only their order is a fact. The files were read against the fixture
before they were committed. The feed's four existing readers now go through
wholeFeed.

TestRecentActivityReadFailures fails each of ten reads in turn (the four
sources, the product, the tiers, the organization, the person, a handler
without a database and a closed one) and pins the events the feed keeps and
the warning it writes. TestRecentActivityReadsPerEvent grows the feed by two
organizations on a scratch database and expects exactly the product,
ladder, organization and person reads of two more of each, and no change in
the four source reads or the raw ladder read. The landing page tests read
GET /operator on a scratch database: each source is cut at 100 rows (the
101st invoice is on no page and the pager counts the 101 events the feed
holds), and a feed of 30 events pages by act_page and act_per, with a page
past the end showing page 1 and an unknown size reading as 10.
dbtest.CountedAt is Counted over a given address.

Shown to change no output: 33 breaks of the feed's code, each applied on its
own, are all caught by the new tests; the tests of ./internal/server and
./internal/testkit/... pass three times in a row, each on a reset database,
with no test or subtest of the parent missing or changed in result and 19
added in ./internal/server (the golden, the failure table and its ten
subtests, the reads test, the cut test, the paging test and its four
subtests); the golden passes a second run without -update and leaves the
tree unchanged.

Unprobed, and why:
- Each source is read once per render: no one-line edit doubles a source
  read without also changing the rows.
- The order of two warnings when two sources fail: no subtest fails two
  reads.
- The When cells: the golden replaces them with tokens, and the test of
  billing events' times pins their text.
- The template's id fallback for an unnamed organization or actor: the
  overview render tests, which build their events by hand, pin it.
- An extension older than the transitions window shows as its grant row:
  pinning it needs about 100 later tier changes.
2026-10-09 21:23:00 -05:00
cgalo5758 94b7c364de Characterize the organization page: a snapshot of its data and render, a table of its failed reads, and its reads per row
No production file changes. The page's output is unchanged because nothing
the page runs was edited; the commit adds tests and the builders they use.

TestOrgEnrollmentGolden compares, for nine states (no organization, no
pool, the default plan alone, a rich organization with one plan on two
ladders and a subscription on a third, a grant with a note, a flagged
subscription, usage, an open overdue invoice and a rule change, a removed
tier, a second pool, a suspended subscription, twelve tier changes paged, and
the rich one searched), the data the loader returns and the
operator_enrollment.html render with files under testdata/org_enrollment.
The data leaves out the Issue grant form, the grants ledger's form and the
grants rows, which the render holds. The files were read against the state
table before they were committed.

TestOrgEnrollmentReadFailures fails each of 23 reads in turn and pins the
section it feeds and, where it is words on the page, the render. The
operator read-count test gains the row "organization page" at 9 reads per
row, as predicted.

world gains Member, Suspend, Pool and ConferOn, and each says which state it
writes that the console does not: Pool and ConferOn, a second
pool; Member, a member who is not an organization's owner; Suspend, a
suspended subscription whose row keeps its status.

Shown to change no output: 66 breaks of the page's code, each applied on its
own, are all caught by the new tests; the tests of ./internal/server and
./internal/testkit/... pass, with no test or subtest of the parent missing or
changed in result, and 35 added in ./internal/server (the golden's 10, the
failure table's 24 and the read-count row); the golden passes three runs in a
row on a reset database.

Unprobed, and why:
- The two advisory failure rows keep their fallback in the nothing-held
  state; no one-line edit flags a product without the rows the failed read
  did not return, so a break there cannot be written.
- The order of the rungs of one act (Hosting before Chat) is the position
  query's order among equal keys, which it does not specify; accepted.
- The members cap: no state holds more than 16 members.
- Values copied unchanged from a row (organization, pool, set and resource
  names, keys, statuses, emails, roles, notes, limit lines): a copy can only
  be dropped, and the golden shows a drop.
- The ledger's rows, its History tab and its search beyond one break: the
  grants goldens pin them; this page's render only holds them.
- The pager's own markup (the Showing line, Previous and Next): the pager
  test of the embedded lists and the list-scale tests pin it.
- The pool type badge, which is a copied value the template renders.
2026-10-09 19:35:24 -05:00
cgalo5758 67a5d5badc Characterize the grants listing: both queries' rows, both pages, and their reads per row
Adds tests, one test-kit option and goldens only; no production file
changes.

TestGrantsListingRowsGolden builds the waiting fixture of the grant
resumption tests (eight organizations, eleven grants) plus seven
organizations (an extended grant, a revoked one, an expired one, a
restored default, a marked duplicate of the default, a grant on no ladder
and twelve grants for the pager), 33 grants in all, and writes the rows of
each fixture organization's ListGrantsWithDelivery call and of the search
call ListGrantsWithDeliveryPage on the fixture's suffix (all grants, each
state, and four pages of ten) under testdata/grants_listing/rows. Its drift
subtest holds the two queries to the same 25 columns for every fixture
grant and names the grant and each column that differs. It passes at the
parent, so the two queries already agree on this data.

TestGrantsListingPagesGolden writes, for 8 requests of the Grants page and
for the Active and History tabs of each fixture organization's grants
ledger (plus a reason search, a product search, page 2 and a page past the
end), the data each surface renders as JSON, captured through the handler,
and the HTML of the real render (the page's #operator-body, the ledger's
whole answer). Each file was read against the fixture before it was
committed: every waiting row's hover against the table of
TestGrantResumptionRows_WaitingStatesRenderAlike and every revoke body
against TestGrantResumptionRevoke_Bodies.

grWorld gains manage, the Manage save's extension; world.IssueOpts gains
By, the person who issues the grant; newGoldenNormalizer's body moves to a
form that takes the database and the suffix.

The read-count table gains grants (5 reads per row), grants-ledger (2) and
grants-ledger search (2), each as the plan predicted.

Names and results of ./internal/server and ./internal/testkit/... match the
parent apart from the new tests, which pass: TestGrantsListingRowsGolden
with its 18 subtests, TestGrantsListingPagesGolden with its 44, and the
three read-count subtests. A second run without -update leaves the files
unchanged, and the tests pass three runs in a row after a reset. Nineteen
edits to the listing code, each breaking one fact, fail the test that names
it (each caught by the new test; the rows golden and its drift subtest are
one test, so a break in a column the golden holds is caught by both).

Unprobed: the target badge, the quantity, the Resumes column, the hover
wording, the list controls and the ledger's Manage markup (each is a column
copy or a template literal, probed by the waiting-row tests that the next
two commits convert); the Grants page's past-the-end reload, which the
merge of the two queries probes; the floor's revoke body, which the
resumption revoke test probes; the pool the ledger evaluates for an
organization with no active default pool, because no fixture organization
lacks one; the order of grants that share created_at, which no fixture
grant does.
2026-10-09 19:35:24 -05:00
cgalo5758 2a913ca635 Characterize the plan-ladders page, the ladder page's tiers and the reorder preview
Adds tests, one test-kit builder and goldens only; no production file
changes.

TestPlanLaddersGolden builds three ladders (Hosting with Basic and Shared,
Support with Shared, Empty with no tier), five products (a shared one, an
add-on, one with no category, a draft), three organization types (defaulting
to Hosting, to nothing and to the empty ladder) and an organization holding
Basic, and compares with files under testdata/operator_plan_ladders: the
topology's columns, rank rows, shared products, off-ladder strip and type
summary together with the ladder list (page); the ladder page's tiers with
their organization counts (detail); and the reorder preview's order for the
ladder's own order (preview). The page lists every ladder in the database, so
the files hold the fixture's ladders, products and types. The properties
subtest holds the whole grid to the ladders' own reads: rows ascend by rank,
every row has one cell per column, and every column, the fixture's or not,
counts what CountActiveAttachmentsByLadder returns and holds the tiers
ListTiersByLadderWithProducts returns. Each file was read against the fixture
before it was committed; a type whose default ladder has no rank-0 tier reads
as no default.

TestPlanLaddersReadFailures pins, per read (ladder listing, shared products,
ladder counts, tier reads, product list, rank-0 listing, organization types,
tier counts on the ladder page and in the preview), what the pages show when
that read fails.

world gains AddTier. The read-count table gains plan-ladders (4 reads per
ladder), plan-ladder-detail (1 per tier) and tier-reorder-preview (2 per
tier), each as the plan predicted; the table's request can now be a POST.

Names and results of ./internal/server and ./internal/testkit/... match the
parent apart from the new tests, which pass: TestPlanLaddersGolden and its
four subtests, TestPlanLaddersReadFailures and its nine, and the three
read-count rows. A second run without -update leaves the tree unchanged, and
the tests pass three runs in a row after a reset. Thirty-two edits to the
topology and the ladder code, each breaking one fact, fail the test that names
it.

Unprobed: each column's and list row's IsActive and description, the org-type
summary's display names (moved unchanged); the list's name filter and paging,
which no section of this step edits; the order of same-named products in the
shared index, which is unspecified today and which no section of this step
fixes; the reorder preview's classification path (rank 0 does not change in the
fixture); the topology's template rendering, which its own tests keep.
2026-10-09 17:44:40 -05:00
cgalo5758 0fe2305905 Characterize the products list: a golden of its rows and a table of its failed reads
Adds tests, two test-kit builders and goldens only; no production file
changes.

TestProductsListGolden builds thirteen products (a ladder tier, an off-ladder
product, an add-on, one with no set, one with no rules, one priced in the other
environment, an unlisted, an inactive, a draft, a retired, one needing an
unconfigured provider, and two sharing a set) and compares the rows the loader
builds, and the page rendered from them, with files under
testdata/operator_products, with Stripe configured and not. Each file was
read against the fixture before it was committed, and each failure file against
the fallback that read gives every row when it fails. The golden files show the
verdict, note, set name, category and visibility of every row, the provider leg
naming the unconfigured provider, and both set cells naming the shared set.

TestProductsListReadFailures pins, per read of the page (shapes, set names,
prices, price mappings, set rules, resource keys, and the page itself), the
rows the loader builds when that read fails, and that a failed page read says
"Failed to load products" and builds no row.

world gains EditProduct and Lifecycle. The read-count table already holds the
products row at 0 per row.

Names and results of ./internal/server and ./internal/testkit/... match the
parent apart from the new tests, which pass: TestProductsListGolden and its
two subtests, TestProductsListReadFailures and its seven. A second run without
-update leaves the tree unchanged, and the tests pass three runs in a row
after a reset. Nineteen edits to the list code, its readiness helper and its
template, each breaking one fact, fail the test that names it. One of them
(the price reader told "configured" when Stripe is not) changes no output in
that state, because the mapping read is skipped there and the map stays empty;
it is run in the configured state with "not configured" instead.

Unprobed: the view model's ProductID, Name, Description, IsActive,
LifecycleStatus, Lifecycle and EntitlementSetID fields as individual facts
(the golden compares them, and the other breaks show it compares every
field); the skipped mapping read when Stripe is not configured, since no
output depends on it; the order of the six reads; the row order, which the
list query fixes; that two products sharing a set pass its id to the set and
rule reads once (the ids a batch read receives change no output, and the page
reads the sets in one statement either way).
2026-10-09 17:44:07 -05:00
cgalo5758 f53186bb6b Characterize the four billing lists and the lookup's candidates, with their read failures and reads per row
Adds tests, three test-kit builders and goldens only; no production file
changes.

TestBillingListsGolden compares each list's data, in the default and the
all state, with a file under testdata/operator_billing for a fixture of two
organizations and four billing accounts (live, unrecorded, unmapped, test),
each with a subscription, an invoice and a payment. Each file was read
against the fixture before it was committed: the test-mapped row is hidden
by default with the line counting one, shown with the test badge in the all
state, the unmapped row reads not_mapped, and every row names its own
organization.

TestBillingListsKeepTheirFallbacksWhenAReadFails pins, per list, that a
failed organization read leaves the name empty and a failed mapping read
shows not mapped.

TestLookupInvoiceCandidatesNameTheirOrganizations pins the invoice
candidates' organization names and the organization id as their fallback.

The read-count table gains billing accounts, billing subscriptions, billing
invoices, billing payments and lookup rows. Each grow adds its rows on a new
organization named from a counter, and the lookup row is sent as an htmx
request. Measured per row: 2 for each billing list, 1 for the lookup, as
predicted.

world gains MapAccount, MapSubscription and MapPayment, which record no
environment, beside MapInvoice.

Names and results of ./internal/server and ./internal/testkit/... match the
parent apart from the new tests, which pass: TestBillingListsGolden and its
twelve subtests, TestBillingListsKeepTheirFallbacksWhenAReadFails and its
twelve, TestLookupInvoiceCandidatesNameTheirOrganizations and its two, and
the read-count subtests billing_accounts, billing_subscriptions,
billing_invoices, billing_payments and lookup. A second run without -update
leaves the tree unchanged, and the tests pass three runs in a row after a
reset. Thirty-two edits to the list code and the lookup, each breaking one
fact, fail the test that names it.

Unprobed: the goldens pin the loaders' other fields (dates, amounts, statuses,
account names, payment method, invoice numbers, Overdue, the pager and the
absence line) with no break, because no later section edits the code that fills
them; the breaks probe the fields the mapping and naming changes move.
2026-10-09 17:43:59 -05:00
cgalo5758 ee3b298287 Characterize the Products page, the entitlements panel, plan features and the price readers
Tests and fixtures only; no production file changes, so no output of the
console changes.

internal/testkit/world gains the plan fixtures, each through the console's own
query or function: Price, OneTimePrice, MapPrice, MapPriceIn, SortOrder,
Issue, Revoke, Floor, Use, Retire, Subscribe (with SubOpts, whose Policy is
written in the same statement as CommitmentEnd because the schema refuses a
commitment end without one), ConferUnprojected, ScheduleDowngrade,
ScheduleCancellation and CancelAtPeriodEnd. Every fixture date the page prints
is 12:00 UTC.

internal/server:
- TestMemberPlansGolden: three ladders and thirteen organizations, one subtest
  per state; buildPlansData as JSON and the member_plans.html render are
  compared with testdata/member_plans/<state>.{json,html}.
- TestMemberEntitlementsGolden: four organizations; GetEntitlements through a
  capture template set (data as JSON) and through the member set, compared
  with testdata/member_entitlements/<state>.{json,html}.
- TestMemberEntitlementsReadFailures: one subtest per failed read, plus the
  401 without a session.
- TestPlanFeatures, TestPriceReadersTrackTheDefaultAndClassifyItsMapping,
  TestOrgHasEntitlement, TestCheckoutAnswersHTMXWithAClientRedirect.
- failingEntitlementsQ and failingBillingQ gain the methods the new tests
  fail, and TestPlansShowTheLoadErrorWhenAReadFails and
  TestCheckoutRefusesWhenAReadFails gain a GetActiveAttachmentsByPool row.

internal/fulfillment: TestPlanChangeRefusesAnOrganizationWithoutAPool.

Shown to change no output: git diff against the parent touches no non-test
file outside internal/testkit/world; over internal/server, internal/fulfillment
and internal/testkit, run with the app down on a freshly reset test database,
all 1171 tests and subtests of the parent still pass with the same names and
results, and the 88 new ones (the tests above, their subtests, and the two new
rows) pass; each golden test passes three runs in a row on a reset database
and a second run without -update leaves git status clean. 25 breaks, each
caught by the new tests; none dropped, none left unprobed.
2026-10-09 01:16:28 -05:00