Plan-wide changes to ladders and defaults run inside HTTP handlers, each with its own transaction shape #187

Closed
opened 2026-10-06 21:25:45 +00:00 by cgalo5758 · 5 comments
Owner

Grant acts live in internal/entitlements; three acts that move many organizations stayed in handlers. Tier removal (internal/server/operator_plan_ladders.go) runs in one transaction with sorted pool locks, and any failure changes nothing. The org-type default change (operator_org_types.go) and ladder reorder save the catalog first, then move each organization in its own transaction through enactOrgDisposition; a failure leaves that organization on the outgoing default, recorded only in the message after the commit.

Done when: the three acts are functions in internal/entitlements locking pools through one helper; the reorder and default change run in one transaction or record the organizations they did not move where a page can show them (#78); handlers only parse, call and render; and a test runs a tier removal concurrently with a default change on a shared pool. #82 builds on these functions.

Grant acts live in `internal/entitlements`; three acts that move many organizations stayed in handlers. Tier removal (`internal/server/operator_plan_ladders.go`) runs in one transaction with sorted pool locks, and any failure changes nothing. The org-type default change (`operator_org_types.go`) and ladder reorder save the catalog first, then move each organization in its own transaction through `enactOrgDisposition`; a failure leaves that organization on the outgoing default, recorded only in the message after the commit. Done when: the three acts are functions in `internal/entitlements` locking pools through one helper; the reorder and default change run in one transaction or record the organizations they did not move where a page can show them (#78); handlers only parse, call and render; and a test runs a tier removal concurrently with a default change on a shared pool. #82 builds on these functions.
cgalo5758 added this to the Public launch milestone 2026-10-06 21:25:45 +00:00
cgalo5758 added the
kind
design
area/entitlementsarea/operator-ui
labels 2026-10-06 21:25:45 +00:00
Author
Owner

This also covers tier add and tier delete. Both open their transactions in the handler, and tier delete renumbers the ladder as the reorder and the tier removal do (the race in #213). They move into internal/entitlements with the other three and take the same shape.

This also covers tier add and tier delete. Both open their transactions in the handler, and tier delete renumbers the ladder as the reorder and the tier removal do (the race in #213). They move into `internal/entitlements` with the other three and take the same shape.
Author
Owner

Two more things this needs to cover. The five plan-wide acts now run in internal/entitlements (AddTier, DeleteTier, RemoveTier, ChangeOrgTypeDefault with EnactDefault, and ReorderTiers), with the same transactions they had in the handlers.

Neither the default change nor the reorder can be re-run to move the organizations a failed enactment left on the outgoing default. The default-change form refuses the current default as a candidate, and dropping the committed order again finds rank 0 unchanged and enacts nothing. Both failure answers still tell the operator to re-run ("Re-run the commit.", "re-drop the same order, and commit again"), and the org-types requirement says the enactment is safely re-runnable after failures are fixed.

The default change fixes its candidate's rank-0 product once, before it saves. A reorder, delete or removal on that ladder that commits in between makes every organization classify against a product that is no longer rank 0. Two reorders of one default ladder in quick succession reach the same thing: the first one's enactment classifies against the rank-0 product it saved after the second has renumbered the ladder, and grandfathers an organization that is already on the current rank 0. TestReorderEnactsAgainstTheRankZeroItSaved pins what that records today.

The removal-versus-default-change test exists now: TestTierRemovalAgainstDefaultChange queues both acts on one pool in each order and runs them one after the other in each order. It pins what happens today, so the change that settles this issue flips its files on purpose.

Two more things this needs to cover. The five plan-wide acts now run in internal/entitlements (`AddTier`, `DeleteTier`, `RemoveTier`, `ChangeOrgTypeDefault` with `EnactDefault`, and `ReorderTiers`), with the same transactions they had in the handlers. Neither the default change nor the reorder can be re-run to move the organizations a failed enactment left on the outgoing default. The default-change form refuses the current default as a candidate, and dropping the committed order again finds rank 0 unchanged and enacts nothing. Both failure answers still tell the operator to re-run ("Re-run the commit.", "re-drop the same order, and commit again"), and the org-types requirement says the enactment is safely re-runnable after failures are fixed. The default change fixes its candidate's rank-0 product once, before it saves. A reorder, delete or removal on that ladder that commits in between makes every organization classify against a product that is no longer rank 0. Two reorders of one default ladder in quick succession reach the same thing: the first one's enactment classifies against the rank-0 product it saved after the second has renumbered the ladder, and grandfathers an organization that is already on the current rank 0. `TestReorderEnactsAgainstTheRankZeroItSaved` pins what that records today. The removal-versus-default-change test exists now: `TestTierRemovalAgainstDefaultChange` queues both acts on one pool in each order and runs them one after the other in each order. It pins what happens today, so the change that settles this issue flips its files on purpose.
Author
Owner

Two more defects in the plan-wide acts.

Enactment order among organizations with one name is not fixed. The default change (ChangeOrgTypeDefault, internal/entitlements/default_change.go:192-206) and the reorder's enactment (enactReorder, internal/entitlements/ladder_acts.go:441-449) enact organizations in the order ListOrganizationsByType returns them. That query sorts by name alone (internal/organization/queries/organizations.sql:87-90), and organization names are not unique. Two organizations with the same name are enacted, and listed among the failures in the answer, in either order from one run to the next. Ending the sort with the organization id fixes it.

Tier add records the ladder's id in the transition reason. AddTier writes "tier added to ladder " (internal/entitlements/ladder_acts.go:334). The other acts record names, read when they write: the removal records the product and ladder names (line 145), the reorder the ladder's name, with the id only when the name cannot be read (lines 428-437), and the default change the org type (default_change.go:191). The Tier changes list matches the prefix and shows "Tier added to the ladder" either way (internal/server/operator_tier_changes.go:50-51), so the difference is only in the stored history: a tier add's rows name the ladder by an id, which says nothing once the ladder is deleted. Once the acts share one shape, one function should build the reason for all of them.

Two more defects in the plan-wide acts. **Enactment order among organizations with one name is not fixed.** The default change (`ChangeOrgTypeDefault`, `internal/entitlements/default_change.go:192-206`) and the reorder's enactment (`enactReorder`, `internal/entitlements/ladder_acts.go:441-449`) enact organizations in the order `ListOrganizationsByType` returns them. That query sorts by `name` alone (`internal/organization/queries/organizations.sql:87-90`), and organization names are not unique. Two organizations with the same name are enacted, and listed among the failures in the answer, in either order from one run to the next. Ending the sort with the organization id fixes it. **Tier add records the ladder's id in the transition reason.** `AddTier` writes "tier added to ladder <ladder id>" (`internal/entitlements/ladder_acts.go:334`). The other acts record names, read when they write: the removal records the product and ladder names (line 145), the reorder the ladder's name, with the id only when the name cannot be read (lines 428-437), and the default change the org type (`default_change.go:191`). The Tier changes list matches the prefix and shows "Tier added to the ladder" either way (`internal/server/operator_tier_changes.go:50-51`), so the difference is only in the stored history: a tier add's rows name the ladder by an id, which says nothing once the ladder is deleted. Once the acts share one shape, one function should build the reason for all of them.
Author
Owner

All five plan-wide changes now run in internal/entitlements on one shape, and a default change or a reorder keeps a record of every organization it still has to move.

How each part of "Done when" is met:

  • The acts are functions in internal/entitlements that lock pools through one helper. AddTier, DeleteTier, RemoveTier, ChangeOrgTypeDefault and ReorderTiers run on runPlanAct. It opens the transaction, finds the pools the act touches, locks them in ascending id through lockPools, reads them again under the locks, then acts and commits. Every failure names the step where it happened. Each act also writes an act record (core.acts, with its plan details in core.plan_act_details): who made the change, why, and which request it came from.
  • The default change and the reorder record the organizations they did not move where a page can show them. Their first transaction saves the change and records which org types it reaches. A listing then writes, in short batches, one obligation for each organization of those types that has a default pool. Each organization moves in its own transaction, which settles its obligation. That transaction reads the type's current default and its rank-0 tier, so a reorder or a removal that commits in between cannot make a move use a product that is no longer rank 0. The request itself tries up to 50 organizations within two seconds. A background workflow moves the rest, and a poller starts that workflow for any change that still owes work. An organization whose move fails five times is listed on the org type's card, or below the ladder's tiers for a reorder, with its reason and a Retry button. Retry is recorded as an act of its own and moves the failed organizations again. A newer change to the same type takes over the organizations an older one has not moved yet.
  • Handlers only parse, call and render. A failure shows a reference, and the error's log line carries the same one. A lock wait or a deadlock answers "Another change collided with this one; try again."
  • A test runs a tier removal concurrently with a default change on a shared pool. TestTierRemovalAgainstDefaultChange runs both acts on one pool, in each order, each queued behind the other.

The defects raised in the comments above are fixed too:

  • A failed move is no longer re-run by committing again. It is retried from the card.
  • Organizations of one type are listed by name, then by id.
  • One function builds every plan act's reason, and names the ladder by its name.
  • A tier removal no longer empties the default plan of an org type. It refuses to remove the last tier of a ladder an org type defaults to, and the ladder page renders that tier's Remove disabled with the reason.
  • The ladder page no longer offers a removal it would refuse: a refused removal preview shows the refusal with Discard alone.

What it leaves to other issues:

  • #237: a tier removal or a tier add above 200 holders is refused. Retiring the tier and moving its holders one at a time is the way past that limit.
  • #238: a default change or a reorder takes no note yet. The act record already has the column.
  • #239: these acts' error sentences carry a reference. Most of the console's other error sentences do not yet.
  • #82: one disposition still applies to every organization a change reaches. Each organization's obligation row is where its own disposition would go.
  • #179: the Tier changes rows do not point at their act yet. Grants created by a plan act already do, through core.grants.act_id.

Commits: de059233, af75641a to 86c8d0a1, ff7f31e5, 7382877e, 0b48297b, b0df2864, a86547a4, eeded12c, 9b0e2e56, 93cb5545, 95a863e0.

All five plan-wide changes now run in `internal/entitlements` on one shape, and a default change or a reorder keeps a record of every organization it still has to move. How each part of "Done when" is met: - **The acts are functions in `internal/entitlements` that lock pools through one helper.** `AddTier`, `DeleteTier`, `RemoveTier`, `ChangeOrgTypeDefault` and `ReorderTiers` run on `runPlanAct`. It opens the transaction, finds the pools the act touches, locks them in ascending id through `lockPools`, reads them again under the locks, then acts and commits. Every failure names the step where it happened. Each act also writes an act record (`core.acts`, with its plan details in `core.plan_act_details`): who made the change, why, and which request it came from. - **The default change and the reorder record the organizations they did not move where a page can show them.** Their first transaction saves the change and records which org types it reaches. A listing then writes, in short batches, one obligation for each organization of those types that has a default pool. Each organization moves in its own transaction, which settles its obligation. That transaction reads the type's current default and its rank-0 tier, so a reorder or a removal that commits in between cannot make a move use a product that is no longer rank 0. The request itself tries up to 50 organizations within two seconds. A background workflow moves the rest, and a poller starts that workflow for any change that still owes work. An organization whose move fails five times is listed on the org type's card, or below the ladder's tiers for a reorder, with its reason and a Retry button. Retry is recorded as an act of its own and moves the failed organizations again. A newer change to the same type takes over the organizations an older one has not moved yet. - **Handlers only parse, call and render.** A failure shows a reference, and the error's log line carries the same one. A lock wait or a deadlock answers "Another change collided with this one; try again." - **A test runs a tier removal concurrently with a default change on a shared pool.** `TestTierRemovalAgainstDefaultChange` runs both acts on one pool, in each order, each queued behind the other. The defects raised in the comments above are fixed too: - A failed move is no longer re-run by committing again. It is retried from the card. - Organizations of one type are listed by name, then by id. - One function builds every plan act's reason, and names the ladder by its name. - A tier removal no longer empties the default plan of an org type. It refuses to remove the last tier of a ladder an org type defaults to, and the ladder page renders that tier's Remove disabled with the reason. - The ladder page no longer offers a removal it would refuse: a refused removal preview shows the refusal with Discard alone. What it leaves to other issues: - #237: a tier removal or a tier add above 200 holders is refused. Retiring the tier and moving its holders one at a time is the way past that limit. - #238: a default change or a reorder takes no note yet. The act record already has the column. - #239: these acts' error sentences carry a reference. Most of the console's other error sentences do not yet. - #82: one disposition still applies to every organization a change reaches. Each organization's obligation row is where its own disposition would go. - #179: the Tier changes rows do not point at their act yet. Grants created by a plan act already do, through `core.grants.act_id`. Commits: de059233, af75641a to 86c8d0a1, ff7f31e5, 7382877e, 0b48297b, b0df2864, a86547a4, eeded12c, 9b0e2e56, 93cb5545, 95a863e0.
Author
Owner

Two follow-ups landed after closing:

  • 9db9c107: the refusal of a default ladder's only tier now names the way past it: "New Personal organizations start on this tier, the only one in their default plan. Add another tier first, or choose a new default for Personal."
  • 35e14578: the ladder page decides every tier's removal with the same rule the preview and the removal use, so a tier more organizations hold than the holder limit renders Preview removal disabled with the limit sentence as its reason. It reads every tier's holder counts in one statement.
Two follow-ups landed after closing: - 9db9c107: the refusal of a default ladder's only tier now names the way past it: "New Personal organizations start on this tier, the only one in their default plan. Add another tier first, or choose a new default for Personal." - 35e14578: the ladder page decides every tier's removal with the same rule the preview and the removal use, so a tier more organizations hold than the holder limit renders Preview removal disabled with the limit sentence as its reason. It reads every tier's holder counts in one statement.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: wiki-cafe/member-console#187