26 Commits
Author SHA1 Message Date
cgalo5758 f3fa8e7456 Confirm a plan switch in the shared dialog
#291. A plan switch (Upgrade, Downgrade, or Pay for this plan on a
granted tier's card) was confirmed in a panel at the top of the Plans
section, above the cards and away from the button that opened it. It
now confirms in the same dialog as Cancel plan and Resume plan. The
button opens the dialog at once, titled with the panel's question
("Switch to Pro?", or "Pay for Pro?" on the granted tier's own card),
and the dialog's body loads from the switch's preview route, because the
charge it states comes from Stripe at that moment. Keep current plan
closes the dialog and sends nothing.

An answer that belongs in the dialog stays there: a declined card, a
payment awaiting the bank's approval, stale figures; focus moves to the
new form. An answer that lands outside closes it: the switch made or
refused, or a plan withdrawn after the page loaded. A load that fails
closes the dialog with the error toast; a commit that fails (403 or
5xx, which the pages' htmx config does not swap) leaves it open with
the toast. Closing the dialog while its body loads aborts the load, and
the aborted load shows no toast. The shared dialog gains a region the
loaded form lives in; the forms library renders a preview that leaves
by the dialog without a box of its own; the lint accepts
`data-action-load` as a trigger's route. No string is added or
reworded. docs/design-system.md, docs/operator-ux-conventions.md and
docs/stripe.md say the same.

Tests: TestSwitchAnswersNameTheirRegion, TestPlanMoveTitle,
TestSwitchControlOpensTheSharedDialog,
TestConfirmModalCarriesTheLoadedRegion,
TestPreviewInTheDialogRendersNoBox, and the walkthrough
TestSwitchDialogWalkthrough (seven steps in a browser, among them a
load answered offline, a load closed mid-flight, and a commit answered
503 by the browser so nothing reaches Stripe). The walkthrough resets
the application database, so it runs only with SWITCH_DIALOG_WALK=1, as
the merchant-disclosure sheet runs only when asked; make test skips it.
35 goldens change: every member plans golden loses one blank line, the
switch controls of 12 lose their hx-* attributes for the dialog's, and
the plan move form's golden loses its box.

Full suite on reset databases: 5249 passed, 16 skipped, none failed.
Sandbox walk on this commit, all twelve steps passed: the dialog opens
at once and loads Stripe's figure ($20.00 equal to the preview's
amount_due); Keep current plan sends nothing; Cancel plan shows its own
form; offline the dialog closes with the toast; stale figures, a
declined card and a payment awaiting approval stay in the dialog with
focus on the new form; the made switch closes it and Max is current;
a plan withdrawn in another session refuses in the Plans region; Pay
for this plan reads "Pay for Max?".

make screens: no page this commit touches changed. Three operator
captures differ for other reasons: person detail and invoice detail
show new ids, because the demo snapshot was rebuilt when the previous
landing added a FedWiki migration, and the domains list's age column
counts one more day (#120).

Not walked by the walkthrough, with the reason: a request in flight at a
chosen moment (the dialog closed while the commit runs, another switch
opened) and a page restored from the browser's cache; a load answered
400 and a commit the switch refuses answer through the same close rule
as the withdrawn plan and the made switch that the walkthrough and the
hand walk cover.

Breaks, all CAUGHT (19 test records):
- own card titled as a switch (title test)
- own card titled as a switch (control test)
- setMove leaves the title unset
- control loads without the price (control test)
- control loads without the price (render test)
- control loads the switch's route
- heading left in the message
- current tier's name unescaped
- refused preview left in the dialog
- confirmation answered into the Plans region (a declined switch)
- confirmation answered into the Plans region (a stale switch)
- confirmation answered into the Plans region (a switch awaiting
  approval)
- decline answered 200
- way out a link again
- commit's description dropped
- region renamed in the partial
- box rule removed
- box rule widened to every kind
- box rule widened to every way out

Breaks of the walkthrough, all CAUGHT (8 records, each building and
booting the app from the edited tree):
- the close not deferred
- the abort on close dropped
- the cancelled mark ignored by the toast
- the load not sent
- the loaded region left showing under a static trigger
- the close on an answer outside the dialog removed
- the failed commit closing the dialog
- the way out a link again

Seen by no test: renderConfirmation without its describeCommit call
(the database tests' handlers carry no Stripe key fingerprint, so the
line is never shown there; describeCommit itself is pinned);
data-action-load dropped from the lint's condition (no template
interpolates a route into it); the spinner and aria-busy not set, and
the focus after a stay-open answer not moved, which only the hand walk
reaches, because the walkthrough stops before any Stripe write.
2026-10-30 05:42:38 -05:00
cgalo5758 dec031e6dc Name every ladder a grant leaves empty when it supersedes a bundle
#271. A grant on one ladder of a product that is a tier of several
ladders keeps ending the subscription's tier on all of them, and the
ladders the grant is not on stay empty until it ends; what changes is
that every place an operator or a member looks now says so. A new test
pins the console's behaviour the strings rest on: the subscription
holds nothing anywhere, the other ladder holds no position, the grant
delivers, and a reconcile of the subscription is queued, which pauses
its billing when it pays for nothing else.

The Issue grant form: an option now carries its own hint
(forms.Option.Hint, rendered as data-hint, and hint-on-choice.js shows
the chosen option's hint or the declared one). A product that would
supersede a tier of several ladders names them: "This grant would
supersede a tier a subscription pays for on Duo and Wiki; its billing
pauses until the grant ends." A product superseding a tier of one
ladder keeps the existing warning. An option's hint is not held to the
registry's 100-character cap, because the ladder names are the fact.

The Not delivering badge on the organization page's Billing card and
in the subscriptions list: the ladder's name rides the conferral shapes
(ListConferralShapes selects it, Rung.LadderName), and the title adds
"; nothing is delivered on {ladders}" for the ladders of the tier on
which no live position exists when the page renders: "Billing paused
while a grant holds Wiki; nothing is delivered on Duo." A ladder
something else holds is named in neither clause. The organization page
reads the default pool's positions it already holds; the list reads
them with one statement per page (ListLiveLadderIDsBySubscriptionIDs),
and a failed read shows "Failed to load deliveries." as the other reads
do. Whether a sentence says paused or continues is decided from the
deciding displacements only, as the reconcile decides the pause, so a
ladder a later subscription holds cannot turn a recorded pause into
"Billing continues"; when a named holder is not a grant the sentence
reads "Billing paused while other plans hold Duo and Wiki."

The member's Products page: on each ladder the grant does not hold, the
paid tier's card reads "Paused while Pro is granted on Wiki", or
"Billing continues while Pro is granted on Wiki" until Stripe confirms
the pause, and draws no control; the controls stay on the card of the
ladder the grant holds.

docs/models/plan-ladders-transitions.md, docs/plan-management.md and
docs/stripe.md say the same.

Tests: TestReconcile_AGrantOnOneLadderOfABundlePausesIt,
TestSupersededLadders, TestLadderNamesNeedsEveryName,
TestRenderHintOnChoiceShowsTheChosenOptionsOwnHint,
TestSubscriptionsListNamesEveryLadderOfABundle,
TestOrgEnrollmentTwoLadderGolden (two states on their own World),
TestGrantHeldLadder, TestPaidPlanCardOnAVacatedLadder, and new rows in
TestDisplacedOnPage, TestDisplacementTitle,
TestOrgEnrollmentIssueGrantSupersedeHint, TestOrgEnrollmentReadFailures
and TestSubscriptionsListDisplacementReadFailures. TestMemberPlansGolden:
the seven paid-plus-under-granted-pro states draw Duo's Plus card with
the ladder line; the member and organization .json goldens gain the new
fields. The grant-hint walkthrough's own-hint step is a subtest that
skips, naming its precondition, because no demo state holds a
subscription to a product of two ladders.

Full suite on reset databases: 5143 passed, 15 skipped, none failed.
make screens: no page changed; no demo state holds such a subscription.
Hand captures cover the grant form, the badge and the member's cards.

Unseen by any test: hint-on-choice.js not setting the line's text; only
the browser walkthrough reads it, and its step skips. The hand captures
show it.

Breaks, all CAUGHT (44):
- DisplacedProducts passes over a product of several rungs
- displacedOnPage adds no emptied ladder (page rows)
- displacedOnPage adds no emptied ladder (golden)
- displacedOnPage names only the deciding ladder as held
- groupDisplaced keeps an emptied ladder the group names as held
- continuingSentence one subscription without the clause
- displacedOnPage ignores live (page rows)
- displacedOnPage ignores live (list)
- pausedSentence uses pausedHolder whatever the holders
- displacedOnPage takes grantsOnly over every holder the walk adds
- organization page drops the positions read error from the badge
- subscriptionDisplacementTitles ignores the live ladder read error
- ListLiveLadderIDsBySubscriptionIDs answers no row
- ListConferralShapes selects no name (list)
- ListConferralShapes selects no name (golden)
- RungsByProduct leaves LadderName empty
- supersededLadders answers for a product whose superseded provisions each hold one ladder
- supersededLadders keeps only the ladders the product is a tier of
- ladderNames answers the names it read when one is missing
- issuanceProductOptions leaves Option.Hint empty (page)
- issuanceProductOptions leaves Option.Hint empty (golden)
- issueGrantSupersedeHintOn answers for no ladders
- selectOptions does not copy Hint
- selectOptions copies Hint without ShowsHint
- choiceHint answers the declared hint for a chosen option with its own
- HintDefault left empty
- HintDefault set when no option carries its own hint
- ui_form_field.html without data-hint on the option
- ui_form_field.html without data-hint-default on the line
- grantHolds without its DefaultSourced test
- grantHeldLadder walks rows in place of ladders
- grantHeldLadder without its subscription test
- setPaidUnderGrant draws the controls on a card with grantLadder
- template plain paused branch without the ladder
- template payment-due paused branch without the ladder
- template moves-to paused branch without the ladder
- template without the Billing continues branch
- buildLadder without the empty-ladder case
- buildLadder takes the heading's cancellation date from a card with grantLadder
- pausedSentence one subscription without the clause
- pausedSentence several subscriptions without the clause
- continuingSentence a count of the account without the clause
- continuingSentence several subscriptions without the clause
- groupDisplaced leaves the emptied ladders unsorted
2026-10-29 22:34:15 -05:00
cgalo5758 f7363e98af Show the grant form's supersession hint only once a marked product is chosen
Task 5.10 of #269's change. The Issue grant form showed a yellow alert
under its lead whenever its Product select offered a product marked
with the warning sign, before anything was chosen, so it read as a
standing warning. The sentence now reads "This grant would supersede a
tier a subscription pays for; its billing pauses until the grant ends."
and is the Product field's one hint line, hidden until the operator
chooses a marked product and hidden again when an unmarked one is
chosen. A refused submission that chose a marked product renders it
shown. The form's message is the lead alone.

The forms library gains the mechanism, for any Stacked form: a select
declares HintOnChoice with its Hint, options carry ShowsHint, and the
field part renders the hint only when an offered option shows it,
hidden unless the chosen one does, and named by the select's
aria-describedby only while it shows. One external script,
hint-on-choice.js, loaded with defer by the operator shell, follows
later choices and htmx swaps through data-hint-toggle and
data-shows-hint. The invariants refuse HintOnChoice off a Stacked
select, without a Hint or with a Notice, and the escape hatch refuses
data-hint-toggle.

The change's text gains a form-library requirement ("A select's hint
can wait for the option that shows it") and modifies form-conventions'
"Every control has a label, and its hint and error are described" so a
hint is described while it shows. docs/design-system.md describes the
mechanism in section 8 and adds the script to the
progressive-enhancement table.

Tests: TestOrgEnrollmentIssueGrantSupersedeHint (at rest, no marked
product, a refused submission with a marked, an unmarked and no
product, the message the lead alone), TestOperatorShellLoadsTheHintToggle,
TestRenderHintOnChoiceFollowsTheChosenOption,
TestInvariantsHintOnChoiceBelongsToAStackedSelectWithItsHint, the
escape hatch's and the invariant messages' rows, and
TestGrantSupersedeHintWalkthrough, which chooses a marked and then an
unmarked product on the demo's Carlos Member organization and checks
the hint and aria-describedby both ways. Three organization goldens
change only in the message and the Product field.

Review: round 1, three majors (the hint supplied per render, bypassing
the registry's hint checks; no spec for the library's new contract,
with form-conventions saying the opposite; the script's break
unreachable while the app serves its embedded copy) and five minors,
all fixed; round 2, no major.

Breaks: 12 of 12 caught. 8 in code; the script's 4 run as a second
list whose preparation rebuilds and restarts the app on the broken
tree. Full suite on reset databases: 4843 pass, 14 skip.

make screens: Carlos Member's organization, open and refused, at both
widths, loses the alert. Hand captures of the form at rest, with a
marked product chosen and with an unmarked one chosen after it sit
beside the change's screens.
2026-10-28 14:18:11 -05:00
cgalo5758 fc68a6b78e Stop offering "None" for the entitlement set of a product that is a tier
Fourth section of #153's change. The product edit reads the ladders the
product is a tier of. For a tier product the Entitlement set select no
longer offers "None"; it opens with the disabled placeholder "Choose an
entitlement set", and the field's one line reads "This product is a tier
of <ladders>, and a tier needs an entitlement set." in place of the
off-ladder sentence, which moves from the field's declared hint to the
same line slot (the create page keeps it). A submitted "None" is refused
with that sentence; so is a clear refused by section 2's trigger when
the product became a tier after the page was read, after the ladders are
read again. A failed read of the ladders is logged and saves nothing,
instead of reading as "on no ladder".

The edit's write now runs in its own READ COMMITTED transaction
(db.InTx with db.ReadCommitted, item 4.6), so a tier add that commits
first is seen by the clear whatever the server's default isolation; at
REPEATABLE READ the edit's snapshot predated the tier and the clear
committed. UpdateProduct is split into helpers, each answer and log line
kept.

Tests: TestTheEntitlementSetFieldFollowsTheProductsTiers,
TestTheCreatePageCarriesTheOffLadderLine, TestTierNeedsSetSentence,
TestProductEditOfATierProductOffersNoNone,
TestProductEditRefusesNoneForAProductThatBecameATier,
TestProductEditWithUnreadLaddersSavesNothing,
TestProductEditClearingASetIsRefusedWhenATierAddCommitsFirstAtAnyDefaultIsolation
(on a connection whose default is REPEATABLE READ). The forms golden
operator.product.html moves the hint to the line.
TestUpdateProductEntitlementSetSentinel builds its product on committed
rows, since the edit's own transaction cannot see an uncommitted
fixture; its assertions are unchanged. productTierOf takes no context:
it logs through the handler's logger and unparam refuses the unused
parameter.

Breaks: 20 of 20 caught. The parse's error left in place; None offered
whatever the tiers (T6, T6c, T6d); the placeholder left out (T6d, T6);
the ladders out of name order; the line kept beside the error; the
off-ladder line blanked beside an error; the create page's line lost;
the failed read treated as no tier; the save going ahead when the read
failed; the parse's field error left when the ladders are unread; L3 not
logged; the second-read refusal not logged; the database's refusal
unmapped; the refusal named over a failed second read; the join reduced
to the first name; the write at the server's default isolation; the
empty-value condition dropped.

Review: round 1, no blocker or major; four minor and three nits,
applied.

#153's sequence (item 5.4), app rebuilt from this tree, from the demo
snapshot, the whole walkthrough package three times without a reset: ok
each run. The plan-ladders walkthrough's ladder now has tiers with sets,
the org-types walkthrough committed a default change to it on runs 2
and 3 and put the default back, and the grant revoke passed on all three
runs; the app log has no "has no entitlement set". Item 5.4 is reworded
to what ran: the three-walkthrough filter it named finds no revoke
subject on the demo snapshot, which another walkthrough's grant
supplies.

Full suite on reset databases: 4731 pass, 14 skip. make screens: no
change beyond section 1's; the captured product is not a tier.
2026-10-26 19:02:13 -05:00
cgalo5758 95a863e05b Offer no tier removal that is certain to be refused or cannot be read
A refused removal preview still asked for a disposition and showed the
Keep and Migrate choices beside a disabled "Apply change" whose reason
showed only in a tooltip. The tier card's rank 0 offered Remove while it
could not read the org types its last-tier guard needs, and the only
tier of a default ladder offered a Remove the act always refuses
(#187).

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

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

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

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

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

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

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

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

Along the way:

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

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

Goldens:

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

The full suite passes.
2026-10-19 19:54:16 -04:00
cgalo5758 e5ab7da9d0 Drop the filler phrases from five member-facing sentences
Five sentences a member can read carried a phrase the copy rule
refuses. They now read:

  The form could not be read. Try again.   (a form body, and a batch
                                            body, that cannot be read)
  That name isn't available. Choose another.
  ... Another can start there after %s. ...  (abandonment budget)
  ... Another can start after %s.            (initiation budget)

They were "Please try again." (twice), "Please choose another." and
"You can start another ..." (twice). The lint does not read
internal/forms or internal/domains, so it never saw them; its reach is
unchanged. The sentences in workflow results and FedWiki's sync errors
are untouched.

The two "malformed body" rows of
internal/forms/testdata/parse-answers.golden carry the new sentence;
no other golden changes. The new TestMemberMessageWording pins the
three domain sentences word for word; no test pinned them before. It
failed at the parent and passes at this commit.

How it was shown to change no other output: the names and results of
./internal/forms, ./internal/domains, ./internal/server and
./internal/integrations/fedwiki/web (on freshly reset test databases)
are equal at the parent and at this commit apart from the added test,
which fails at the parent. Five breaks, each restoring one old
sentence, are caught: three by TestMemberMessageWording, two by
TestParseAnswers.

Not probed: that no screen shows these sentences. The new tests read
the strings and the parse golden; no browser run was taken for this
commit.
2026-10-11 21:47:51 -05:00
cgalo5758 09cf0ec540 Build a field's view in named steps
renderFieldAs keeps its signature and now calls fieldBase (what the
declaration and the family decide: the hidden label only in a bar or a
dense row, a checkbox's label above in stacked and dense, "(optional)"
never in a bar or a table, cells and footer only in a table, a
textarea's 3 rows, the column class and the small controls),
bindFieldValue (the bound value unless unbound, always for a Hidden
field, a static row's own text), the error, selectOptions or
radioOptions, and describeField (the ids under the control and the
described-by order: hint, notice, error, slot), then fieldAttrs as
before. Cells and the control footer are now set only in a table instead
of set and cleared outside one. The small-control rule is one
expression. fieldAttrs and the rest are unchanged.

The gocognit exclusion for renderFieldAs (34) is deleted. Measured with
the pinned golangci-lint, min-complexity 1: renderFieldAs 4, fieldBase
9, selectOptions 5, bindFieldValue 4, describeField 4. radioOptions
scores 1 and the linter at that threshold does not list it. fieldAttrs
stays 28.

No output changes. Shown by:
- ./internal/forms ./internal/server ./internal/integrations/fedwiki/web
  ./internal/integrations/discourse/web, app down, test databases reset
  before each side: 1756 test and subtest names with identical results
  at the parent and here, 0 failed, 0 skipped, including the 15
  TestFormPart tests. No golden file changed. No flaky test failed.
- The whole screens run (41 screens, 82 shots) at the parent and here,
  each side prepared right before it from the demo snapshot. Every shot
  is byte-equal except three. operator-integration-stripe at desktop
  and mobile is a page that shows read times. operator-plan-ladder-
  detail-open at desktop differs in 4 pixels by 1 in one colour channel
  (around x 264 to 266, y 579 to 608); a second run here gave the same 4
  pixels against the parent, and the -invalid shot of the same page
  differed in 6 pixels by 1 between those two runs here. These are
  edge-pixel differences between runs; the -invalid one is between two
  runs of the same code. The pixels were not inspected beyond their
  coordinates and values. The forms catalogue (forms.json) is
  byte-equal. The contact sheets differ in their heading's timestamp
  and in one status line each: the parent run marks operator-plan-
  ladder-detail-open at desktop as changed against the tool's reference
  (2x29 at 264,579) and the first run here marks it same; the second
  run here marks -invalid at desktop as changed (2x28 at 1574,509). The
  change lists differ only on that line. Not compared with the accepted
  baseline, because the snapshot was rebuilt since it was taken.
- render.go's string literals: the distinct set is equal; the empty
  string occurs 54 times where the parent has 55 (the footer is no
  longer reset to "").
- 14 break records (7 per side) on render.go: each caught on the old
  side at the parent and on the new side here, a dense checkbox's label
  position by TestFormPartCheckboxLabelSitsAboveTheBox and the rest by
  TestFormsHTML. The first-option and radio-id records are one per
  side, because the code moved.
- go vet, gofmt, project lint, golangci-lint (0 issues) and the lint
  snapshot (nothing added or removed) are clean.

Unprobed: the templates' own markup, because no section of this step
edits a template and the goldens hold their output; fieldAttrs,
buildRows, RowField and resolveRowsFocus, which this commit does not
move; the list search's declaration, built inside the function that
renders it, so no test reaches it without a production change and it
stays unchecked against the invariants; the goldens' check for stale
files, which guards the test's own files and which no production edit
reaches.
2026-10-11 17:01:12 -05:00
cgalo5758 2308be4727 Render a form through named steps
Render keeps its signature and now calls resolveSide (the method,
action, rendered kind, commit label and way out for the binding's side),
newFormView (the form id with its instance, the target, the family
booleans, the table headings' defaults, the preview at rest and the
confirm's message id), formHX (a search's push-url, select and
select-oob, nothing when boosted; otherwise the verb, target, swap,
disable and indicator) and renderFields (Rows focus, then each field the
side, the placement and ShowIf keep, through libraryRenders; a table
row cut or padded to one cell per column through padCells; autofocus on
the first refused field). resolveSide and newFormView carry doc
comments. renderFieldAs, boxFor, hxVerb, buildRows and the rest are
unchanged, and FormSpec.Bind stays the one-line alias.

The gocognit exclusion for Render (55) is deleted. Measured with the
pinned golangci-lint, min-complexity 1: Render 3, resolveSide 12,
renderFields 11, newFormView 7, formHX 4, libraryRenders 4. padCells
scores 1 and the linter at that threshold does not list it.
renderFieldAs stays 34.

No output changes. Shown by:
- ./internal/forms ./internal/server ./internal/integrations/fedwiki/web
  ./internal/integrations/discourse/web, app down, test databases reset
  before each side: 1756 test and subtest names with identical results
  at the parent and here, 0 failed, 0 skipped, including the 15
  TestFormPart tests. No golden file changed. No flaky test failed.
- The whole screens run (41 screens, 82 shots) at the parent and here,
  each side prepared right before it from the demo snapshot: every shot
  is byte-equal except operator-integration-stripe at desktop and
  mobile, a page that shows read times and differed between two runs of
  the same code earlier (the pixels were not inspected here); both
  sides' change lists are identical. The two contact sheets differ in
  their heading's timestamp only. Not compared with the accepted
  baseline, because the snapshot was rebuilt since it was taken.
- render.go's string literals: the distinct set is equal; the empty
  string occurs 55 times where the parent has 54 (formHX returns "" for
  a boosted search).
- 24 break records (12 per side) on render.go, each CAUGHT by
  TestFormsHTML on the old side at the parent and on the new side here.
  The table-row cut is one record per side, because the code moved.
- go vet, gofmt, project lint, golangci-lint (0 issues), deadcode, and
  the lint snapshot (nothing added or removed) are clean.

Not probed: the templates' own markup, which this commit does not touch
and the form goldens hold; fieldAttrs, buildRows, RowField and
resolveRowsFocus, which Render still calls unchanged and whose output
the goldens hold; the list search's declaration, built inside the
function that renders it, so no test reaches it without a production
change and it stays unchecked against the invariants; the goldens'
check for stale files, which guards the test's own files and which no
production edit reaches.
2026-10-11 17:01:12 -05:00
cgalo5758 e44488d8aa Read a form's fields through one step per kind of control
parseFields keeps its signature and now reads each submitted value
through readRaw, keeps the checkbox branch, and hands a select or radio
group to parseChoice and every other control to parseText. assignTyped
keeps its signature and calls parseNumber and parseDate, each returning
the value or the refusal. A malformed Min or Max is still no bound.

Each declared pattern is compiled once: patterns is a sync.Map keyed by
the pattern text, and patternMatches stores a pattern that does not
compile as nil, which refuses every value as before. That a pattern is
compiled only once is not probed: no answer changes with the cache, so a
break that compiled on every request again would pass every test, and
the cache is checked by reading. The map's size and the first-use race
(two requests may both compile before LoadOrStore keeps one) were not
probed either: only declarations supply a pattern and no production
declaration uses one, so the map stays empty in production, and both
compiled copies of a pattern are equivalent.

The two gocognit exclusions for parseFields (49) and assignTyped (33)
are deleted. Measured with the pinned golangci-lint, min-complexity 1:
parseFields 9, assignTyped 9, parseText 9, parseDate 6, parseChoice 5,
parseNumber 5, patternMatches 4, readRaw 2.

No output changes. Shown by:
- ./internal/forms ./internal/server ./internal/integrations/fedwiki/web
  ./internal/integrations/discourse/web, app down, test databases reset
  before each side: 1756 test and subtest names with identical results
  at the parent and here, 0 failed, 0 skipped. No golden file changed.
- The demo-state walkthroughs, each side prepared right before it from a
  rebuilt demo snapshot: 16 pass and 6 skip, names, results and skip
  messages equal at the parent and here.
- parse.go's string literals: the distinct set is equal apart from the
  import path "sync"; the empty string literal occurs 27 times where the
  parent has 23 (the new helpers return "" for no refusal).
- 14 break records (11 per side) on parse.go, each CAUGHT by
  TestParseAnswers on the old side at the parent and on the new side
  here. A malformed Min, an optional radio group and a pattern that
  does not compile are one record per side, because the code moved.
- go vet, gofmt, project lint, golangci-lint (0 issues), deadcode, and
  the lint snapshot (nothing added or removed) are clean.
2026-10-11 17:01:12 -05:00
cgalo5758 abb438bc71 Check a form declaration through named rule groups
CheckInvariants ran every rule of a form declaration in one function
that gocognit scored 139. It now keeps its signature and its problem
recorder and calls checkKindAndFamily, checkFamilyScope (which calls
checkRowsScope), checkRoutes and checkWayOut, then the no-fields rule,
then per field the no-name rule, checkFieldOrder, checkFieldStructure
(which calls checkFieldPlacement and checkFieldWidth) and
checkFieldCopy (which calls checkEscapeHatch and checkFieldDashes), and
finally checkFormDashes. Each takes the recorder as addFunc. Every
message and condition is unchanged and the rules run in the order they
did; the two checkbox conditions became one, and the Select-or-Radio
test is one local. The exclusion rule for CheckInvariants is deleted.
Measured scores: CheckInvariants 5, checkFieldStructure 17,
checkFamilyScope 13, checkFieldCopy 13, checkWayOut 8, checkRowsScope 7,
checkRoutes 7, checkFieldDashes 6, checkKindAndFamily 5, checkFieldWidth
5, checkEscapeHatch 4, checkFieldOrder 3, checkFieldPlacement 3,
checkFormDashes 3.

Three things differ from the parent, none visible in a registered form:
- The two dash checks range over ordered slices. Their maps gave a
  field's problems in map order, so the slices' order is one of the
  orders the maps could give, and the registry test's failures now come
  out in a fixed order. Each test is still strings.Contains(value, "—").
- The escape hatch's keys are checked in sorted order. A field with two
  refused keys gave its two problems in map order; they now come out in
  key order. No registered form carries a refused key.
- ownedAttrs and its message "the field %q escape hatch carries %q,
  which the part owns" are deleted. A key reached that branch only after
  the data-/hx- prefix test, and no key of the map has either prefix, so
  no input reached it; the prefix rule already refuses all of them. The
  escape hatch's comment says why the hatch is narrow.

The "steps wider than a half" branch of the width rule stays; no control
reaches it today.

scripts/deadcode-testonly.txt gains 13 entries for the new helpers,
which only CheckInvariants calls and only tests call CheckInvariants.
TestInvariantsEscapeHatchProblemsComeInKeyOrder is new: it checks, 20
times, that a field whose escape hatch carries style and class gets two
problems with the class one first.

No output change. Shown so:
- go test over ./internal/forms, ./internal/server and
  ./internal/integrations/... gives 2144 test names and results at the
  parent, 2145 here; the only difference is the new test. None fail,
  one is skipped on each side. No golden file changed, so the message
  golden, the HTML goldens and the parse goldens read the same text.
- The new test fails at the parent in 3 of 3 runs, because a two-key map
  ranges from the key written first most of the time, and passes here in
  20 runs of 20 iterations each.
- invariants.go's string literals are equal apart from the 21 attribute
  names and the message of the deleted map, and the import paths "maps"
  and "slices".
- 20 break records (16 on the new side, 15 on the old; four dash and
  way-out records are written once per side because their text moved,
  and the sorted-keys record is new side only) are all caught by the
  existing message golden, and the sorted-keys one by the new test.
- go run . lint prints the same line (91 routes, 53 URL refs), the lint
  snapshot gains and loses nothing, and scripts/deadcode.sh is clean.

Not probed: the 46 messages' words beyond what the equal literal set
shows; the deleted branch, which no input reached.
2026-10-11 17:01:12 -05:00
cgalo5758 46a7cbbf11 Characterize what Parse answers for each control
TestParseAnswers posts 38 one-field forms (a row with a ShowIf adds the
field it reads) and one batch whose body cannot be read, and prints for
each row the form error, the refusals by key in sorted order, and the
raw value, presence and typed answers (bool, int, time, UUID). The text
is compared through golden.Compare with testdata/parse-answers.golden
(99 lines).

The rows cover date and datetime ranges, the datetime-local layout, a
Min or Max that does not parse (read as no bound), a pattern that does
not compile, the "expected format" fallback, a UUID typed from a select
and from a hidden field, "an" before a vowel, both "could not be read"
form errors, MaxLen counted in runes, the optional and the required
radio, a deliberate empty option, a checkbox with another value, a
repeated name and a pattern read twice.

Shown to change no output: only a test file and a golden are added. The
names and results of ./internal/forms ./internal/server
./internal/integrations/... (2112 at the parent and 2113 at the commit,
reset test databases before each run) differ only by the new
TestParseAnswers. The golden is byte-equal to the output of the
stand-alone program that prints the same rows from the unchanged
library. The new test passes five runs in a row.

11 breaks, each edited once in internal/forms/parse.go and each caught
by the new test, which fails with the golden differing from what the
code produced. The old side has no counterpart: every break is a
new-side record, since no old test names this one. None dropped.

Pinned defects, so a later fix flips them by name: both "could not be
read" sentences (the form's and the batch's) carry "Please", a filler
phrase the copy rule refuses; the lint does not read internal/forms.

Not probed: the rows parse_test.go already pins (required and trimmed
text, optional empty text, MaxLen, number range and "whole number",
select outside the options and empty, optional and required radio,
runtime options, checkbox absence, repeated names, the pattern's hint,
date parsing and "not a valid date", ShowIf). The breaks probe mostly
rows only this test pins; one break per pinned row would repeat a kind
already measured. The break that lets an optional radio group stay
empty is also caught by TestParseRequiredRadioNeedsAChoice. Four rows
only this test pins have no break: the "later than this form allows"
message, a malformed Max, a date bound that does not parse, and a
checkbox submitted with a value other than its own; each is the same
kind as a probed literal or bound.
2026-10-11 15:55:41 -05:00
cgalo5758 08e22939d7 Table every message the form invariants give
TestInvariantMessages runs CheckInvariants over 73 declarations, each
one of four well-formed bases (stacked, batch, table, dense) with one
change, and prints every case's problems whole, in the order
CheckInvariants returns them. The text is compared through
golden.Compare with testdata/invariant-messages.golden (156 lines, 59
distinct messages).

The cases cover 59 of the 61 messages, the four bases and a confirm
form with no fields (no problem), the inputs on which the registry and
the lint read an em dash differently ("1 — 2" and a lone "—", both
refused here), the strings the registry does not check today (a column
heading, a static row's Value, a filler phrase in a hint: no problem),
and one declaration with a problem in each rule group, whose eight
problems come out in the groups' order. The two dashed strings of one
field come out in either order at this commit, because the dash checks
range over a map, so that case sorts its problems and says why.

Deletes internal/forms' TestRegisteredFormsSatisfyTheInvariants: no form
registers in that test binary, so it checked nothing. The test of the
same name in internal/server, which runs over every registered form,
stays.

Shown to change no output: only test files and a golden change. The
names and results of ./internal/forms ./internal/server
./internal/integrations/... (2112 results each), taken with the test
database reset and the app down at the parent and at this commit, are
equal apart from the new TestInvariantMessages and the deleted
TestRegisteredFormsSatisfyTheInvariants in internal/forms. The new test
passes five runs in a row.

15 breaks, each edited once in internal/forms/invariants.go and each
caught by the new test, which fails with the golden differing from what
the code produced. The old side has no counterpart: every break is a
new-side record, since no old test names this one. None dropped.

Pinned defects, so a later fix flips them by name: the registry refuses
any em dash where the lint exempts a lone marker and a dash with no
letter beside it; the registry does not check a column heading, a static
row's Value or a filler phrase; the hint cap counts bytes and its
message prints them as characters (no row is non-ASCII, so the golden
shows the count only); two dashed strings in one field come out in
either order.

Not probed: the words of the 46 messages no break changes, since each is
a literal the golden prints whole and a break per message would repeat
the reworded-message breaks' kind; and two messages no declaration
reaches, "escape hatch carries %q, which the part owns" (a key reaches
it only after the data-/hx- prefix test, and no owned key has either
prefix) and "steps wider than a half, which would reach a full row" (the
ladder refuses a wider step only from the half rung, and no control
stands on it today).
2026-10-11 15:55:41 -05:00
cgalo5758 dc685004dd Delete unused helpers and move splitRuleRows into its test 2026-10-07 00:03:01 -05:00
cgalo5758 60fbdde6fd Apply staticcheck's simplifications in production code 2026-10-06 23:15:31 -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 27fb02fbfa Show members who bills them before they pay, and archive the change
Stripe Checkout, receipts and the card statement carry the Stripe
account's public business name and statement descriptor, which Stripe
fills from the registered legal name until they are set. Where that
name differs from the deployment's, a member first meets it above the
card fields (#13). Each install has to notice this on its own, since a
fix in one account's settings reaches no other deployment.

- A Temporal workflow reads the public details at every boot and on
  request, with whatever permission the key holds: the account itself
  with Accounts Read, otherwise an invoice preview and the latest
  charge. The result is a JSON instance setting; the account id is
  never stored.
- The Stripe provider page gains a Public details section. Its Read
  again control, and the Environment check's Check now, move into the
  section header as a refresh action.
- The Products page and the checkout return name who bills the member
  and what the card statement says, only when a name would not be
  recognised against deployment-name. Every control that costs money,
  the switch preview's commit included, references the line through
  FormView.CommitDescribedBy.
- docs/stripe.md gains "What members see"; production-deployment.md
  points to it. golang.org/x/text becomes a direct dependency for the
  name comparison.

OpenSpec change merchant-disclosure is archived and its six deltas
synced into openspec/specs/.
2026-09-28 18:39:41 -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 a2acd7fd32 Render a hidden field's bound value in an unbound form
An unbound render decides a ShowIf from the handler's bound marker but
left the hidden marker itself empty. The form then submitted an empty
marker, and the parse dropped the field the render had shown: the site
create form's custom-domain box was shown and then silently ignored.

A Hidden field now carries its bound value in every mode, since it
holds what the handler derived, never a person's input. Visible fields
of an unbound render stay empty.
2026-09-28 18:34:03 -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 782ca8f326 Derive Stripe mode from API key and refine disabled controls
Derive Stripe test/live mode from the API key prefix at boot, failing on
unrecognized prefixes, and drop the separate `stripe-mode` config key.

Refine disabled controls to render through the shared `disabledControl`
part with the not-allowed cursor, and add a lint rule refusing
hand-rolled disabled buttons.

Adjust plan cards to offer no purchase control on free rungs, fix bound
checkbox Bool handling, and rename "Public/Private" to "Listed/Unlisted"
with enhanced readiness verdicts.
2026-09-13 16:59:12 -05:00
cgalo5758 12f1d3fc00 Fix the four Slice 3 walk findings and page every operator list
Archives openspec change slice3-walk-fixes and syncs its five delta
specs (fedwiki-sites, entitlements, operator-panel-navigation,
operator-list-scale, ui-quality-gate).

- FedWiki site usage is read from active site rows in both quota
  readers; the reservation counter converges on the rows: raise-only
  after farm sync and inside the create quota check, exact at boot.
  The understated production counters repair on the first boot.
- The People tile caption excludes the reserved system person through
  the same query parameter the directory uses.
- The operator Domains live-claims list is a governed list: pages of
  50, true total, search over root name and organization, a
  pending/active facet.
- New lint rule table-without-list-controls refuses an unpaged
  page-body table unless it carries a list-scale exempt marker with a
  reason; six curated or detail tables carry one. Its first run caught
  the operator FedWiki sites list, which is now governed the same way.
- Entitlement-set rule copy: "Per unit", "Multiplied by the quantity
  purchased or granted."
2026-09-12 01:16:17 -05:00
cgalo5758 88db730fcc Add dual licensing and SPDX headers
Introduce a commercial license option alongside AGPL-3.0-only, require a
CLA for contributors, and document the terms in COMMERCIAL.md and
NOTICE. Add a script to stamp SPDX headers on Go files and apply it
across the tree.
2026-09-06 02:29:42 -05:00
cgalo5758 d47c47ef0f Make control type decide dense form width
Width becomes a one-rung step from the control's natural fraction,
and invariants bound the ladder. The grant's free text is renamed
to GrantNote, and both grant ledgers show it as a muted note line.
2026-09-06 01:26:08 -05:00
cgalo5758 e581bf81c3 Add typed config keys and config CLI
Parse every config value through a single declared type at save, boot,
and validate, and expose the settings seam from the terminal so
operators can list, set, clear, and validate overrides without SQL.
2026-09-05 17:47:04 -05:00
cgalo5758 9ff516ba95 Introduce the forms library and migrate all forms
Every form on both surfaces is now a declared FormSpec in
internal/forms, rendered through the shared form part and parsed
by its handler through the same declaration. Create and edit
share one field list, refusals answer 422 with values carried
back, and layout, buttons and errors come from one part.

Also adds the form registry with its invariant and route-mapping
tests, the raw-form, filler-copy and Go em-dash lint rules,
declared action triggers, and the capture-coverage cross-check.
The forms-library change is archived under
openspec/changes/archive/2026-09-05-forms-library.
2026-09-05 03:16:45 -05:00