Commit Graph
201 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 701ab7e5b9 Refuse in the database a plan tier whose product has no entitlement set
Second section of #153's change. Migration 00035 adds two trigger
functions and two triggers on core:

- On a plan ladder tier insert, or an update of its product, the tier's
  product is read FOR SHARE and the row is refused with
  chk_plan_ladder_tiers_product_has_set when the product has no
  entitlement set.
- On an update that clears a product's entitlement_set_id (from a set to
  NULL; a product already without one is left alone), the update is
  refused with chk_products_tier_keeps_set when any ladder tier names the
  product.

The product row taken FOR SHARE by the tier trigger is a new lock; a tier
add and a set clear on one product cannot both commit. That holds under
READ COMMITTED with the trigger functions VOLATILE, which the migration
header and docs/database-locks.md now state; the product edit's write
runs at the server's default isolation today, and item 4.6 opens it
explicitly READ COMMITTED in section 4. docs/database-locks.md gains the
product row, the tier add's order and the second FOR SHARE exception;
docs/models/product-catalog.md invariant 13 and
docs/models/identity-organization-workspace.md invariant 7 state the
rule, with the set id that names no set (#99) as the case it does not
cover; docs/models/plan-ladders-transitions.md names it on the tiers
row.

Tests (internal/db/tier_products_keep_a_set_test.go):
TestATierOfAProductWithNoEntitlementSetIsRefused,
TestClearingTheSetOfATierProductIsRefused,
TestATierAddAndAClearedSetCannotBothCommit (both orders),
TestTierProductsKeepASetMigrationUpDownUp.

Race run (2.3): `go test -race -p 1 -count=1 -run
'^TestATierAddAndAClearedSetCannotBothCommit$' ./internal/db` after
test/reset-test-db.sh: ok (1.126 s).

The review's blocker: T2 renamed a product whose set was already empty
with SET name alone, which the trigger's UPDATE OF entitlement_set_id
never sees, so the break on the trigger's WHEN passed. The statement now
names the column too, and the break is caught.

Breaks (scripts/mutation-check, command records on the migration, each
preceded by a test-database reset; 14 of 14 CAUGHT):
- tier check never raises (T1)
- tier check never raises (T3)
- tier check raises for every product
- tier trigger on inserts only
- tier check constraint name changed
- tier lock removed (set clear returns)
- tier lock removed (tier insert returns)
- WHEN refuses a replacement
- WHEN refuses the state
- product check ignores the tiers
- product trigger not created (T2)
- product trigger not created (T3)
- product check constraint name changed
- Down leaves the product trigger and function

The migration adds no relation or column, so scripts/db-reader-role.sql
needs no re-run. make lint passes; openspec validate --strict passes.
The suite on reset databases: 4699 pass, 14 skip; two failures, both
outside this section: TestEveryRegisteredFormWasCaptured read the
capture list from before the previous commit's capture and passes after
it, and TestPlanLaddersWalkthrough timed out once on its create toast
(#222) and passed twice alone. make screens: no change from this
section.
2026-10-26 16:54:41 -05:00
cgalo5758 710591aad7 Give the demo's add-ons an entitlement set
The demo seed's three products on no ladder (demo-addon, demo-usage,
demo-onetime) had no entitlement set. Since the previous commit the Add
tier picker leaves out a product with no set, so the demo ladder had
nothing to add: the screen capture never rendered the tier add form, and
TestEveryRegisteredFormWasCaptured failed once make screens wrote a new
capture list. That commit's suite ran before the capture and did not see
it.

The add-ons now share a second demo set, demo_addons ("demo-addons"),
with the same two rules as demo_baseline (a limit of 5 on demo.widgets,
a boolean on demo.feature-x), created and found by its key like the
first and never written over. An add-on with no set is also #272's
fault in the demo's own data: a member could buy one and receive
nothing.

The seed golden, the demo README's catalog and counts, the identifiers
page and the demo seed spec's catalog requirement (a delta in
tiers-need-an-entitlement-set) say two sets.

