Fourth section of #269's change. Every act that can change who holds a ladder now writes one reconcile_subscription outbox entry per live subscription of the organization (active, trialing, past_due or unpaid, in id order), in the act's own transaction, so each subscription's collection pause is decided again after the act commits. The entry is an insert and takes no lock. The acts: issuing a grant (entitlements.IssueGrant, which the issue handler now calls in place of ConferGrant), managing, revoking and expiring one, the legacy grant a default change confers, and removing a tier from a ladder. ConferGrantTx itself requests nothing, because the conferral and provisioning paths that call it run before the subscription exists or reconcile it themselves. In the reconcile, the ended branch and the surplus end request the organization's other subscriptions, excepting the one being reconciled. The screens utility waits until the application database holds no pending or failed outbox row before its first capture, because the demo seed's revocation and expiry now leave two entries the running app's relay completes on its own schedule. docs/stripe.md rewrites the grant over a paid plan: the console pauses the subscription's collection while the grant holds, and the costs that pause accepts are listed. docs/models/plan-ladders-transitions.md, docs/plan-management.md, docs/models/payments-billing.md and docs/database-locks.md say the same. Tests: TestActsRequestSubscriptionReconciles (six acts, two live subscriptions and a canceled one, ids and creation order differing), TestConferGrantTxRequestsNothing, TestReconcileEndedRequestsTheOtherSubscriptions, TestSurplusEndRequestsTheOtherSubscriptions, TestIssueGrantHandlerRequestsAReconcile, and the demo seed's entries. Seven trace goldens gain the ListLiveSubscriptionsOfPool read. Review: no blocker or major; six minors and six nits applied (a step name for the tier removal's request error, the fixture's id order, doc comments and the docs that still said a grant over a paid plan keeps billing). Breaks: 17 of 17 caught. Ignoring the exception is caught only through the surplus end: in the ended branch the excepted subscription is already canceled, so the live read never lists it. Full suite on reset databases: 4768 pass, 14 skip. make screens: no change beyond the three captures every run since section 2 redraws.
Server Package
This package implements the HTTP server for the member-console application, including HTMX partial handlers.
Overview
The server uses Go's standard net/http package with:
- Bootstrap 5 for styling
- HTMX 2.0 for dynamic content updates
- gorilla/csrf for CSRF protection
- scs (
alexedwards/scs) for server-side session management (Valkey/Redis-backed)
Architecture
The HTTP server handles several types of requests:
- Static Files (
/static/*) - CSS, JS, images served from embedded assets - Page Handlers (
/) - Full HTML page rendering - Partial Handlers (
/partials/*) - HTMX partial HTML responses - Auth Handlers (
/login,/logout,/callback) - OAuth2/OIDC authentication
Partial handlers interact with Temporal workflows for async operations like site provisioning.
Files
server.go- Main server setup, middleware chain, route registrationmember_plans.go,member_plan_moves.go,member_entitlements.go,member_addons.go,member_invoices.go,workspace_partials.go,operator*.go- HTMX partial handlers for this package's own featuresprice_text.go- price text shared by the member and operator pages
Integration-owned handlers (e.g. FedWiki's member-facing API and partials)
live in their own tree under internal/integrations/<key>/web instead of
here — see docs/building-an-integration.md and
internal/integrations/fedwiki/web for the worked example. The patterns
below apply equally to handlers in either location.
HTMX Integration
Partial Handler Pattern
Partial handlers return HTML fragments (not full pages) that HTMX swaps into the DOM:
// Handler returns HTML fragment
func (h *Handler) GetItems(w http.ResponseWriter, r *http.Request) {
// ... fetch data ...
h.Templates.Render(w, "items.html", data)
}
<!-- Template returns partial HTML -->
<div class="table-responsive">
<table class="table">
{{ range .Sites }}
<tr>...</tr>
{{ end }}
</table>
</div>
HTMX Triggers
Use HX-Trigger header to trigger events that refresh other parts of the page:
// After successful operation, trigger refresh
w.Header().Set("HX-Trigger", `{"refreshSites": true}`)
<!-- Element listens for the trigger -->
<div hx-get="/partials/items"
hx-trigger="load, refreshItems from:body">
</div>
Cross-origin protection with HTMX
There is no CSRF token. internal/middleware/csrf.go wraps net/http's
CrossOriginProtection, which refuses a non-safe request whose
Sec-Fetch-Site (or, failing that, Origin) says it came from another
site. An htmx request from one of the console's own pages is same-origin
and passes with nothing added; a refusal is a 403 whose body names the
CSRF check, which error-handler.js reads to keep the person's input.
Workflow Execution
Handlers that start a Temporal workflow for a member's request answer when the start is confirmed, not when the workflow finishes. An integration records the request first (FedWiki's site lifecycle records a row in core.lifecycle_requests), starts the workflow under the identifier stored on that row, and returns a fragment or an HX-Trigger refresh; the workflow records its own outcome, and the list that shows the request re-renders until the outcome lands. A handler never waits for a workflow to finish: one that reads a result (the Stripe environment check) waits on WorkflowRun.Get under a context of a few seconds and answers that the check started when it runs out, because a workflow can outlast the server's write timeout. FedWiki starts a recorded request's workflow detached from the member's request, so a closed connection cannot leave the request with no workflow.
Route Organization
Routes are organized by feature using handler structs:
// ItemPartialsHandler groups related routes
type ItemPartialsHandler struct { ... }
func (h *ItemPartialsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /partials/items", h.GetItems)
mux.HandleFunc("POST /partials/items", h.CreateItem)
mux.HandleFunc("DELETE /partials/items/{id}", h.DeleteItem)
// ...
}
Error Rendering
Errors are rendered back into the same partial/modal that initiated the request:
func (h *Handler) renderCreateFormError(w http.ResponseWriter, userID int64, errMsg string) {
// Re-fetch form data
data := h.getCreateFormData(userID)
data.Error = errMsg
// Re-render the form partial with error displayed
h.Templates.Render(w, "create_form.html", data)
}
This keeps the user in context - they see the error in the same modal/form they were using.
Middleware Stack
handler = middleware.Logging(logger)(handler) // Request logging
handler = middleware.Recover(logger)(handler) // Panic recovery
handler = middleware.Compress()(handler) // Gzip compression
handler = middleware.SecurityHeaders()(handler) // Security headers
handler = middleware.RequestID()(handler) // Request ID generation
handler = csrf.Protect(key, opts...)(handler) // CSRF protection