Commit Graph
47 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 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 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 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 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 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 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 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 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 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
cgalo5758 d9f73ea45c Remove a tier with holders through entitlements.RemoveTier
The tier removal's transaction, its pool locks and its per-pool
reconciliation lived in the HTTP handler. They are now
entitlements.RemoveTier, which takes the removal's ladder, product, the two
names its reason records, the disposition and the operator, and returns the
positions it ended and the organizations it migrated. The same
transaction opened with BeginRuleChange, the same statements in the same
order: read the holders, lock their pools in ascending order, read them
again, the gate on default-sourced holders without a disposition, the tier
delete, the list of the remaining tiers and the renumbering, then for each
pool the incumbents' ending, the alignments, the restoration as the
operator and the materialization, and the commit. Each failure comes back as
a PlanActError naming the step and, where there is one, the pool or the
provision; a refused gate comes back as a DispositionRequiredError.

CommitPlanLadderTierRemoval keeps the session, the form parse and the reads
of the two names, calls RemoveTier, and answers: the toast on success,
refuseUndecidedTierRemoval (today's preview, field error and 422) for the
gate, and tierRemovalFailure for any other error. tierRemovalFailure is one
switch on the step that writes the log line each step wrote and returns the
sentence it returned; the pool locks, the second read of the holders and
the list of the remaining tiers log nothing, as before.

The one departure from today: every failure or refusal answer the removal
gives from inside its transaction is now rendered after the act's rollback,
where the handler used to render it before its deferred rollback. Only reads
move: no write, Stripe call or sentence changes, and the rendezvous and the
holders' pool locks are held for less time. Under concurrency, a request
queued on those locks can commit before the page is read, so the gate's
preview can list a different set of holders than the count in its field
error.

The renumbering no longer guards on a non-empty list of remaining tiers;
RenumberTiers over no tiers runs no statement.

The gocognit exception for CommitPlanLadderTierRemoval (57) is deleted.
Measured scores: CommitPlanLadderTierRemoval 11, RemoveTier 11, and the
largest helper, removeFromPool, 13 (lockTierHolders 10, deleteTier 4); in the
handler file, tierRemovalFailure 2 and refuseUndecidedTierRemoval 1.

Docs: the handler names in the pool-row lock table and in the tier-removal
paragraph of docs/database-locks.md become RemoveTier. Riding along as
corrections: docs/models/plan-ladders-transitions.md drops the structural
validation page from the operator ladder management row and the health
strip from the plan topology row, and invariant 14 no longer says they
report drift; both were retired and the code no longer has them. The
ladder management row also names internal/entitlements/ladder_acts.go.

No output changes beyond that departure. Names and results of
./internal/server, ./internal/entitlements and ./internal/testkit/... are
equal at the parent and at this commit after a database reset on each
side: 1779 results on each side, 1778 passing and the one existing skip. No
test, golden or testdata file is edited and no test ran with -update, so
every plan-act golden is byte-identical, the removal files included. Screens
of operator-plan-ladders, plan-ladder-detail (and its open and invalid
states) and org-types, desktop and mobile, are byte-identical at the parent
and at this commit; only the masked operator-integration-stripe shots
differ. The string literal "tier removal (%s from %s)" leaves the handler
file and is found in internal/entitlements; every other literal of the
plan-ladders and org-types handlers is still found in a non-test file of
internal/server.

20 breaks, each caught by the old and the new code where its text exists on
both sides (36 records, the removal's toast, the product-name fallback, the
contention sentence and the did-not-commit sentence as one record each
because their text did not move): the gate counts the default-sourced
holders, keep aligns a default-sourced position, migrate counts the
organizations, the removal counts the positions it ended, the reason names
the product and the ladder, the operator restores the default, the toast,
the pools reconcile in ascending order, a migrated pool held elsewhere is
materialized, a failed restoration's sentence and its log line with the
pool, a failed begin is logged, a removal that did not commit says nothing
was changed, the gate's refusal counts the holders, the ladder is renumbered,
other-source positions align, the removal locks its holders' pools, an
unread product name falls back to its id, a removal in contention says a
change is in progress, and the removal takes the exclusive rendezvous.
None was dropped.

Not probed: faults inside the transaction (no querier makes one fail), so
none of the failure answers that now render after the rollback is seen
moving, nor the restoration failures beyond the two the goldens pin; the
removal files record no reads, so they cannot show the move. The second
read of the holders under the pool locks: since the removal holds the
exclusive rendezvous from its first statement no console transaction can
change a holder between the two reads, and no test tells them apart. The
dropped guard on remaining tiers. A failed read of the preview at the gate,
which answers "Failed to remove the tier; nothing was changed." because no
querier refuses the preview's reads.
2026-10-11 04:18:40 -05:00
cgalo5758 13a9d50417 Stop the invoice reconcile adopting earlier payments
The invoice reconcile no longer adopts payments the webhook handlers
recorded from event payloads before the invoice read-back existed. Until
now a settled charge with no mapping first looked for a payment of the
same invoice and outcome that was unmapped, or mapped by its payment
intent alone, and gave the charge to the oldest such payment. Now every
settled charge with no mapping is recorded as a payment of its own,
mapped to the charge. An earlier payment with no charge mapping keeps
its times and its mapping, and the charge's new payment sits beside it,
so a member's and an operator's payment list show that attempt twice.

This is accepted because the handlers have recorded payments by charge
since the read-back, so only a database upgraded from before it can hold
such a payment. The console has no check that looks for them: a database
that still holds some is corrected by hand.

Also deleted: the two queries only adoption used,
ListUnmappedPaymentsByInvoice and SetPaymentMappingCharge (sqlc-unused
counts 450 queries, 452 before, none unused), the adoption outcome and
its count, and the payments_adopted attribute of the log line "stripe:
invoice reconciled". Three database error names go with it ("list
payments to adopt", "adopt payment mapping", "map adopted payment");
every other error format and logger message is equal to the parent's.
The payment-intent index stays, and no migration is added.

Behaviour changed on purpose, and only here. The row "earlier payments
adopted" of the invoice payments characterization becomes "earlier
payments kept beside the charges" with the same fixture: its golden file
is replaced and now shows the two earlier payments kept (one mapped by
its intent alone, one unmapped) and a new payment written for each
charge. The row "adoption skips the other outcome" is deleted with its
golden, since the outcome match it pinned is gone. The line
ListUnmappedPaymentsByInvoice leaves two-declines-then-a-success (3
lines), two-charges-in-the-same-second (2), a-charge-without-a-currency
(1) and the sweep's every-kind-of-candidate (1); nothing else in them
moves. TestInvoiceAdoptsEarlierPayments and
TestInvoiceAdoptsDeclinesInOrder are deleted.
seedPayloadProjectedInvoice writes no payment, so
TestInvoiceSweepRepairsWithoutRenumbering starts from an invoice with
none and finds the one payment its charge records. The test kit's
payment mapping now carries a charge id beside the intent, the shape the
reconcile writes; the earlier-payments row builds its intent-only
mapping with a helper that says so. docs/models/payments-billing.md says
what a payment without a Stripe charge is, and docs/stripe.md's upgrade
notes say a payment recorded from an event payload is not matched to a
charge. The OpenSpec change remove-payment-adoption restates the six
requirements of the payment projection spec that promised adoption and
is archived here; openspec validate --specs --strict passes.

How no other output changed: the goldens were rewritten with -update and
read line by line against the code (four files lose lines, one is replaced
by the renamed file and one is deleted; no other golden changed), then the
tests ran again without -update after a database reset and changed
nothing. At the parent, with this commit's tests, goldens and kit copied
over, TestInvoicePaymentConvergence fails in
earlier_payments_kept_beside_the_charges, two_declines_then_a_success,
two_charges_in_the_same_second and a_charge_without_a_currency, and
TestInvoiceSweepRun fails in every_kind_of_candidate, each on the
ListUnmappedPaymentsByInvoice lines or the adopted rows; everything else
passes. Over ./internal/integrations/stripe/..., ./internal/testkit/...
and ./internal/server the names and results at the parent and at the
commit are equal apart from the three deleted tests and subtest and the
renamed one (1793 passing names before, 1790 after, none failing on
either side).

Assertion map. The adoption tests' counts, charge, method, livemode and
kept created_at on adopted rows are adoption's and go with it. The
invoice moving to paid with its number kept and Stripe's issue time over
the backfill stay pinned by TestInvoiceSweepRepairsWithoutRenumbering.
Two rows are NARROWER: the order in which two earlier rows are adopted
is gone (the order of written inserts stays pinned by two declines then
a success and two charges in the same second), and the sweep repair test
no longer asserts a kept created_at on its payment, since the seeded
payment is gone (the invoice's kept created_at is still asserted).

3 breaks on the new side, each caught: a charge handed to an earlier
unmapped payment (the golden row), the same with no search recorded
(the sweep's statement trace), and an intent-only mapping gaining the
charge (the golden row).

Scores, measured with gocognit: convergePayments 11 (11 at the parent),
convergeCharge 2 (4); adoptEarlierPayment (14) is gone. No gocognit
exception is deleted or added.

Unprobed:
- The log line's attribute. No test reads the reconcile's log line.
- The kit's charge id. No test reads the charge of a mapping the kit
  writes; the five server tests that use it read the intent, the sync
  status and the environment.
- An out-of-band payment followed by a succeeded charge. Adoption gave
  such a charge the unmapped payment written for an invoice paid
  without a charge; now a second succeeded payment is written beside
  it. Stripe does not report a succeeded charge on the payment intent
  of an invoice it reported paid without one, so no fixture builds it;
  read from the code.
- Other old-version states: an invoice whose failed read stamped its
  read time with earlier payments unmatched, and a never-read mapping
  of the other Stripe environment. No fixture builds them; a charge
  read for any of them takes the insert the new golden row shows.
- Screens, walkthroughs and test/e2e: no template or handler changes
  and none reaches the reconcile; not run.
2026-10-11 02:33:10 -05:00
cgalo5758 750b5db13d Move the Stripe catalog sync's act into the Stripe tree
SyncProductToStripe held the whole sync: the price read, the
dead-letter probe and reset, the product lock, the re-reads under the
lock, the pending mappings and the outbox enqueues, in one 46-point body
that a lint exception pinned. The act now lives in the new package
internal/integrations/stripe/catalogsync, which cannot sit in
internal/integrations/stripe because that package imports
internal/server. Sync returns an outcome and a closer; FailedSync is the
dead-letter probe, now also called by the product readiness panel.

The handler keeps the configured check, the product and price reads, the
price choice (syncPrice, the same rule as the loop it replaces), and
every sentence and log text, now in two tables keyed by outcome and by
stage. The transaction is ended by the handler after the page is
rendered, as before: Sync returns with its transaction open on the paths
where the handler rendered before its deferred rollback (a failed lock,
mapping write or enqueue, a re-check under the lock that finds the price
synced or in flight, the product's create in flight), and the handler
defers the closer on the next line. Only an enqueue commits. So the
product lock is held until the page is read, the rollback sits where it
did, and no golden changes.

The gocognit exception for SyncProductToStripe (pinned at 46) is
deleted. Measured scores: SyncProductToStripe 6 (was 46), enqueueLocked
19 (the largest new function), Sync 7, enqueue 6, syncPrice 4,
renderSyncFailure 2, FailedSync 1.

Six string literals leave operator_billing.go and move to catalogsync.go
with the same number of occurrences as at the parent:
"create_stripe_product", "unit_amount", the product lock SELECT
pg_advisory_xact_lock(hashtext($1)::bigint), the dead-letter probe and
the outbox reset once each, and "create_stripe_price" twice. The two SQL
texts are copied byte for byte, indentation included. Every other
literal of operator_billing.go at the parent is found in a non-test file
of internal/server at this commit.

Docs correction riding along: docs/database-locks.md named
SyncProductToStripe as the holder of the product-sync lock and said
nothing of when the lock ends; the table row and the section now name
catalogsync.Sync and say that a sync that commits nothing is rolled back
by SyncProductToStripe after it has rendered its answer, so the lock is
held until then. docs/building-an-integration.md and
docs/models/provider-integration.md said the catalog sync stays
core-side; they now say the act lives in the Stripe tree and the
operator's action stays. A test comment naming the moved probe is
renamed.

No output changes. Names and results of ./internal/server,
./internal/integrations/stripe/... and ./internal/testkit/... are equal
at the parent and at this commit after a database reset on each side:
1793 results, all passing. Every operator_sync_stripe golden is
unchanged, product_create_in_flight included, and no test ran with
-update. TestProductsWalkthrough passes on both sides with equal names,
results and skip messages; TestPlanLaddersWalkthrough failed once at this
commit with its known toast wait ("still false after 20s") and passed
three of three alone right after. The operator-products and
operator-product-detail screens are byte-identical at the parent and at
this commit; the only differing shots are the masked
operator-integration-stripe ones. The lint snapshot differs from the
parent's by no added finding and no removed one.

27 breaks, each caught by the old and the new test: an unconfigured
deployment refusing, the default price syncing when none is requested, a
price of another product refused, a synced price answering before the
lock and again under it, a dead-lettered create retried and set back to
pending, a pending price answering before the lock and again under it,
the sync waiting for the product lock, a product create in flight
holding back another price, both mappings written pending, a reachable
product enqueuing the price only, the price and product payloads
carrying their fields, a transaction that cannot begin, the six answers,
the readiness panel reading and naming a dead letter, an unreachable
product created again, and the no-op under the lock rolling back after
the page. Two records were reworded on one side each, because the first
wording left a variable or an import unused and failed to compile
instead of failing the test.

Not probed, as when the sync action was first characterized (all moved
unchanged):
- "Failed to load the product.", which renders only when the page's own
  read of the product succeeds after the handler's read failed;
- a failed read of the price mapping before the lock, which reads as no
  mapping and is not told apart (that read now goes through the
  database handle rather than the Stripe query handle, which are one
  handle in production and in every test that drives this action);
- the stale and other-environment mappings, which only
  TestSyncProductToStripeCreatesAgainWhenUnreachable pins;
- the rollback after the page on a failure after the transaction began
  (a failed lock, mapping write, enqueue or commit), because no row
  reaches one, so the place of the rollback is shown on the no-op path
  only;
- the log lines, whose texts now sit in the stage table.
2026-10-11 01:59:21 -05:00
cgalo5758 fc97a2b44d Commit a rule-change batch through named steps on one transaction
RuleChangeBatchCommit keeps the request id, the transaction, the deferred
rollback, the set lock and the two commits, and calls one function per step:
batchSet checks that a batch carries one set, stageDeltas (with stageDelta for
one delta) re-reads the rules and orders the batch by resource key, fileActs
files one act per delta and reads each act's stamp, settleCarryingPools settles
every carrying pool on the atomic path, and drainRequest drains what it can on
the deferred path. actRefusal, poolRefusal and inProgressOr hold the refusals:
a lock wait that ran out is the in-progress refusal at each of the five sites
that checked for one, each with that site's own fallback, and the missing
rendezvous still returns the bare error for the caller to log. Each
step takes the commit's transaction or its values, and the statements run in
the order they ran. The deferred path still returns its result together with
the drain's error. The gocognit exception for RuleChangeBatchCommit (pinned at
61) is deleted. Measured with gocognit 1.2.1 at this commit:
RuleChangeBatchCommit 13, settleCarryingPools 8, stageDeltas 7, fileActs 7,
stageDelta 5, batchSet 4, actRefusal 2, poolRefusal 1, drainRequest 1,
inProgressOr 1.

Docs correction riding along: the core.commit_rule_change row of
docs/models/entitlements.md described the function as recomputing the carrying
pools up to the cap. It never materializes: it files the act, rewrites the rule
and leaves one obligation per carrying pool, and the commit settles those
obligations up to the cap and the drain the rest. The row now says so.

No output changes. The names and results of internal/entitlements,
internal/workflows/entitlements and internal/server are equal at the parent and
at this commit after a database reset on each side: 1737 results, none failing,
1 skipped. On the first run at this commit TestPlanActsGolden/default-already-at
failed on the order of two transition rows, which the golden reads with ORDER
BY on a time-ordered uuid; the default change does not reach this function. It
passed four times alone at the parent and four at this commit, and the whole
server package passed on a second run at this commit. TestRuleChangeCommitGolden
and the rest of testdata/rule_change are unchanged and no golden was updated.
TestEntitlementSetsWalkthrough and TestGrantManageWalkthrough pass on the parent
and on this commit from the demo snapshot, and all 22 walkthrough results and
skip messages match. The lint snapshot differs from the parent's by no added
finding and no removed one. No test is edited.

Breaks: 19 breaks. 18 run on both sides and are caught by the old and the new
test; the failure of a plain lock wait to answer in progress exists only in
inProgressOr and is caught on the new side alone. Three breaks are two records
each, one per side, because the text moved into a helper. One record (two
deltas on one key refused in plain words) had a replacement that stopped the
package from vetting; it was corrected and rerun on both sides before it was
counted.

Unprobed, each because no break in this list changes it: the missing rendezvous
kept in poolRefusal; the deactivation mismatch, the lock wait at the act, the
population refusal and the lock wait of the pool listing, which moved unchanged
and are read by the refusal tests but not broken here; and the request id's
value, since only its distinctness is tested.
2026-10-10 23:42:22 -05:00
cgalo5758 c20ab7dc3c Page the recent-activity feed so every event lists on exactly one page
The landing feed read the newest 100 rows of each of its four sources and
merged only those, so an event past a source's 100th row listed on no page,
the Showing line counted the window ("of 101" for 102 events), and a page
past the window read as empty. Page p of size s now reads each source to
depth p*s in the order the feed merges them in, cuts the page from the
merge, and sums each source's count(*) OVER () for the total. A page number
past the end shows page 1, as before; the depth is capped at what a query's
LIMIT takes, so act_page=2147483647 does not wrap.

The rows a supersession makes redundant are now named once, in the
database, and the lists' queries leave them out, so each count is the count
of the rows shown. Migration 00025 adds core.transition_change_kind (the
kind of change a transition records: a resumption, an extension, else its
type, moved whole from the Go classification), core.superseded_ends (an end
and the typed row of the same act that states it, with by_extension marking
an extension's transfer) and core.extension_handoffs (the grant an
extension's transfer handed a position to), plus an index on (pool_id,
effective_at), the pairing key. The organization page's Tier changes list
leaves out every superseded end; the feed leaves out the ends an extension
states and the extension's successor grant, now over the whole history
instead of the 100-row window. supersededEnds, foldSupersessions and
feedPairing are deleted, and the transitions are cut by (effective_at,
transition_id) like the other sources. Both views read the transition
reason, which the reader role withholds, so the reader-role column file
lists the six view columns as withheld; the role's script is unchanged.

Behaviour that changes: an event past a source's 100th row lists on its
page; the total counts every event; an extension older than the newest 100
tier changes lists as its tier change, not as its successor grant; and when
the transitions read fails, the extension's grant no longer stands in for
the tier change (it drops with the rest of that source, the warning
unchanged).

How it was shown to change only that: TestRecentActivityPagesEveryEventOnce
fails at the parent in its first two subtests (page 11 lists the payment
alone "of 101", and the extension's successor grant "of 102") and passes
here; its other two subtests pass at the parent and guard the hiding inside
the transitions query and the transitions' order. Names and results of
internal/server, internal/entitlements, internal/billing, internal/migrate
and internal/db are equal to the parent's (1778 result lines at the parent,
1787 here) apart from: new TestRecentActivityPagesEveryEventOnce (four
subtests), TestTransitionChangeKind and TestSupersededEnds (nine subtests);
deleted TestLandingFeedCutsEachSourceAt100Rows (its fact is the first new
subtest), TestFoldSupersessions (four subtests; its cases are the first,
second and fourth new TestSupersededEnds subtests, stated clause by clause
in SQL instead of kept as a Go oracle) and
TestTransitionChange_RecognisesMachineReasonsByWriter (its eight cases are
in TestTransitionChangeKind, with eight more); and the failure subtest "a
failed transitions read drops the tier changes, lists the extension's grant
and says so", renamed "... drops the tier changes and says so" and wanting
the issued grant, the payment and the invoice. Both goldens
(operator_activity and org_enrollment) are byte-equal, and the read-count
rows and TestRecentActivityReadsPerEvent are unchanged.
TestTransitionChange_OneLabelRule now takes (type, kind) pairs; the
classification half is in TestTransitionChangeKind.

Rewritten assertions that are NARROWER: the old test also read pages 1 and
2 of the 50-row size on the 101-invoice feed; the new one reads page 11 and
the clamped pages at 10 a page, so those two pages of a 50-row size are no
longer read (a 25-row size and page 2 of a 30-row feed stay in
TestLandingPagesTheActivityFeed). wholeFeed in the golden tests now asks
for the whole timeline on one page: a 400-event limit missed the fixtures'
billing events once other tests had written newer rows to the shared
database.

test/e2e/plan-management TestMVPPlanManagement step 8.6 read the transition
audit through the by-pool list and wanted 5 rows (initiate 2, upgrade 1,
end 2); the list now leaves out the end the trial grant's upgrade
supersedes, so it returned 4. The step now counts the 5 rows written in
core.pool_provision_transitions directly, then wants 4 listed (initiate 2,
upgrade 1, end 1). Wider: the audit fact is kept and the list is asserted
besides. The end-to-end package is outside this commit's checks; a copy of
steps 8.2 to 8.6 run on the test database passes here and, at the parent,
fails on the list's count (5 listed).

Checks: 25 breaks (12 in Go and generated code, 13 in the migration, each
migration break probed with the test databases reset before and after),
each caught by the new tests. The Down of 00025 ran once on a scratch
database (down one version, up again) without error. sqlc-unused reports
unused=0 and the deleted names occur nowhere in internal or docs. The lint
snapshot is unchanged (0 added, 0 removed). Walkthroughs are equal at the
parent and here (16 pass, 6 skip); screens at the parent and here show the
same changes against the baseline, all in the masked regions or equal on
both sides, so operator-overview is unchanged at both widths.

EXPLAIN (ANALYZE, BUFFERS) on the test database, with enable_seqscan on and
off: the restored-names subplan runs once per returned grant (10 loops),
the two anti-joins build the redundant set once, and the invoice and
payment reads scan each table once. A tiny database does not show how the
plans scale, so the same queries ran on a rolled-back fixture of 146,000
transitions: the by-pool list probes the primary key and the effective-time
index in 0.08 ms; the feed's transitions read, which counts the whole
history for its total, takes about 150 to 200 ms there; with tier changes
sharing a few instants, the pairing probe uses the new (pool_id,
effective_at) index.

Unprobed: the by-pool list's pairing probe chose the pool_id index with an
effective_at filter instead of the new index (a pool's rows are few, so the
choice costs nothing; the new index is used where one instant spans many
pools); the statement count of a page after the first (the read-count rows
read page 1, and a later page runs the same four statements); the total
when a source read fails (the failure table asserts rows and warnings, not
the total); a grant-less provision or a provision-less extension in
core.extension_handoffs (no writer records one); the legacy transition type
'extend' in the views (no writer has written it since an early migration's
type check left it out; the function's test pins its kind); and a source
whose rows and count come from two snapshots, when an event is written
between two of the four statements.
2026-10-10 20:51:37 -05:00
cgalo5758 e68493ea16 Read both grants pages from one listing query
ListGrantsWithDelivery now takes optional organization, search, product,
state and paging arguments and serves the grants page and the
organization page's ledger. The grouped SELECT's columns and grouping
moved unchanged into a CTE named listed, whose WHERE now holds the
organization filter and the search filter (with the organizations join
the search needs); the outer statement filters on the state computed
once, counts with count(*) OVER() after that filter, orders by
created_at DESC and pages (a NULL limit or offset reads every row).
ListGrantsWithDeliveryPage, toDeliveryRows and loadGrantsListData's
unused success and error-message arguments are deleted. The generated
row is field for field the old page row. Product names are still
matched in Go, so % and _ in a search keep matching themselves there.

The two pages keep the pool difference: with an organization the waits
are evaluated on its active default pool, without one on every pool.

How the commit was shown to change no output:
- The row golden, run first on PostgreSQL against the merged query,
  passed with every file under testdata/grants_listing/ and
  testdata/org_enrollment/ unchanged. LIMIT NULL OFFSET NULL returns all
  rows, and EXPLAIN ANALYZE of the organization-only call and of the
  search, state and page call shows no CTE scan on listed (it is inlined).
- internal/server (1398 results) and internal/entitlements (180) give
  the same test and subtest names and results at the parent and at this
  commit; no test was added, renamed or deleted.
- 13 breaks, each caught by the old and the new test, plus 3 breaks
  caught on the old side only: the adapter's copies of the waits, the
  default-duplicate and the restored-after columns, which are deleted
  with the adapter. No break was dropped because the old test missed it.
- Test calls were re-pointed to the merged query with their assertions
  unchanged (every row of the assertion map is same). The comments of the
  query, the grants template, docs/models/entitlements.md and
  docs/operator-ux-conventions.md now name the one query.

Unprobed: the order of grants that share created_at (a tier removal
writes one default grant per vacated pool in one transaction). The
merged query's plan may order such a tie differently from either old
query, no fixture holds a tie, and the order is accepted until the
tie-break by grant id, as for positions. The zero UUID pool of an
organization with no active default pool: no fixture organization lacks
one. total_count on the organization call, which no caller reads. The
adapter's other 22 copied columns on the old side: the goldens read each,
three are probed, and the adapter is gone so no copy remains to break.
2026-10-10 03:36:06 -05:00
cgalo5758 c77c9d7300 Correct the documents that name moved code
The storefront and consumers rows name the files that now hold the plan,
add-on and entitlement code; the integration guide drops member_products.go
from the files that stay in core and says the files of the split import no
Stripe package; the resource-pools
dimension states what the schema admits and what the console refuses; the plan
ladders card gains a Member position row; the kit table says the fixture
builder covers plan fixtures.

No output changes: only Markdown under docs/ is touched, no Go, SQL or
template file. git grep for member_products.go in docs and scripts is empty,
make lint and the notebook-citation check pass, and the lint snapshot differs
from the previous one by removed 0, added 0.
2026-10-09 04:50:39 -05:00
cgalo5758 f66a80b26a Restore the rules in the model cards and the Stripe guide 2026-10-07 06:28:59 -05:00
cgalo5758 8dcda61e37 Describe the console's database locks and the order paths take them 2026-10-07 01:35:25 -05:00
cgalo5758 afb18aae07 Correct the model cards that disagree with the code 2026-10-07 01:19:45 -05:00
cgalo5758 ab2bd4ada6 Delete the materializer's dry run and rule overlays 2026-10-07 00:36:11 -05:00
cgalo5758 15ffabbdd6 Delete the rule-change dry run 2026-10-07 00:36:11 -05:00
cgalo5758 23d5bad024 Add integration delivery core: provider connections, desired-state
records, outbox, lifecycle requests, relay, push, read-back, and FedWiki
lifecycle workflows

- Add core.provider_connections table and unique provider connection per
  provider
- Add core.target_mappings table linking connections to resource keys
  and targets
- Add core.desired_state_records table storing desired entitlements per
  connection and recipient with generation and content hash
- Add core.observed_projections table for application-reported state per
  connection and recipient
- Add core.lifecycle_requests table recording lifecycle requests by
  instance and generation
- Add core.person_cut_offs table and setter function to record persons
  cut off everywhere
- Add core.desired_state_outbox table with triggers on entitlement,
  membership, organization, connection, mapping, and cut-off tables to
  write change notices
- Enforce constraints on recipients, generations, and statuses across
  tables
- Implement desired_state_content SQL function computing record content
  per recipient rules
- Implement relay workflow to claim outbox rows, expand to records,
  recompute records, and dispatch pushes
- Implement push workflow to push records, handle transport answers, and
  record push results
- Implement read-back workflow to page through application state, update
  projections, and raise records as needed
- Add lifecycle request writers with idempotent recording, finishing,
  and acknowledgment
- Add FedWiki integration lifecycle workflows for create, set status,
  swap, delete, and reconcile
- Add FedWiki web handlers and forms for lifecycle requests, including
  create form with instance and generation
- Add tests for schema, triggers, relay, push, read-back, lifecycle
  requests, FedWiki workflows, and operator pages
- Add documentation updates for provider integration, entitlements,
  identity, organizations, lifecycle, and FedWiki integration
- Add benchmarks for delivery and push workflows
- Ensure schema-per-provider for in-tree integrations; connector state
  lives in generic core tables keyed by connection
- Secure functions and triggers with SECURITY DEFINER and restrict
  core_writer role privileges accordingly
- Handle Temporal client integration for workflow execution and
  scheduling
- Provide a recording transport for integration tests
- Close Issues 32 and 33 by implementing provider connections and
  desired-state records with lifecycle requests and relay mechanisms
2026-10-06 03:46:15 -05:00
cgalo5758 113c672e3b Resume a marked grant when the plan that replaced it ends, and archive the change
An operator can mark a grant to resume. When a plan replaces a marked
grant and that plan later ends (the subscription ends, or a granted plan
is revoked or expires), the grant delivers again from its own row before
the org-type floor runs (core migration 00023, core.grant_waits).

- Grants: a resumes_after_supersession column, off by default and never
  on a default grant; delivery states gain On hold, with a badge hover
  that names what the grant waits on.
- Operator: one Manage action per grant opens a card under its row
  (Note, Valid until, Resumes; Save, Cancel, Revoke). A changed Valid
  until extends the grant; otherwise the note and mark change in place.
  An extension now keeps its predecessor's valid_from.
- Member: a held rung reads Granted and is reached by cancelling; the
  cancel confirmation names each ladder's outcome; Cancel plan sits
  beside Keep plan while a downgrade is scheduled.
- Smaller fixes from the screens reviews: 24-hour times everywhere,
  search placeholders that name what they match, a checkbox label above
  its box, and the org-types walkthrough restoring the default it
  changes.
2026-10-01 18:16:40 -05:00
cgalo5758 56e65b669c Read invoices back from Stripe and archive the change
Stripe delivers webhook events in the endpoint's API version. Under a
version later than the 2025-02-24.acacia stripe-go v81 pins, the
payload-shaped invoice handlers projected every invoice with no
subscription link and no lines, and every payment with no payment
intent or method.

- Invoice events read only the invoice id and run ReconcileInvoice,
  which reads the invoice, its lines and its payment intent's charges
  back at the pinned version and converges the core rows under a
  per-invoice lock, in any event order. A payment row is one attempt,
  keyed by its Stripe charge (Stripe store migration 00005).
- Two clocks: created_at is when the console wrote a row; invoices gain
  issued_at and payments paid_at (core migration 00022, backfilled),
  and every invoice and payment list, the activity feed and the
  composite's latest invoice read those.
- An hourly and at-boot invoice sweep reads back invoices never read,
  open invoices once a day, and invoices of dead-lettered events; the
  provider page states when it last finished. The first run is the
  repair of invoices projected so far.
- The API version skew is logged once per version, and
  payment_method.attached links payments recorded before it.
- A restricted key now needs Invoices read and Charges read.
- scripts/db-reader-role.sql maintains console_reader, a login role
  that reads the console's tables without personal data, documented in
  docs/database-management.md and checked against a golden column list
  by TestConsoleReaderColumns. The demo snapshot keeps owners and
  privileges so the role's checks hold on it.
- The demo seed and test fixtures carry issue and paid times.
  status/design-feedback.md gains four upstream notes, one written when
  #60 was closed.

OpenSpec change stripe-invoice-reconcile is archived and its deltas
synced into openspec/specs/. Follow-ups are #150, #151 and #152.
2026-09-28 18:42:29 -05:00
cgalo5758 fba272fedb Charge upgrades now and defer downgrades to renewal
A paid-to-paid switch sent create_prorations, which charged nothing
now: the difference waited on the next renewal invoice, an immediate
cancellation lost it, and the confirmation's "charged now" was false.
A downgrade took effect at once, so moving to a cheaper paid tier cost
more access than cancelling (OpenSpec change plan-switch-timing,
unarchived: it awaits the maintainer's review, task 11.4).

- An upgrade is invoiced at once (always_invoice) and applies once paid
  (pending_if_incomplete). A declined card voids the invoice and leaves
  the plan as it was; a card that needs the bank's approval links to
  Stripe's invoice page. The preview pins its proration moment and the
  switch charges at it, so the amount charged is the amount shown.
- A downgrade takes effect at renewal through a subscription schedule
  and is recorded in the scheduled-change ledger.
- Every paid plan's card offers Cancel plan; a scheduled downgrade or
  cancellation shows on the card with Keep, which drops it at no
  charge. A paid rank-0 tier is a downgrade target, and a rung a grant
  holds moves up through Checkout with no move down.
- Reconcile keeps the ledger true to Stripe, and marks ended the
  subscription item a schedule's phase change replaced (core migration
  00021, subscription_items.ended_at). The sweeper reconciles a
  period-end move instead of firing it.
- The form library gains a commit that is a link out of the console.

The verify skill and the first-contact process now say the gap in
scripted screens is tracked as #118 and that a new issue waits for the
maintainer's approval.
2026-09-28 18:37:10 -05:00
cgalo5758 0c78d5f425 Move issue tracking to the forge tracker
Replace status/issues.md and status/milestones.md with the Gitea forge
tracker, add issue templates, and archive pre-forge issues as YAML.
2026-09-21 02:35:27 -05:00
cgalo5758 0caed6e905 Harvest the exploration notebooks into the docs
A survey of the twenty notebooks on disk found general facts that no
tracked page stated; the maintainer struck the weak rows and the rest
are written into their owning pages in each page's own words. The
design-system page gains the hidden-navigation figures that replaced a
dangling survey pointer, the reason the anatomy is parts and lint, the
record-table grouping and single-tint rules, and when a surface earns
row editing. The first-contact process gains a code-verification phase,
the transcript lane for models without vision, the three kinds of
honesty-audit drift and the click-path guardrail. The operator
conventions gain guards-before-writes, error state over empty state,
copy that states the real effect, toasts that report counts and the
PostForm rule. The Stripe, FedWiki, identifiers, IA, testing, model and
environment pages each gain their facts, the integration guide stops
telling authors to hand-write a page title, issues.md logs the
placeholder security contact, milestones.md records the Codeberg terms
clause and CONTRIBUTING.md notes that the module path is the forge URL.

One claimed fact was checked against the code and not written: the
mass-mutation preview does not run the commit loop in a rolled-back
transaction.
2026-09-19 19:47:33 -05:00
cgalo5758 660485d9b8 Make status/explorations a local notebook git ignores
The exploration directory held 245 tracked files, and one week's
notebook had grown to 28M of captures, per-run CSS copies and model
logs, so every change ended with an argument about what to prune. It is
now a local lab notebook: git ignores it, its tracked files are
untracked, and no tracked file cites a path under it, because another
clone does not have it.

Every live citation is rewritten so the fact stands in the sentence:
the docs pages, AGENTS.md, four main specs, one Go comment, issues.md
and milestones.md. The operator IA page gains the landed IA revisions it
used to point at, and the UX rubric moves to docs/first-contact-rubric.md
because the process requires it. Archived changes, status/archive,
status/log and design/ keep their old paths as frozen history; the
design/ ones are logged as an upstream issue. MAINTAINING.md and the
status README describe the notebook and the harvest that replaces
pruning.
2026-09-19 19:46:52 -05:00
cgalo5758 f8a3478f2a Rebuild the entitlement set Rules surface as a staged batch
The Rules section is one record table grouped by kind, Limit then
Boolean, on fixed columns, edited in place: Edit opens a row's controls
in their columns, Add rule opens a dense row above the table, and every
change is staged into a tray that lists the deltas with Undo and applies
them as one rule-change act. The reduction policy is a column of the
rule beside its limit. History shows counts only. Group rows are a quiet
heading rather than a divider, the maintainer's pick from four rounds of
outside-model ideation.

Dense rows align to the top and render each error under its control in
every form family (design D16), replacing the below-row error block; the
forms library gains the batch form (rows plus one tray) and the RowField
dense and label-hidden options. Migration 00019 records the governing
reduction policy on effect rows.

Archive staged-rule-changes with its spec updates (entitlement-set-
management, entitlement-set-history, entitlements, form-library,
form-conventions, ui-quality-gate). Screens accepted 2026-09-19.
2026-09-19 19:46:09 -05:00
cgalo5758 0b28a9dc29 Remediate security audit findings
- Replace gorilla/csrf with net/http CrossOriginProtection
- Require valkey-password and add TLS options for session store
- End session at /logout and revoke refresh tokens
- Re-derive identity and roles from provider every five minutes
- Process each Stripe webhook event in its own Temporal workflow
- Give each outbox entry its own workflow with Temporal retries
- Guard against stale Stripe events with provider timestamps
- Derive transport security from base-url scheme
2026-09-09 13:25:43 -05:00
cgalo5758 568383b55e Reorganize status docs and add identifier docs
Move settled investigation records into
`status/explorations/<topic>-<date>/` with README indexes,
promote durable conventions to `docs/`, and add
`docs/identifiers.md` plus upstream Doc 46 person-name
provenance documents. Update cross-references and archive
resolved issues.
2026-08-29 21:15:31 -05:00
cgalo5758 dd3962990b Adopt entity keys and add invoice numbers
Replace the entity slugs on organizations, workspaces, resource pools,
and
plan ladders with nullable `key` columns and add keys to products,
prices,
and entitlement sets. Rename `providers.slug` to `provider` and add
partial
unique indexes for system and org role names.

Assign invoice numbers per billing account from a gapless transactional
counter; Stripe's number moves to the invoice mapping as an external
reference.

Seeds, fixtures, and the operator lookup address rows by key, and the
returning-login resync no longer blanks a display name when the IdP
sends
no `name` claim.
2026-08-29 20:12:04 -05:00
cgalo5758 8e3c68c6be Make UI surfaces honestly reflect system state
- Add deployment-name branding to titles, mastheads, and OG tags
- Share one grant delivery-state query with lineage across grants
  surfaces
- Show pool status/usage, org owners, and config readiness
- Make billing views projection-aware with recency and sync vocabulary
- Guard FedWiki creation without domains and render route-aware 404s
2026-08-23 01:45:52 -05:00
cgalo5758 ad7a219adf Enforce schema and boot invariants
Enforce 10j's verified gaps (schema-hardening change):

- Migration 00010: partial unique indexes for one default pool and one
  primary assignment per workspace, plus CHECKs pinning
  pool/provider/subscription vocabularies and provider lifecycle
  timestamps.
- Workspace creation shares a transactional provisioning function;
  extension validates its target pool; last-tier deletion of a defaulted
  ladder is guarded; signup completes plan-less on a broken ladder.
- Boot asserts integration slug parity and validates declared config
  enums; Stripe invoice amounts are range-checked; domain cancellation
  runs a final evidence probe; rule authoring is additive-only.
2026-08-22 18:02:46 -05:00
cgalo5758 efe3f1528d Restrict member surfaces to published products
Add lifecycle_status = 'published' to the public-catalog queries
(plans and add-ons listings) and reject checkout before any Stripe
call unless the product behind the price clears the shared member
gate (published + active + public). The currently-enrolled ladder
rung stays renderable even if its product is later drafted or
retired, fetched directly so members keep seeing what they are on.

Introduce a single evaluateMemberGate definition shared by the
catalog paths and the operator readiness panel so the surfaces
cannot disagree about what is publishable for members.
2026-08-22 12:58:05 -05:00
cgalo5758 ac2760a0fe Add model catalog cards and ratify Doc 44
Write the eight domain-model cards under `docs/models/`, mark M10 10i
Done, and update the design docs to v16. Also records Doc 44, which
ratifies Decisions 129-133 as amended, and the Doc-39 conformance
check that satisfied its gate.
2026-08-22 03:11:19 -05:00
cgalo5758 706c177847 Add model cards catalog
Introduce `docs/models/` with the catalog index and its first card
(product-catalog), recording invariants, dimensions, and drift traps
per the Decision 141 documentation division. Add the model-cards
openspec change (proposal, design, spec, tasks) and sync the design
docs to v15: product kind taxonomy dissolved (Decisions 134–139),
Decisions 140–141 ratified, and product/pricing helpers updated. The
catalog is the single descriptive home for as-built models.
2026-08-21 18:47:49 -05:00