make screens: the ladder page's open and invalid captures show the Add
tier form again, with the add-ons in its list; the Entitlement sets list
and the products list show demo-addons; the add-ons' product page reads
ready to grant. TestEveryRegisteredFormWasCaptured passes after the
capture. The suite ran on this tree with the next commit's migration;
TestPlanLaddersWalkthrough timed out once waiting for its create toast
(#222) and passed twice alone.
2026-10-26 16:53:43 -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 8c0fb4ae42 Archive the suspended-plans and reconcile pool-lock changes
reconcile-takes-pool-lock (#193) and suspended-plans-stay-held (#148)
move to openspec/changes/archive/ with every task ticked, and their
deltas are synced into the main specs:

- plan-transitions: 1 requirement modified (#193).
- member-dashboard, member-entitlement-view, member-offer: 1 modified
  each; member-plan-upgrade: 1 renamed ("active" to "live" ladder
  attachments) and 1 modified; member-invoice-history: 2 added;
  member-product-discovery, stripe-subscription-creation: 1 added each;
  plan-enrollment-administration: 1 added, 3 modified;
  stripe-subscription-management: 1 added, 2 modified (#148).

What #148's research and sandbox check taught, written into the pages
that own each topic:

- docs/models/plan-ladders-transitions.md: the member position's readers,
  all eight, each holding a suspended position as the organization's plan.
- docs/models/payments-billing.md: pausing collection is not the paused
  status, so the plan keeps delivering.
- docs/stripe.md: an expired pending upgrade's invoice is voided; a
  period-end cancellation stops retrying only that subscription's open
  invoice; only a subscription made outside the console reaches paused;
  paying on Stripe's page leaves the subscription's own default card in
  place; an uncollectible invoice's page still takes payment.
- docs/testing.md: what a break record's wanted message must be, and how
  to drive a failed renewal in the Stripe sandbox.
- status/design-feedback.md: the data model delivers through a past_due
  grace period, the console suspends at once (#255).
- The archived design gains three reasons: why the immediate-cancel
  refusal comes first, the https-only redirect, and the paused badge's
  tone.
2026-10-26 15:45:27 -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 e3f3ec56b9 Confer the higher tier when one subscription pays for two of one ladder
A subscription whose items name two tiers of one ladder (Plan and Plus,
say) conferred whichever its live read left, so the member could keep
the lower tier while paying for both. The item pass now runs
chooseTiers over the desired products, after the displacement rule has
left its products out: it walks them by their highest rank, keeps a
product whose ladders no kept product has taken, and outranks the rest;
a tie goes to the lower product id, and a product on no ladder is always
kept. Each outranked product is logged once at Warn ("reconcile:
product not conferred; a higher tier of the subscription holds its
ladder", with the product, the ladder and the product that took it). The rank compared is
a product's top rank across its ladders, so a bundle at rank 3 on one
ladder outranks a tier at rank 2 on a ladder they share.

reconcile_trace/two_tiers_one_ladder flips on purpose: Plus stays the
ladder's position, no core.confer, the Warn line names Plan. The
productsLeftAlone helper now receives the rungs the item pass read once,
which keeps the statement order of section 4's goldens unchanged.
docs/models/payments-billing.md states the choice in the Reconciler row.

Tests: TestChooseTiers (two tiers keep the higher, tiers of two ladders
are both kept, a bundle outranks a tier on one of its ladders, a higher
tier takes a shared ladder from a lower bundle, a tie, an add-on always
kept), each row in the given and the reversed order; T10
TestReconcile_TwoTiersOfOneLadderConferTheHigher with Plan then Plus and
Plus then Plan.

Breaks (scripts/mutation-check, 11 of 11 CAUGHT):
- the lower tier walked first (TestChooseTiers, T10)
- every product kept (TestChooseTiers, T10)
- the tie broken the other way
- a product on no ladder left out
- the Warn line removed
- top from the last rung
- any held ladder blocks
- a bundle never left out
- the left-out skip dropped (T1; section 4's skip break restated against
  this section's filter)
The first run reported four records WRONG-MESSAGE because the test
printed each row's order inside the parentheses; the test now prints
the row's name alone there.

The suite on reset databases passes, 4652 tests, 15 skipped; after the
review's fixes (test output, comments, a rename), ./internal/fulfillment
passes. make lint passes; openspec validate --strict passes.
2026-10-24 15:43:54 -04:00
cgalo5758 76ff9d693a Leave a ladder alone while another plan holds what it took
#212: when a grant or another source took a ladder from a paying
subscription, the subscription's next reconcile conferred its tier again
and ended the plan that held the ladder. Now the reconcile reads which
ladders the subscription was displaced on and leaves those alone while
the displacer still holds them.

Migration 00034 adds core.subscription_displacements(subscription, pool):
for each ladder, the subscription's latest episode; it is a displacement
when that episode ended in a supersession pair (core.supersession_pairs)
whose successor is another source, and it is returned while an active or
suspended, non-default position of another source holds the ladder. The
function returns the holder's provision and kind (grant, subscription,
purchase). A default-sourced position never holds against a subscription.

fulfillment.DisplacedProducts is the one rule over those rows: it reads
no database row, takes the desired products' rungs (ListConferralShapes,
fulfillment.RungsByProduct) and the displacements, and returns the
products to leave alone whole, any of whose rungs is on a displaced and
held ladder. The item pass reads both under the pool row, confers the
rest in ascending product id, and logs each left-out product once at
Info ("reconcile: product not conferred; another plan holds its
ladder", with product, ladder, holder provision and kind). Add-ons and
other ladders are conferred; live products only update their quantity.
When the holder ends, the subscription's next reconcile confers its tier
again.

Goldens: 29 trace goldens change. Four flip on purpose to the left-alone
answer (grant_over_subscription, grant_over_suspended_recovery,
second_subscription_over_first, downgrade_lands_while_displaced): the
Info line, no core.confer, no materialization, the holder's provision
still active. The other 25 gain only the two reads. The trace kit names
provisions by product and source. TestReconcile_SupersededWhileSuspended
NotResurrected now wants no live provision after the recovery.

Tests: nine TestSubscriptionDisplacements_* on the function (a grant
over the subscription, an extension still holds, the default does not
hold, a successor with no from side, an episode the subscription ended
itself, the subscription's own switch, the latest episode decides, a
second subscription, one ladder of a bundle); twelve on the reconcile (a
grant over the subscription and one of its own tier keep the ladder, a
marked grant is not replaced, the subscription delivers again over the
default and on a vacant ladder, a grant issued over the default and an
extended grant keep the ladder, a subscription bought over a grant
supersedes it, a later subscription keeps the ladder, a grant issued
while the reconcile waits is seen (also under -race), a bundle displaced
on one ladder is left alone, an item back on a ladder it left supersedes
the grant); a downgrade that lands while displaced is applied and not
conferred (sweep); TestDisplacedProducts.

Docs: plan-ladders-transitions.md invariant 20 and the function's row;
payments-billing.md, stripe.md, plan-management.md and database-locks.md
say a subscription waits while another plan holds what it took.

Breaks (scripts/mutation-check, 27 of 27 CAUGHT):
- convergeConferrals confers the left-out products (seven records: T1,
  T1b, T2, T3, T6b, T12, trace grant_over_subscription)
- DisplacedProducts matches the product, not the ladder (T8, unit)
- DisplacedProducts reads the first rung only (T12, unit)
- reconcileItems without its LockPools call (T11)
- the log line removed
- the item pass walks the ids in reverse (section 2's break restated
  against this section's loop, which now ranges over the ordered ids)
- holder: a default holds against the subscription (T4, function)
- an ended position counts as a holder
- displacer's chain: the holder must be the displacement's successor
  (T6, function)
- occupancy alone
- grant successors only (T9, function)
- the pair condition removed (function, T13)
- every episode read, not the latest
- the earliest episode read
- the pairs view without from_rank
The convergeConferrals record is written as
`if _, out := left[productID]; false && out {`, since `if false {`
leaves `left` unused and does not build.

Two mutants no test can see: productsLeftAlone passing the missing
products rather than every product (a live product's rungs are its
latest episodes, so it can never sit on a displaced ladder), and
DisplacedProducts keeping only the first row per ladder (the exclusion
constraint allows one live position per pool and ladder, and the
function returns one latest episode per ladder).

The suite on reset databases passes, 4650 tests, 15 skipped; after the
review's wording-only fixes, ./internal/fulfillment and
./internal/entitlements pass. make lint passes; openspec validate
--strict passes.
2026-10-24 15:22:10 -04:00
cgalo5758 8181d5461c State a supersession pair once, in core.supersession_pairs
A conferral that supersedes a ladder's incumbent writes two transitions
at one instant by one actor: the incumbent's end and the successor's
typed row. Migration 00033 adds core.supersession_pairs, which joins
those two rows of core.plan_transitions on pool, ladder, instant, actor
type and actor id, with a from-side on the typed row, and names both
provisions. A restoration (from_rank NULL) is not a pair. The view does
not group: a transaction that confers twice on one ladder at one
instant yields every end and typed combination. core.grant_replacements
is re-expressed over the view, its rows unchanged; the Down restores
00023's body before dropping the view. The console reader may select
the view (its column list gains five lines), and
docs/models/plan-ladders-transitions.md lists it.

test/pin-timestamps.sql now walks base tables only. It updated every
column information_schema.columns names, views included, and the demo
snapshot's rebuild failed on the new view ("cannot update view
supersession_pairs", the first suite run of this section stopped at the
app database reset). No existing view carries a pinned column, so the
snapshot's pinned values do not change.

Tests (internal/entitlements): the pair names both provisions; an
expiry's restoration is not a pair; a whole-bundle end pairs only the
contested ladder; a chain of acts pairs each end at its own instant; an
expiry's end is not paired with a subscription's conferral. The
extension filter's ep.grant_id clause has no break: only a grant
extension succeeding a subscription would need it, and no test builds
that case.

Breaks (scripts/mutation-check, command records on 00033, 8 of 8
CAUGHT):
- view without from_rank
- view without same ladder
- grant_replacements without extension filter
- no SELECT grant on the view
- view selects reason
- view swaps the two provisions
- view without same instant
- view without same actor type

The suite on reset databases passes, 4627 tests, 15 skipped. make lint
passes; openspec validate --strict passes.
2026-10-24 14:43:57 -04:00
cgalo5758 16cc1de651 Take the pool row in every branch of the subscription reconcile
ReconcileSubscription changed a provision's status and materialized the
pool holding only the shared materialization lock and the subscription's
own lock (#193). An operator act on the same pool, such as issuing or
revoking a grant, could run at the same time; each transaction computed
the pool's entitlements without the other's uncommitted change, and the
last commit won, leaving entitlements that missed one of the two changes.

The reconcile now takes the pool row through LockPools after the
subscription lock and before any write, in all three branches:
- active: before the resume's sync_source_status;
- suspended: before the suspension's sync_source_status;
- ended: before the floor restoration. When a provision ended,
  end_conferral and resumption already hold the row and the call
  re-takes it; on a replayed cancellation that ends nothing, it is the
  first take, so the vacancy read and the materialization run under it.
The order matches docs/database-locks.md: no path takes a pool row and
then a subscription lock, so no cycle appears.

docs/database-locks.md: the pool row's "Taken by" cell, "Subscription
reconcile" and rule 2 name the three calls; rule 2 loses its exception
and "Where the paths differ" loses the suspended branch's entry.
docs/models/resource-pools.md: invariant 11 names the reconcile's own
call in all three branches.

TestAStatusChangeAndAGrantActOnOnePoolKeepBothChanges runs a suspension
and a recovery beside a grant act on one pool, waits on the other side's
lock (AwaitLockWait, no sleep), and compares the stored limit with the
sum of the active provisions. Thirty-three trace goldens gain one
LockResourcePool line each; the eight the tasks list stay unchanged.

Race run (2.2): `go test -race -count=1 -p 1 ./internal/fulfillment`
after test/reset-test-db.sh: ok (41.3 s).

Breaks (scripts/mutation-check, 5 of 5 CAUGHT):
- reconcileSuspended without its LockPools
- reconcileItems without its LockPools
- reconcileItems LockPools after SyncSourceStatus
- reconcileSuspended LockPools after SyncSourceStatus
- reconcileEnded without its LockPools

make lint passes; openspec validate --strict passes; the suite on reset
databases passes, 4616 tests, 15 skipped.
2026-10-24 10:25:45 -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 715404500a Archive the runtime database user and the boot that serves first
serve-before-dependencies (#141) and runtime-database-user (#23) move to
the archive, and their deltas are synced into openspec/specs. The two
Purpose edits a delta cannot make are made here: temporal-connection's
Purpose says start connects in the background and retries for the life
of the process, and error-pages' names the 503.

What the two investigations learned beyond each decision moves into the
docs:

- go-conventions.md: a new section on code that reaches a service that
  answers after the listener opens (holders, per-request reads, the
  refused write, retryloop, viper writes before the listener).
- production-deployment.md: what a liveness probe requests, and why it
  must not wait on Temporal or the identity provider.
- building-an-integration.md: declared paths skip the session layer;
  a completed workflow id runs again, and the guarded first update.
- models/payments-billing.md: invariant 14 states the 503 and
  Discourse's failed rows; invariant 15 states the guarded update.
- operator-ux-conventions.md: an error answer carries no HX- header.
- database-management.md: why writers hold only the four DML
  privileges, identity columns and sequences, the qualified SQL the
  migration user runs, and sqlc alias lists for set-returning functions.
- database-locks.md: rule 9, a row lock on a table the writer cannot
  update is taken in a SECURITY DEFINER function.
- testing.md: privilege tests drop to console_runtime, race-only breaks
  as command records, the race run's -p 1, synctest for timer loops,
  and test workers off the console's task queue.

Both archived design.md files gain the options weighed and the drafts
the approved copy replaced.
2026-10-24 02:00:58 -04:00
cgalo5758 34d971bd15 Restore the Keycloak walkthrough the boot docs commit dropped
2d71aa8d added the Token URL section to
docs/temporal-authorization-setup.md and, by mistake, deleted the
Keycloak walkthrough before it: the design overview and steps 1 to 5,
137 lines. They come back word for word. The correction #161 asks for
in that walkthrough stays with #161.
2026-10-24 01:59:46 -04:00
cgalo5758 67e69eed0d Name Temporal in the end date's refusal
The end date's field error read "An end date needs the expiration
scheduler, which is not connected." It now reads "End dates can't be
saved while Temporal is not connected.", which names the service that
whoever runs the console configures and searches for.

The bounded-grant test compares the message as html/template renders
it, apostrophe escaped, so its checks that the message is absent no
longer pass on the escaping alone.
2026-10-24 01:44:34 -04:00
cgalo5758 2d71aa8db0 Describe the boot that serves before its dependencies
Seventh section of #141, the docs. docs/production-deployment.md's first
boot section lists the steps in the order start runs them, says what
serves from the moment the listener opens, and describes the two loops
that run beside it: the identity provider's discovery, with sign-in at
503 until a usable document is published and roles not re-derived
before a document with the configured issuer is read, and Temporal,
retried for the life of the process with its log turning to Error after
temporal-connect-timeout. The service list says that Stripe and
Discourse need Temporal. README.md and docs/plan-management.md lose
their description of a bounded retry before the server comes up.

docs/environment-reference.md rewrites the temporal-connect-timeout and
temporal-host rows; docs/stripe.md and docs/discourse-setup.md say why
temporal-host is required; docs/building-an-integration.md documents
NeedsTemporal. docs/fedwiki-setup.md says the console answers the
certificate ask as soon as it serves. docs/identity-provider-setup.md
and internal/auth/README.md replace "refuses to start" with what the
console does, and docs/temporal-authorization-setup.md explains the
token URL. Comments in the FedWiki and Stripe integrations and the
FedWiki workflows README that still described the old boot are
corrected. Every quoted log line is the one the code writes.

No code changes. make test passes with the test stack up, and go test
-race -p 1 over cmd, auth, server, retryloop, workflows and the three
integrations after reset-test-db.sh finds no data race.
2026-10-23 21:07:48 -04:00
cgalo5758 3bfd0f67f4 Read the Temporal connection per request, and answer for its absence
Fifth section of #141. internal/workflows/clientholder holds the Temporal
client: start builds an empty holder, hands it to the server and the
integrations, and publishes the client once, after the worker starts and
before the first Startup hook; a second Publish panics. Every handler
and integration that starts a workflow reads the holder when a request
arrives, so a client that connects after the listener opens reaches
them without a restart; a test scans the handler configurations and
fails on any field that still holds a client.Client.

While the holder is empty:
- POST /webhooks/stripe stores the event as received, then answers 503
  with Retry-After: 5 and the body "Temporal is not connected", and logs
  that it answered 503 so Stripe redelivers it. A redelivery after
  Temporal connects starts the event's workflow, and the processing
  guard keeps a finished event from running twice.
- A Discourse event is recorded as failed with "start reconcile:
  Temporal is not connected"; the sweep restores what it missed.
- Issuing or managing a grant with an end date is refused under the
  end-date field with "An end date needs the expiration scheduler, which
  is not connected.", after the date itself is checked, and writes
  nothing. The operator page's "no scheduler" warnings, which saved the
  grant and left it without an expiry, are gone.
- FedWiki's site actions and the domain claims answer that they are not
  available, as they did with no Temporal configured.

The operator test harnesses and the plan-management end-to-end harness
publish a holder over a fake client whose ExecuteWorkflow succeeds, so
every bounded save keeps its result.

Full suite on reset databases: 4485 pass, 15 skip, none failed. The console booted on the
test stack and connected to Temporal. go test -race -p 1 over
clientholder, internal/server and the three integrations after
reset-test-db.sh: every package passes, no data race. make lint passes. Breaks, each caught:
- 5.10 member domains handler reads the client at construction
- 5.10 FedWiki site action handler reads the client at construction
- 5.10 Stripe environment-check handler reads the client at construction
- 5.10 the holder is a plain field in place of an atomic.Pointer (-race)
- 5.10 a client.Client field added back to MemberDomainsConfig
- 5.10 the scan ignores import names, so a temporalsdk.Client field would pass
- 5.10 Stripe StartProcessing reads the holder at registration
- 5.10 the webhook answers 500 for ErrNotConnected
- 5.10 the webhook answers 503 before the insert
- 5.10 Discourse StartReconcile calls the nil client
- 5.10 the issuance refusal after ConferGrant
- 5.10 the management refusal removed
- 5.10 the issuance refusal applied to a grant without an end date
- 5.10 the management refusal applied to a save that clears the end date
- 5.10 Publish stores a second client without panicking
- 5.10 a temporalsdk.Client field added back to a FedWiki handler configuration
- 5.10 Discourse StartReconcile reads the holder at registration
- 5.10 the issuance refusal before the parse
- 5.10 the management refusal before the parse
- 5.10 the issuance refusal is a form error, not under the end date
2026-10-23 19:11:03 -04:00
cgalo5758 29520a8120 Set the Stripe key once at boot and read the boot mode everywhere
First section of #141. start assigns the stripe-go key through UseKey,
directly after it derives the key's mode and fingerprint and before any
goroutine starts; the Stripe integration's Startup no longer assigns it,
and nothing else does. UseKey assigns the trimmed key, so the SDK sends
the key whose mode and fingerprint boot derived, and an empty key leaves
the SDK's key as it was.

Every surface that asked a function for the key mode now reads the mode
boot derived: Checkout, the plan moves and the eager reconcile in
internal/server, the fulfillment entry points (SwitchPlan,
KeepCurrentPlan, CancelSubscription, the two reconciles and the
scheduled-change sweep), and the worker's billing activities. The Stripe
workflows' activities still read the SDK's key, which start set before
the worker existed. SDKKeyMode, Config.StripeKeyMode and
WorkerConfig.StripeKeyMode are gone. stripe-api-key is a secret, which
an operator override cannot replace, so the value boot reads is the one
Startup used to assign.

One behaviour changes until the next section lands: with no
temporal-host, the SDK now holds the key, so checkout sessions and plan
moves reach Stripe while the webhooks they cause are stored and never
processed. The next section refuses that configuration; the two are not
deployed apart.

The tests pass the boot mode, stripetest.KeyMode(), where they passed
the function. TestCheckoutThroughTheRouter lost its count of how often
the mode function was asked, since the function no longer exists, and
gained a row that posts a price mapped in live mode under the test key
and wants 422, which fails if the router hands Checkout an empty mode.
The eager reconcile's wiring is covered by no break.

Race baseline (go test -race -p 1 -count=1 over cmd, auth, server,
workflows and the three integrations, on bdd042bc after
reset-test-db.sh): 19 packages pass, no data race. An earlier run
without -p 1 failed two desiredstate relay tests on counts, because the
packages ran in parallel over one test database; the race gates now run
with -p 1 after reset-test-db.sh, as make test does.

Full suite on reset databases: 4373 pass, 15 skip, none failed. make lint passes. Breaks,
each caught:
- UseKey assigns an empty key
- Startup assigns stripego.Key from the configuration again
- the checkout handler compares against an empty mode
- SwitchPlan reads an empty mode
- SweepDueScheduledChanges passes an empty mode to the change it fires
- UseKey assigns the untrimmed key
- the router hands Checkout an empty mode
2026-10-23 13:39:27 -04:00
cgalo5758 e722e43603 Document the two database users and how an existing deployment moves to them
Sixth section of #23, docs only. docs/production-deployment.md explains
why the console connects as two users and what the runtime user may not
be (a superuser, an owner of the database or of any object, or a member
of any role but console_runtime), how a new deployment creates the
runtime login with its password set in psql, how to run migrations as a
separate step, and the steps for a deployment that has connected as one
login so far: create the runtime login, store its string as a new secret
(Docker Swarm secrets cannot be changed), point MC_DB_DSN_FILE at it and
MC_DB_MIGRATE_DSN_FILE at the existing secret, deploy, verify the
sessions, privileges, memberships, database settings and the functions
of public, re-run the reader-role script, and how to roll back. The
bootstrap superuser of the official image cannot lose SUPERUSER, so it
stays as the migration user. docs/database-management.md runs the
reader-role script as the migration user.

make lint and TestEnvironmentReferenceDocumentsEveryConfigKey pass; the
section changes no code, and the previous section's full suite ran with
these files already in the tree.
2026-10-23 09:43:57 -04:00
cgalo5758 f9c9b3225e Run the console on a runtime user and migrate on a second connection string
Fourth section of #23. db-dsn now names the console's runtime user, and
the new db-migrate-dsn (or db-migrate-dsn-file) names the user that runs
migrations. start opens both, migrates on the migration connection,
grants console_runtime to the runtime user there (WITH INHERIT TRUE, the
name quoted by the database), closes it, and serves on the runtime pool
alone. With db-migrate-dsn left out, start runs no migration and refuses
when the schema is behind its code, which is how a host runs migrations
as a separate step; migrate up, down and status use the migration string
and refuse without it, with no fallback to db-dsn.

Both pools are opened from one pgx.ParseConfig: the migration pool's
search_path is public and both pools' role is none, set in the startup
parameters after every key of either name is removed case-insensitively,
so neither a setting the database or role carries nor a key the string
repeats in another case moves a session off its login or its path.
PendingMigrations reads each stream's ledger by qualified name inside a
transaction pinned to search_path pg_catalog, pg_temp.

The test stack runs on two users: postgres-seed creates
member_console_runtime in new and existing volumes, bootstrap-stack.sh
writes MC_DB_DSN as that user and MC_DB_MIGRATE_DSN as member_console,
and the four scripts refuse a .env without the migration string, with
the command that regenerates it. Scripts and fixtures that need a
superuser on the application database (the reset, the screens'
timestamp pinning) use the migration string.

This worktree's stack was regenerated with that command and the console
booted as member_console_runtime: its sessions were the runtime user's
only, no migration-user session stayed open, and the runtime user could
not insert into core.pool_provisions.

Full suite on reset databases: 4277 pass, 15 skip, none failed. The
walkthroughs' names, results and skip messages match the previous
section's run. make screens lists three changed screens, all data:
regenerated ids on operator-person-detail and
operator-billing-invoice-detail (the demo snapshot was rebuilt) and a
domain's age on operator-domains (294d to 295d); the baseline was not
accepted. make lint passes.

Deliberate breaks, scripts/mutation-check -side new, all CAUGHT (36):
  dbRuntime runs the migrations on the runtime pool
  dbRuntime skips the boot grant
  dbRuntime leaves the migration pool open
  dbRuntime skips the pending check when db-migrate-dsn is unset
  dbRuntime migrates on db-dsn when db-migrate-dsn is unset
  GrantRuntimeRole without WITH INHERIT TRUE
  GrantRuntimeRole with %s in place of %I
  PendingMigrations takes a missing ledger as current
  PendingMigrations resolves the ledger without the public. qualifier
  the migration pool opened without search_path=public
  the migration pool opened without role=none
  the runtime pool opened without role=none
  ConnectMigration keeps the search_path and role the string carries
  dbMigration falls back to db-dsn
  ValidateStart accepts a malformed db-migrate-dsn
  db-migrate-dsn-file is not a database secret pair
  db-migrate-dsn-file is not a database secret pair (the migrate commands read db-dsn-file only)
  the bootstrap guard removed
  reset-test-db.sh guard removed
  seed-demo.sh guard removed
  reset-app-db.sh guard after its first psql
  startup parameters match their keys case-sensitively
  the migration pool opened without role=none (keys and options in the string)
  the runtime pool opened without role=none (keys and options in the string)
  the runtime pool replaces the search_path the string carries
  the string's spaces are written as plus signs
  ValidateStart reads db-migrate-dsn as set whatever it holds
  PendingMigrations lists only the versions the ledger shows when it is missing
  migrationDSN keeps whitespace
  migrate up accepts a whitespace-only db-migrate-dsn
  runtimeDSN accepts a whitespace-only db-dsn
  openDB runs the migrating boot for an unknown mode
  bootstrap-stack.sh trusts the exported MC_DB_MIGRATE_DSN
  reset-test-db.sh trusts the exported MC_DB_MIGRATE_DSN
  reset-app-db.sh trusts the exported MC_DB_MIGRATE_DSN
  seed-demo.sh trusts the exported MC_DB_MIGRATE_DSN
2026-10-23 08:01:14 -04:00
cgalo5758 3cdf7bbc25 Create webhook partitions and take the plan-act obligation lock through core_owner
Third section of #23. Two runtime paths worked only because the
console's login is a superuser; once it runs as console_runtime, both
would fail.

Creating a partition requires owning the parent table, so the boot step
and the maintenance schedule could not create a webhook_events partition
as the runtime user, and inserts would start failing once the lookahead
ran out. Migration 00031 gives core.webhook_events and each of its
partitions to core_owner (partitions do not follow their parent's owner)
and adds core.ensure_webhook_event_partitions, a SECURITY DEFINER
function owned by core_owner, with a pinned search_path, EXECUTE only
for console_runtime and a window of 0 to 12 months, that computes month
bounds in UTC whatever the session's time zone.
EnsureWebhookEventPartitions calls it. The core baseline's initial
partition block now computes its bounds in UTC too (a quick fix listed in
the change): in a non-UTC session it ended the first partitions at local
midnight, which the next UTC partition would overlap.

The plan-act obligation move locked its row with SELECT ... FOR UPDATE on
core.plan_act_obligations, which the enclosure leaves the writer only
SELECT on; Postgres requires UPDATE for a row lock, so every move would
fail as the runtime user. Migration 00032 adds
core.lock_plan_act_obligation, owned by core_owner, which takes the same
lock and returns the same row, and the LockPlanActObligation query calls
it. The lock lasts until the caller's transaction ends, as before;
docs/database-locks.md says where it is taken now.

schemaSnapshot compares ACLs as sets, so the ownership move's down-then-up
round trip compares equal on a correct Down, which re-grants core_owner.

TestPlanActOwnerRuleIsStatedOnce changed: LockPlanActObligation leaves
its list of queries that call core.plan_act_owns_org_type, because it now
calls core.lock_plan_act_obligation, and that function joins the list of
functions whose definitions must call it.

TestBaselinePartitionBoundsAreUTC checks the bounds on any day, but it
can observe the UTC month computation only within the 14 hours before a
UTC month start, because the baseline block reads the real NOW(); a run
that crosses a UTC month start between the migration and the test skips.
The function's session-zone break is caught by the partition-exists
assertion (the gap test's now is 2119-12-31 12:00Z, already 2120 in
Pacific/Kiritimati); the last-hour insert catches bounds rendered without
AT TIME ZONE 'UTC'.

Full suite on reset databases: 4215 pass, 15 skip; one failure,
TestEntitlementSetsWalkthrough's missing toast (#222), passed when run
alone. make lint passes.

Deliberate breaks, scripts/mutation-check -side new, all CAUGHT (33),
among them: the month or the bounds in the session's time zone; no
EXECUTE grant to console_runtime; SECURITY INVOKER; no SET search_path;
EXECUTE left with PUBLIC; the partition ownership loop removed; a window
of 13 allowed; monthsAhead - 1 passed; the baseline block in the session
zone, or restored whole; the Down giving the parent or the partitions to
the wrong owner, or without the core_owner re-grant; ACLs compared in
stored order; the direct-DDL test without its SET LOCAL ROLE; the lock
query as the bare FOR UPDATE again; the lock function without FOR UPDATE,
without the owner rule, as SECURITY INVOKER, or returning a composite
row; the owner-rule test's function list without the lock function.
2026-10-23 06:53:01 -04:00
cgalo5758 028e2ef448 Grant every writer to one runtime role, and leave writers only the four writes
First section of #23. The console still connects as before; this section
puts in place the role its runtime user will hold.

Each stream's new migration grants its writer, and SELECT on its goose
ledger, to console_runtime, a NOLOGIN role core creates, and revokes the
writer from member_console where an earlier release granted it there.
The five baselines no longer grant a writer to any login, so a fresh
install needs no role of a particular name.

The baselines granted ALL on their tables to the writers, which gave the
runtime path TRUNCATE, REFERENCES, TRIGGER and MAINTAIN as well. With
TRIGGER, a user holding the writer could put a trigger on an ordinary
table; a SECURITY DEFINER function's write to that table then ran the
trigger as the function's owner, which wrote the enclosed tables
directly. Each stream's writer_dml_only migration revokes the four, and
TestRuntimeRolePrivileges fails, naming the relation and the privilege,
when console_runtime holds any of them, holds a column grant that reopens
a write, or is denied a write it needs.

The enclosure tests now drop to console_runtime instead of core_writer
or a copied role, and the conferral enclosure test probes TRUNCATE,
CREATE TRIGGER and LOCK TABLE on the three position tables.

Full suite on reset databases: 4195 pass, 15 skip; one failure,
TestIntegrationSettingsWalkthrough's missing toast (#222), passed when
run alone. make lint passes.

Deliberate breaks, scripts/mutation-check -side new, all CAUGHT (39):
  core 00029 without GRANT core_writer TO console_runtime
  fedwiki 00005 grants domains_writer in place of fedwiki_writer
  stripe 00006 without its revoke from member_console
  discourse 00004 without its ledger grant (two tests)
  domains 00003 without GRANT domains_writer TO console_runtime
  domains 00003's revoke without its guard
  core and discourse baselines with the login grant restored
  core 00029's Down revokes core_writer from console_runtime
  domains 00003's Down revokes domains_writer from console_runtime
  core 00029's Down drops the role without its dependency handler
  core 00029's Down grants core_writer back to member_console
  core 00030 emptied (catalog test, three probes)
  core 00030 leaves TRIGGER (catalog test, probe)
  core 00030 revokes from two position tables only
  the domains, fedwiki, stripe and discourse writer_dml_only emptied
  GRANT ALL on integration_config_overrides appended after the revoke
  00009's grant loses core_writer
  00027's SELECT on acts becomes SELECT, INSERT (two tests)
  00005's revoke loses INSERT; 00024's SELECT becomes SELECT, INSERT
  core 00029 also grants INSERT, or TRIGGER, on pool_provisions (four)
  column UPDATE and REFERENCES grants on pool_provisions, column
  UPDATE on the outbox's subject_id
  test side: the membership assertion without its fedwiki_writer
  exception; a locked entry for a table that does not exist
The two converted checks were not run on the old side.
2026-10-23 05:56:52 -04:00
cgalo5758 5219a45a4b Keep only the forum ids of a Discourse webhook event
The Discourse receiver stored each verified event body whole in
core.webhook_events.payload, so every forum signup's email address,
username, name, avatar, profile fields and external id stayed in the
database and its backups, members and non-members alike (#163). Nothing
reads a stored Discourse payload back: the receiver handles the event
from the body it was delivered.

The receiver now parses the body once, after it takes the dedupe key,
and stores only the ids processing reads: {"user":{"id":...}} for
user_created, {"group_user":{"group_id":...,"user_id":...}} for the two
group events. A missing id is stored as 0. A body that does not parse,
or lacks the object its event names, is stored as {} and recorded
failed. Email mode still reads the address from the delivered body.

Two answers change. A signed body that is not JSON, or is empty, failed
the JSONB insert: 500, no row, and redelivery of the same bytes, which
can only fail again. It now gets a failed row and 200, as every
recorded event does. A body Go parses but JSONB rejects (a \u0000
escape, invalid UTF-8) answered 500 on every delivery; its ids are now
stored and it is processed.

docs/models/payments-billing.md says what each receiver stores, and the
comments of scripts/db-reader-role.sql stop calling the column raw
provider payloads; no reader column changes. The OpenSpec change
discourse-events-keep-forum-ids is added with this commit. Ride-along:
the test helper newMapping drops a return value nothing read.

Deliberate breaks, scripts/mutation-check -side new, 29 records, all
CAUGHT:
  the insert stores the raw body (8 rows of both tests)
  keep copies the email into the stored user
  keep drops UserID from the stored group user (both group rows)
  keep swaps GroupID and UserID (both group rows)
  keep takes the user object for every event type (3 rows)
  keep stores a user with id 0 when the user object is nil
  a parse error stores keep of the partly filled view
  the parse-error path answers 500 (the three parse-error rows)
  resolvePerson is called with an empty email
  an event of another type is not dropped
  a group event without group_user reconciles from the user object
  the insert writes the body into provider_environment
  the processed body is the stored one (NUL escape, invalid UTF-8)
  the stored group user omits a zero group_id, and a zero user_id
2026-10-22 19:08:33 -04:00
cgalo5758 0d81d9b627 Describe usage as a count its owner writes under the row's lock
docs/models/entitlements.md (invariant 13) and docs/models/resource-pools.md
(invariant 8) now say that a metered key's usage row holds an absolute
count of the pool's units, written by the key's owner under the row's
lock with the count as its own statement, and that consumers only read
it. The pages no longer describe a counter that consumers increment or an
atomic draw against it; the purpose list, the consumers row, the pools
introduction, invariants 4, 7 and 9, and the vocabulary lines of the pools
and provider-integration pages follow.

docs/go-conventions.md: a transaction whose correctness needs each
statement's own snapshot opens with db.ReadCommitted, and the doc comment
of the function that opens it, or of the function it relies on, says so;
the FedWiki sync's insert and farm-absent delete, the permanent delete's
row transaction and DecrementUsageActivity now say it.

status/design-feedback.md records that the design describes usage as a
counter consumers increment and does not name reservations in flight.

The deadcode entries for startClaimVerification and claimErrorMessage now
name #44, and the complexity exceptions of CreateSite and swapActiveSite
name #174, which deletes both functions; their pinned texts are unchanged.
2026-10-22 14:39:22 -04:00
cgalo5758 9ff125dd26 Write the FedWiki sync's status changes under the site row's lock
The sync no longer writes the farm's earlier status over an outcome that
committed after the farm list was taken. ListFedWikiSitesActivity reads
the database clock before the farm call and passes it on as ListedAt.
A site whose request was updated at or after that moment is read from
the farm again. Each status write locks the site row, checks again for an
accepted request and for a request updated since the entry was read,
writes, and recounts the pool, all at READ COMMITTED. The insert and the
farm-absent delete recount in their own transactions; a status that did
not change writes only the observed storage and last-modified time.
SyncSitesToDBActivity is split into functions under the complexity limit
and loses its exception.

The site's name is now servable or not by the status the row holds after
the write, not by the listed status. A status-unchanged pass writes no
status, so following the listed one would make an archived name
servable again.

Two older gaps in the same code ride along. A site permanently deleted
after the list was taken was inserted again as a System row for one
tick; both request sets now also match a request by its recorded domain,
so the sync reads that domain again. A row a create recorded after the
pass read its rows had the farm's status written over it by the insert's
upsert; the insert is now InsertSiteFromFarm, ON CONFLICT DO NOTHING, and
leaves that domain to the next pass.

The stale farm list interleaving now leaves its row archived and its name
unservable. The package's Temporal test environment waits 30 seconds
instead of 3, because a run can reach a sync pass whose closing recount
reads every pool in the shared test database (#192). No history is
recorded for the sync workflow; the recorded ones replay unchanged.
2026-10-22 12:57:13 -04:00
cgalo5758 60981ce396 Read a closed FedWiki activation back from the farm before settling it
Settle no longer ends a closed set_status to active as failed while its
request still holds a slot. It starts ReconcileFedWikiSiteStatusWorkflow
(reconcile-fedwiki-status-<instance>-<generation>), which loads the
request, reads the farm's entry with no attempt limit, and records the
site active when the farm holds it so, or ends the request failed with
retries_exhausted when the farm holds another status or no entry, or
when the site row is gone. Either outcome removes the hold and recounts.
A farm that cannot answer keeps the request accepted and its slot held.

The reconcile's request load retries without an attempt limit too. A
reconcile already running counts as handed on. A request the first
pass's reconcile settled before the second pass reached it is logged at
Info, not as a failed finish.

The boot order and settle then sync interleavings now run the reconcile,
and each compares the site, the request and the figure before and after
the next sync. docs/database-locks.md gives settle's and the reconcile's
rows. No history is recorded for the new workflow; the recorded ones
replay unchanged.
2026-10-22 01:54:20 -04:00
cgalo5758 1b2dc773eb Count FedWiki's site usage instead of keeping a running total
The fedwiki_sites usage row now holds an absolute figure: a pool's active
site rows plus the slots its in-flight creates and activations hold.
Every writer recounts under the usage row's own lock, with the lock and
the count as separate statements under READ COMMITTED, so each count sees
every change committed before the lock was granted.

- fedwiki.slot_holds keeps one row per request between its slot gate and
  its outcome. It has no foreign key to core.lifecycle_requests, because
  no integration table references a delivery table; the count joins the
  request row.
- The create and status workflows gate on a version marker. Version 1
  takes the slot in HoldSiteSlotActivity and drops the compensating
  decrement; the recorded histories replay through the default branch.
  A status change whose farm call fails for a reason other than a refusal
  reads the farm back before releasing its slot.
- Each outcome step removes its hold and recounts in the transaction that
  records the outcome; settle finishes through the same function; the
  sweep takes no slot before its farm call and reverts a reactivation
  that finds the pool full, counting held slots in its headroom; the sync
  ends and boot begins with a recount of every pool, which writes only a
  figure that changed.
- AtomicIncrementUsage, AtomicDecrementUsage, RaiseNumericUsageTo,
  BoundNumericUsage, ListWorkspaceIDsHoldingUsage, CountSiteSlotsByPool
  and DeleteSiteReturningStatus are deleted, with the boot repair and the
  sync's drift arithmetic. Core keeps LockNumericUsage and
  SetNumericUsage.
- docs/database-locks.md gives the usage row's place in the lock order
  and the gate's, outcome, sweep and boot paths.

Tests: the six interleavings now find the figure equal to the rows plus
holds, except that the stale farm list still leaves its row active. Four
changed a step besides their expectation: the double take restores
through HoldSiteSlotActivity at a limit of 2, the boot order and settle
then sync hold their slot, the double release's side transaction writes
no decrement, and the unlocked raise waits on LockNumericUsage and
recounts in its side transaction. Settle then sync starts from a figure
of 1. quota_check_db_test.go's failing limit read runs the recorded
branch. TestGrantRevocationBlocksSiteCreation is now
TestGrantRevocationLeavesTheFigureOverTheLimit.

The two server goldens that call World.Use write the same figures from a
zero row, and no golden moved; the suite passed.
2026-10-22 01:24:58 -04:00
cgalo5758 db1bf7c287 State each spec's purpose, and refuse the archive's placeholder
When `openspec archive` creates a spec, it writes "TBD - created by
archiving change ..." as the spec's Purpose, and `openspec validate
--strict` accepts it. Twenty-five specs still carried it.

Each of the 25 now says what it defines, in one to three sentences
drawn from its own requirements, and each was checked against them.

The lint gains spec-purpose-placeholder: it reports a spec under
openspec/specs/ whose Purpose is the placeholder or empty, at the
placeholder's line, so an archive that creates a spec cannot land
until its Purpose is written. `--specs-dir` points it elsewhere or
turns it off. A table test covers the placeholder, an empty Purpose,
one followed directly by a requirement heading, trailing spaces,
Windows line endings, a written Purpose, a spec with no Purpose, and
a placeholder outside the section. docs/testing.md lists the rule.
2026-10-20 15:00:04 -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 95a863e05b Offer no tier removal that is certain to be refused or cannot be read
A refused removal preview still asked for a disposition and showed the
Keep and Migrate choices beside a disabled "Apply change" whose reason
showed only in a tooltip. The tier card's rank 0 offered Remove while it
could not read the org types its last-tier guard needs, and the only
tier of a default ladder offered a Remove the act always refuses
(#187).

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

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

The full suite passes.
2026-10-20 03:59:41 -04:00
cgalo5758 9b0e2e5677 Describe the act record, the obligations and the position copies
The plan acts added structures the model docs did not describe: the act
record and its detail, the reached types and the rule that the newest
act to reach a type owns it, the obligations and their statuses, the
grants' act id, and the two copies on each position (#187).

- docs/models/acts.md is new. It describes the act header and its
  causes, the actor triple, the note and who may read it, the request
  id, the detail tables, the reached types and the ownership function,
  the rows that point at an act, the obligations and how their status
  changes, and which functions alone write these tables. It also says
  that nothing in the record is purged, so an obligation keeps its pool
  and that pool's organization.
- docs/models/plan-ladders-transitions.md: the map rows for ladder
  management and default changes name the acts, the listing, the
  obligations and the drain. Invariant 17 says one builder writes every
  plan act's reason. The last-tier refusal holds for the delete and the
  removal alike.
- docs/models/entitlements.md: core.grants.act_id, and the position's
  org_type and default_sourced copies, with the rule they serve and the
  guard triggers that keep the facts they copy fixed.
- docs/go-conventions.md: the plan acts' runner is the pattern for an
  act that locks several pools, and core.plan_position_is_outgoing and
  core.plan_act_owns_org_type are rules that SQL and Go both read.
- status/design-feedback.md: three entries for the design's data model.
  It has no act record, its transitions use a different actor
  convention from the act's audit triple, and it does not list the
  position's two copies.

Task 9's text now says what the code does in three places: a default
change's reason names the org type, not a ladder; the grants' foreign
key keeps an act, not a pool; and the guard triggers keep five facts
fixed, a provision's pool among them.
2026-10-19 22:12:33 -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 2375d6ee29 Correct four statements in the testing and Go convention docs
The page anatomy rule list left out actions-column, which the lint
emits. The fixtures paragraph said OrgType is the one raw INSERT, but
Draft and Lifecycle also write a product's lifecycle status and
Subscribe a subscription's commitment end, because no console act does
either. The stack script paragraph said the scripts refuse arguments
they do not take without saying which four. The read page bullet held
up operatorNames as a name resolver without saying that a failed read
still falls back to the caller's text for a missing name (#207).
2026-10-16 11:10:00 -05:00
cgalo5758 c8263bfe0e Turn on nilerr: a function that returns an error may not swallow one
docs/go-conventions.md says a failed read is never an answer, and
nothing checked it. nilerr refuses one form of that: a function that
returns an error catches one and returns nil. On the tree it found 15
such returns. 12 were the project lint's walkers, which the parent
commit replaced. The other three:

- CheckQuotaActivity answers a failed read of the sites entitlement as
  a quota of 0, which tells the member they have reached their site
  limit. That is #231; the return is pinned in .golangci.yml by its
  source line until #231 removes it.
- paymentMethodID in the invoice reconcile returned no id when the
  mapped payment method id did not parse. The column is UUID NOT NULL,
  so that cannot happen with the data the schema allows; the function
  now returns the error, so a row that breaks the rule is reported
  instead of read as unlinked. Not probed: no test can store a value
  the column refuses.
- The domain claim's connect probe answers a failed lookup of the
  operator's own target as ProbeStateError, the state that says the
  record could not be checked. That is the probe's answer, so the
  return is exempt with that reason.

With the pins, golangci-lint reports none. 2 breaks, each caught by
scripts/golangci-lint.sh: a read error returned as nil in the lint's
readTree, and the payment method id's parse error returned as nil.
The rule's page names nilerr as its check and says what it leaves to
review: a failed read turned into an empty list or a zero by a function
that returns a value. docs/testing.md lists nilerr among the linters.
make lint passes; the Stripe workflows and lint packages pass with the
database tests skipped (the test stack was down).
2026-10-15 22:11:26 -05:00
cgalo5758 061493920a Report a file the project lint cannot read instead of passing it
Six of the project lint's walkers (the page-template scan, the anatomy
rules, the Go copy pass, retired resource keys, raw error rendering
and walkthrough coverage) skipped a directory they could not list and
a file they could not read, and discarded the walk's result, so a file
the lint never read passed it. They now share readTree, which returns
the files a rule keeps and reports a walk error as an unreadable-file
violation naming the path. A directory that does not exist still holds
nothing to read, as before; walkthrough coverage keeps its own check
for a missing directory. pageBodyTemplates and eachGoString return the
violations beside their results.

TestReadTreeReportsWhatItCannotRead locks a file, locks a directory,
walks a directory that does not exist, and runs ruleRawErrorRender
over a locked file; it skips when run as root, which reads everything.
5 breaks, each caught (new side only; the test is new): a read error
returned as nil, a listing error skipped, the missing-directory check
removed, the violation filed under the walked directory instead of the
path, and the raw-error rule dropping readTree's violations.

On the working tree the project lint reports no unreadable file, the
lint package's tests (the verdict golden included) pass unchanged, and
make lint passes. docs/testing.md lists the new rule id.
2026-10-15 22:11:15 -05:00
cgalo5758 4dad0c48c7 Write down where a rule and an act live, output order, and how to prove a test
The code-organization rules the recent refactor worked to were written
in no tracked doc. docs/go-conventions.md gains them:
- "Where a rule and an act live": one function for a rule a page shows
  and an endpoint enforces (plans.Offer); one statement for a rule SQL
  and Go both need (core.superseded_ends); a many-row act in the
  package that owns its tables (entitlements.RemoveTier, ReorderTiers,
  PlanActError); a handler that parses, calls, maps and renders; a read
  page that loads, derives, builds and renders with one name resolver
  per request; and a failed read is never an answer. The handlers that
  still open a transaction or run SQL are #221, and the reads that
  still turn a failure into a value are #207 and #197.
- "Output order": a list query ends on a unique key (#225 lists the
  ones that do not), a loop that writes rows walks a stated order
  (#218), and Go output from a map ranges over sorted keys.
- No call to Stripe while a transaction is open. The transactions
  section now says new code uses the helpers and points to #186 for
  the BeginTx calls that remain.
docs/operator-ux-conventions.md's failed-collection rule links the
general one.

docs/testing.md:
- Characterize before a refactor, and what a refactor commit may
  change.
- The deliberate-break rule covers new tests and tests whose names or
  comments state a value, names all six outcomes the break tool
  reports, and says how to read a MISSED break.
- Tests of locks and of order are deterministic; a flaky test is a
  suspected defect; a database test passes twice without a reset and
  filters to its own rows.
- Golden files: defined order, rows of the test's own fixture, an
  expected change edited in by hand before the run, `-update` one
  package at a time.
- A helper joins the test kit with its first caller and a test.

CONTRIBUTING.md: the Go and test conventions point to their pages;
docs move with the code; a written rule names its check; a script
that resets or deletes data refuses unknown arguments; "Before you
push" runs `make test`, since without the stack the database and
Stripe tests skip; a commit message reads from the repository alone;
new source files carry the SPDX header; and a small fix rides along
only when listed, while a second sighting, a new rule or lock, or two
statements of one rule get their own issue.

docs/README.md: one page per rule, and the Go conventions entry lists
what the page now covers.
2026-10-15 20:56:59 -05:00
cgalo5758 925ed57ec6 Describe the test kit by what it covers, and point to go doc
docs/testing.md named world's builders one by one, and 21 of its 54
methods were missing from the list; dbtest, htmltest and the Stripe
mock had drifted the same way. The fixtures paragraph now describes
the builders by what they cover, and "The test kit" says that the page
names the helpers a new test reaches for first while
`go doc -all ./internal/testkit/<package>` (and ./internal/stripetest)
lists every exported name with its comment, so the page no longer has
to grow with each helper.
2026-10-15 20:44:21 -05:00
cgalo5758 6c8419ea1a Refuse r.FormValue in golangci-lint
docs/operator-ux-conventions.md bans r.FormValue, and nothing checked
the ban: nine calls on eight lines in five files still use it, the
calls #205 lists. forbidigo now refuses (*http.Request).FormValue, and
each of the eight lines is pinned by its source text in the exclusions
of .golangci.yml, commented with #205. A pin stops matching when its
line changes, and scripts/golangci-lint.sh fails on an exclusion that
matches nothing, so each pin leaves with the change that removes its
call.

Without the pins golangci-lint reports nine forbidigo findings, at
exactly those calls; with them it reports none.

The doc said r.FormValue lets a URL parameter shadow a submitted
field. The posted value wins, and the URL's value is used only when
the body leaves the field out. The sentence now says so and names the
check.
2026-10-15 20:42:27 -05:00
cgalo5758 e2f3b8aa44 Correct the docs that no longer match the code
docs/database-locks.md said recordObligationFailure records a failed
drain in a transaction that takes the rendezvous alone. It runs
MarkObligationFailed, which calls core.settle_obligation, and that
function locks the obligation row and then the pool row, the reverse
of the drain's order. The drain section now says so, and "Where the
paths differ" lists it with #196; the two entries above it gain #193
and #194.

docs/database-management.md described three schemas and three
migration sources. There are five of each: domains and discourse were
missing, with their ledgers and their place in the source order. Its
sqlc example ran `sqlc generate` in one directory, which leaves the
generated files without the SPDX headers `make sqlc-generate` restores;
the example now uses the make target and says why.

docs/testing.md:
- One list of the project lint's rules, by rule id, under the static
  checks; the "Lint and screens" entry, which repeated part of it,
  points there. The TL;DR's `make lint` comment no longer lists four
  of its five checks.
- The browser-test waits are described once, here; the copy in
  docs/go-conventions.md, which disagreed on which rod waits exist,
  is gone. rod's MustWait with a predicate, used by two walkthroughs,
  is named as allowed.
- MockBackend's description names the fields tests use and the three
  ways it fails a call, in place of three of its twenty fields.
- An example test that no longer exists is replaced by one that does,
  and a sentence fused with the next one in the screens entry is split.

test/AGENTS.md: the suite takes a few minutes, not 26 seconds; the
explicit-slug example runs from test/ like the rest of the page and
states the slug's rule; the stack scripts' refusal of unknown
arguments is described; and a link into one contributor's assistant
memory is removed.

The owner of a pinned lint exception or a test-only allowlist entry
is an issue; the comments in .golangci.yml, scripts/deadcode-testonly.txt
and docs/testing.md no longer offer a #166 step, since that issue is
closed and no entry names one.

golangci-lint, scripts/deadcode.sh and scripts/notebook-citations.sh
pass.
2026-10-15 20:38:17 -05:00
cgalo5758 96ba5067a6 Simplify AGENTS.md by removing deprecated context-mode rules and
updating checks section language
2026-10-15 20:25:36 -05:00
cgalo5758 235ee3c438 Resolve a sign-in in provisioning.ResolveSignIn
CallbackHandler (gocognit 30) held the whole resolution of who a
verified sign-in is: the subject lookup, the first sign-in, and the
returning sign-in with its login stamp, person resync, and organization
and workspace binding. That code moves to internal/provisioning/signin.go
as ResolveSignIn, firstSignIn, returningSignIn, resyncPerson and
sessionWorkspace, and returns a SignedIn (user, person, organization
and workspace ids). The handler calls it once and answers a failure
with "Sign-in failed", 500. Measured scores (golangci-lint's gocognit):
CallbackHandler 11 (was 30), ResolveSignIn 1, firstSignIn 1,
returningSignIn 3, resyncPerson 4, sessionWorkspace 4.

The resolution's log lines move with it onto logging.FromContext(ctx),
the logger the handler used, with the same text, level, attributes and
order. A failure is logged where it happens and returns the unexported
sentinel errSignIn ("sign-in could not be resolved"), which no caller
prints: the handler renders the failure page. provisioning gains one
import, internal/logging.

The handler's second parse of the ID token for the nonce claim, its log
line ("could not parse the ID token's nonce claim") and the struct tag
json:"nonce" are deleted. The comparison reads idToken.Nonce, which
the verification has already read from the same verified payload as a
string; decoding that payload into a struct of one string field cannot
fail, so no input reached the removed failure branch.

The session-binding order (first organization, first workspace, each by
name) is unchanged and now sits in sessionWorkspace alone. The two READMEs
and the model document describe the resolution where it now lives.
TestReturningLoginWritesNoDesiredStateNotice calls ResolveSignIn with
changed claims in place of its copy of the callback's two writes. Its
assertion that a returning login writes no outbox notice for the person,
organization or pool is WIDER: the writes are now ResolveSignIn's own,
the same UpdateUserLogin and UpdatePerson plus its reads, instead of a
copy. Because ResolveSignIn only logs a failed stamp or resync, the test
reads back last_login_ip, display_name and primary_email, which keeps the
two checks that the old test made by failing on a write error (same).

How no output changed:
- go test over ./internal/auth ./internal/provisioning ./internal/server,
  run at the parent and here after resetting the test databases, gives
  1676 test and subtest names with identical results, none failing. The
  sign-in callback goldens and exact log lists are unchanged.
- Literals of internal/auth/auth.go and internal/provisioning's
  provisioning.go, workspace.go and (here) signin.go, summed per file:
  the log message "could not parse the ID token's nonce claim" and the
  tag `json:"nonce"` are gone, "sign-in could not be resolved" is added,
  the other messages are present once each as before, and fmt.Errorf
  formats are 21 at both. Repeat counts of "error", "email", "person_id",
  "org_id" and import paths change where lines moved.
- 44 breaks (68 records, 48 of them one-side) are CAUGHT by the old tests
  at the parent and by the same tests here.
- The operator walkthroughs (./test/e2e/operator-walkthroughs) run on the
  demo state at the parent and here: the same names, results and skip
  messages, 16 passing and 6 skipping.

Unprobed, with the reason:
- Deleting the handler's SetDeadline line is an equivalent edit: the
  session manager's RenewToken, called just before it, sets the same
  deadline, so no break can fail on it. The tests pin the full lifetime
  itself.
- The error half of the organization and workspace guards (err != nil
  beside the empty-list test): neither the generated queries nor the
  test fakes return rows together with an error, so dropping it changes
  no row.
- A first sign-in that succeeds is exercised only against the database.
- RenewToken failing in a real store: only the test wrapper makes it fail.
- A given_name or family_name of another JSON type, which also refuses
  the sign-in: pinned for email_verified only.
- Two first sign-ins of one subject at once: no test runs two callbacks
  concurrently.
- That a returning login writes no outbox row is not probed by a
  one-line edit of signin.go, because the triggers that write those rows
  watch other tables.
- Which attributes each log line carries, and the session's name and
  username keys, beyond the ones the breaks probe: the golden holds
  them all.
- The failure page's texts: the rows share two refusal values, whose
  texts the existing failure-page tests pin.
2026-10-11 19:40:39 -05:00
cgalo5758 9f103c13ef Record a first lifecycle request under one lock and one read
RecordFirstLifecycleRequest took the instance lock and read the latest
generation, then called RecordLifecycleRequest, which took the lock and
read the latest generation again. It now checks its address, locks and
reads once, refuses a later generation of an instance that already
holds a request, reads the content, and passes the latest generation to
decide, the per-instance decision RecordLifecycleRequest also calls
(recordNew above the latest, answerAddress otherwise). decide takes a
*Queries and opens nothing. The person is still parsed after the
first-request check, so a malformed person on a used instance under a
later generation still answers a conflict.

A new create, an identical repeat and a create above the ceiling now run
two statements fewer: the second LockLifecycleInstance and
LatestLifecycleGeneration. The three rows of the lifecycle answers
golden ("first: a new request", "first: an identical repeat", "first:
above the ceiling on an empty instance") lose that pair; they were
edited by hand before the run and the test passed without -update, so
every other row is byte-equal. Answers, rows, errors and writes do not
change unless an instance's latest generation moves between the
parent's two reads. Inserts happen only under the instance lock, and
status and acknowledgement updates leave the generation alone, so the
one writer that can move it is DeleteUnstartedLifecycleRequest, which
takes no instance lock. If it commits between the parent's two reads, a
double submit's repeat is recorded as a new create there and answered a
conflict here, from the one read. That ordering needs a commit between
two statements of one transaction and is not probed.

docs/database-locks.md, "Lifecycle intake", said the second lock was
harmless; it now describes the single lock and read, and says the delete
of an unstarted request runs outside the instance lock. The comment
"taking it again ... is harmless" is deleted with the second lock.

How it was shown to change no output: the packages
internal/integration and internal/integrations/fedwiki/... gave the same
302 test and subtest names with the same results at the parent and here
(301 pass, 1 skip, the replay recorder, skipped on both sides); the
Temporal extraction (activities, workflows, workflow bodies, payload
types) is byte-equal; FedWiki's recorded histories replay; the sites
walkthrough, which creates a site with a double submit, passes on both
sides; the project lint prints the same on both sides; the multiset of
lifecycle.go's string literals is equal (20 formats, no literal added or
removed); the lock-key query, the single address read in heldAt, the
unchanged sameLifecycleContent and the two spec and answer types are
unchanged. 9 break records, 8 per side (one break's code moved into
decide, so it has an old and a new record), each caught by the old test
at the parent and the new test here. Cognitive
complexity (pinned golangci-lint): RecordLifecycleRequest 8 to 6,
RecordFirstLifecycleRequest 7 to 9, decide 1.

Unprobed: the race with an unstarted request's delete, for the reason
above; every database error after the first statement, because no test
makes one fail and nothing else; the *sql.Conn path, which inOwnTx
treats as a transaction as before and no caller uses.
2026-10-11 18:58:35 -05:00
cgalo5758 208e10dd9d Replay recorded claim verification and push histories through one replay kit
Add internal/testkit/replay: Run and File replay a package's recorded
Temporal histories against the workflows it registers, and Record writes
them with stand-in activities. TEMPORAL_REPLAY_RECORD names a temporal CLI
binary (the recorder starts a private in-memory dev server from it) or a
running server's host:port; TEMPORAL_REPLAY_ONLY, a file-name prefix,
records the files it names again. FedWiki's replay test moves onto the kit
with its fourteen histories and every subtest name unchanged. Its two
variables FEDWIKI_REPLAY_RECORD and FEDWIKI_REPLAY_ONLY are replaced by
the two above, and its recorder skip message now reads
"TEMPORAL_REPLAY_RECORD not set".

The recorder now writes only the histories that do not exist yet, where
FedWiki's used to rewrite every file; a recorded history stands for runs a
release started, so replacing one takes TEMPORAL_REPLAY_ONLY.

Claim verification gets nine recorded histories (activates, polls,
check-now, expires, probe-error, mark-refused, record-fails, canceled,
rollover) and the push workflow seven (applied, rose, divergent,
conflict-then-applied, permanent, unlinked, gone), each replayed by
TestWorkflowHistoriesReplay in internal/workflows/domains and
internal/workflows/desiredstate. The kit has its own test over a two-step
workflow and a recorded history of it. The 17 histories were recorded at
the parent's code, from a private dev server started from the temporal CLI
(1.5.1); the commands they hold match an earlier independent recording
of the same scenarios, apart from the two clamped timers' durations. No
file names the recording machine or its user.

New tests: the kit's TestTarget, TestRecordsNeverReplaceAHistoryUnasked,
TestRunReplaysARecordedHistory (and its two-steps.json subtest),
TestFileRefusesAChangedWorkflow, TestEndingNamesWhatTheRunDid and
TestRecordTwoSteps (skips); the import guard's subtest for
internal/testkit/replay; TestWorkflowHistoriesReplay with its 9 and 7
subtests and TestRecordReplayHistories (skips) in the two workflow
packages. The kit is listed in docs/testing.md and in the test-only
deadcode list.

No output changes: no production file is edited. At the parent and at this
commit, the names and results of the section's packages are equal apart
from the tests above (FedWiki's 16 results pass or skip as before), the
Temporal surface listings (activities, workflows, workflow bodies, payload
types) are byte-equal, go run . lint prints the same line, the lint
snapshot has no added or removed key, and deadcode and golangci-lint are
clean.

Breaks: 9 breaks, each caught by the new tests; the 2 FedWiki breaks
(a request-driven create and a create started before lifecycle requests
that check the quota first) are caught by the old and the new test.

Not probed: Histories and Run failing a test (no test can watch its own
test fail; every production break above shows Run failing a subtest);
connect, runScenario and write against a server, which run only when
recording (the recording at the parent is their check). A replay cannot
see activity inputs, timer durations, the result, or the ending of a
history that ends canceled or failed; later tests pin those.
2026-10-11 11:50:42 -05:00
cgalo5758 0abbfac7ae Reorder a ladder's tiers through entitlements.ReorderTiers
The reorder's renumbering and the enactment of a new rank 0 on the
organizations lived in the HTTP handler. They are now
entitlements.ReorderTiers, which takes a ReorderTiersInput: the ladder, the
order, and, when the rank 0 changed on a ladder some organization type
defaults to, an Enact value naming the types, the disposition, the operator
and the readers it needs.

ReorderTiers runs the same steps in the same order: it opens BeginRuleChange
(the exclusive rendezvous, no pool lock), renumbers the ladder and commits.
With Enact set it then reads the ladder's name for the reason (the id stands
in when the read fails), composes "plan-ladder tier reorder (<ladder>)", and
for each type lists its organizations and enacts each through EnactDefault
against the new rank 0. A failed listing returns the result so far and a
PlanActError that now also carries the organization type.

ReorderPlanLadderTiers keeps the session, the form parse, the full preview,
the stale check and the disposition gate. tierReorderAnswer returns the
stale sentence, the rank-0 sentence, the toast naming the types' display
names and the failed organizations' answer; tierReorderFailure returns and
logs the sentences for a failed begin, renumbering, commit and listing. The
failures of each type's organizations are logged after the enactment instead
of after each type; the lines and their order are the same.

One departure, as for the other moved tier acts: a failed renumbering ("This
page was out of date; showing the current tier order. Try again.") and the
other failures before the commit are now answered after the act's rollback,
which releases the rendezvous before the page is read, where the handler
rendered them before its deferred rollback. Only reads move: no write, no
sentence and no lock changes, and the lock is held for less time. Under
concurrency a request queued on the rendezvous can now commit before the
page is read. The stale-candidate case is unchanged: the enactment still
classifies against the rank 0 the reorder saved.

Docs correction that rides along: the reorder entry in docs/database-locks.md
names entitlements.ReorderTiers.

ReorderPlanLadderTiers had a cognitive complexity of 30, the limit, with no
exception to delete. Measured: 14 at this commit; ReorderTiers 4,
enactReorder 6 (the largest new helper), tierReorderAnswer 7,
tierReorderFailure 2.

No output changes. 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. No test, golden or
testdata file is edited and none ran with -update, so every golden is
byte-equal. The seeded operator walkthroughs (TestPlanLaddersWalkthrough and
TestOrgTypesWalkthrough among them) give equal names and results on both
sides, and the 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. Every string literal of the
old operator_plan_ladders.go and operator_org_types.go is still found in a
non-test file of internal/server or internal/entitlements.

15 breaks (24 records), each caught by the old code and by the new code
where its text exists on both sides: a stale submission says so, a new rank
0 on no type's default says so, the reorder's reason names the ladder,
organizations are enacted against the new rank 0, a failed organization's
answer, a new rank 0 on a default ladder is enacted, the gate counts the
organizations, a failed begin is logged, a failed listing after the reorder,
the toast names the types by their display names, the reorder renumbers the
ladder, the reorder takes the exclusive rendezvous, a reorder in contention
fails generically, a failed read of the types reads as no type's default,
and a cosmetic reorder says the order was updated. None was dropped.

Not probed: faults inside the reorder's transaction (the renumbering and the
commit; no querier makes them fail), so the page-out-of-date sentence and
its log line are read but not seen, and the move of its answer after the
rollback is named, not shown; the label's fallback to the ladder id when the
ladder read fails, which no querier refuses; a listing failure on the second
of two types, since the listing fault refuses every listing and the first
type fails; and the browser end-to-end plan-management tests, which were not
run here.
2026-10-11 04:18:40 -05:00
cgalo5758 19ca28745f Change an organization type's default through entitlements.ChangeOrgTypeDefault and EnactDefault
The org-type default change and each organization's enactment lived in the
HTTP handlers. They are now entitlements.ChangeOrgTypeDefault and
entitlements.EnactDefault, which the reorder handler also calls for each
organization.

ChangeOrgTypeDefault runs the same steps in the same order: it saves the
default (autocommit, outside any lock beyond the row update), composes the
actor with the reason "org-type default change (<type>)", lists the type's
organizations, and enacts each one. EnactDefault is the old
enactOrgDisposition: it finds the default pool, opens BeginMaterializing,
locks the pool, reads and classifies its positions, and then initiates the
default, counts the pool, or applies the disposition (grandfather through
ConferGrantTx per incumbent, migrate through core.end_conferral,
ReapplyDefaultsIfVacant and the materialization), each organization in its
own transaction and a failure leaving the others alone. The statements, the
locks, the rendezvous kind and the legacy grant's description and transition
reason are unchanged. Failures come back as EnactFailure values naming the
step (eight new steps, from saving the default to the grandfather
conferral's outcome); internal/entitlements now imports
internal/organization, which imports no console package.

CommitOrgTypeDefaultChange keeps the reads, the reserved-type check, the form
parse, the candidate's resolution and the gate. renderDefaultChangeFailure
gives the two sentences for a failed save and a failed listing,
enactFailureReason gives each organization's reason with the driver's text
unchanged, orgTypeChangeResult converts the counts for the summary, and
logEnactFailures writes the line each failing step wrote before: a warning
for a missing pool, errors for the initiation, the grandfather and the
migrate's end and restore. The reorder handler passes the new rank 0 as the
candidate and logs each type's failures after that type's organizations, as
the lines came before. No answer of the default change moves after a
rollback: each organization's transaction ended inside the enactment before
the card rendered, and still does.

Docs: docs/database-locks.md renames enactOrgDisposition to EnactDefault in
the pool-row line and the reorder entry, names the default change by
entitlements.ChangeOrgTypeDefault and EnactDefault, and gains a correction
that rides along: the migrate branch's list now includes
ReapplyDefaultsIfVacant, which the migration runs before it materializes.
The two model docs point at internal/entitlements/default_change.go, and
the call-sites list in the package comment of internal/entitlements now names
that file for the default change and the reorder's enactment, in place of the
handler.

The gocognit exception for enactOrgDisposition (pinned at 38) is deleted
from .golangci.yml, with the step heading that only it carried. Measured:
CommitOrgTypeDefaultChange 18 at the parent and 16 at this commit;
enactOutgoingDefault, the largest new helper, 9; enactDefault 5,
grandfatherIncumbents 5, initiateDefault 4, ChangeOrgTypeDefault 3,
EnactDefault 1.

No output changes. 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. No test, golden or testdata
file is edited and no test ran with -update, so every golden is byte-equal,
the two pool-queue tests among them. The seeded operator walkthroughs, which
drive the default change through TestOrgTypesWalkthrough, give equal names
and results on both sides, and the 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 except the
masked operator-integration-stripe shots. Three stored texts leave the
handler file: ": grandfathered as legacy", "Grandfathered on org-type default
change: " and "org-type default change (%s)" are each found in
internal/entitlements; every other literal of the plan-ladders and org-types
handler files is still found in a non-test file of internal/server.

21 breaks (38 records), each caught by the old code and by the new code
where its text exists on both sides: a grandfathered and an initiated pool
are counted, the legacy grant's description and transition reason, a failed
restoration's reason, a failed organization turns the toast into an error,
an organization with no default pool fails and is logged as a warning, the
grandfather conferral must supersede, a failed begin fails the organization,
a failed initiation's reason and its log line with the pool, a failed save's
sentence, a failed listing after the save, the reason names the type, the new
default is saved, a migration to None materializes the pool, each
organization's enactment waits on its pool, an organization in contention
says a change is in progress, organizations are enacted against the new
rank 0, and grandfather reissues the incumbents. None was dropped.

Not probed: faults inside each organization's transaction (the position
read, the endings, the materialization and the commit; no querier makes
them fail), so their reasons and log lines are read but not seen; the
undecided refusal, which needs an outgoing-default position that appears
between the gate and the enactment; the enactment order among
organizations with the same name; and the browser end-to-end plan-management
tests, which were not run here.
2026-10-11 04:18:40 -05:00