Set the Stripe key once at boot and read the boot mode everywhere

First section of #141. start assigns the stripe-go key through UseKey,
directly after it derives the key's mode and fingerprint and before any
goroutine starts; the Stripe integration's Startup no longer assigns it,
and nothing else does. UseKey assigns the trimmed key, so the SDK sends
the key whose mode and fingerprint boot derived, and an empty key leaves
the SDK's key as it was.

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

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

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

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

Full suite on reset databases: 4373 pass, 15 skip, none failed. make lint passes. Breaks,
each caught:
- UseKey assigns an empty key
- Startup assigns stripego.Key from the configuration again
- the checkout handler compares against an empty mode
- SwitchPlan reads an empty mode
- SweepDueScheduledChanges passes an empty mode to the change it fires
- UseKey assigns the untrimmed key
- the router hands Checkout an empty mode
This commit is contained in:
2026-10-23 13:39:27 -04:00
parent bdd042bc51
commit 29520a8120
50 changed files with 1792 additions and 279 deletions
+6 -2
View File
@@ -160,6 +160,11 @@ var startCmd = &cobra.Command{
// under, and this is what the next boot compares against that
// record to learn the key moved (stripe-environment-stamp D3).
stripeKeyFingerprint := stripeintegration.FingerprintForKey(viper.GetString("stripe-api-key"))
// The Stripe SDK's key is set here, once, after the mode and the
// fingerprint are derived and before any goroutine starts, so the
// worker's activities, Checkout and the plan moves read one key that
// no later write races; nothing assigns it again.
stripeintegration.UseKey(viper.GetString("stripe-api-key"))
// Database Setup
database, closeDB, err := openDB(ctx, logger, dbRuntime)
@@ -315,7 +320,7 @@ var startCmd = &cobra.Command{
workerCfg := workflows.DefaultWorkerConfig(database, logger)
workerCfg.WorkflowProviders = workflowProviders
workerCfg.DomainsConnectTarget = domainsConnectTarget
workerCfg.StripeKeyMode = stripeintegration.SDKKeyMode
workerCfg.StripeMode = stripeMode
// No transport serves a connection until integration-contract-wire
// adds one: records are computed and stored, nothing is pushed.
workerCfg.Transports = integration.NoTransports{}
@@ -513,7 +518,6 @@ var startCmd = &cobra.Command{
StripeAPIKey: viper.GetString("stripe-api-key"),
StripeDashboardURL: stripeDashboardURL,
StripeMode: stripeMode,
StripeKeyMode: stripeintegration.SDKKeyMode,
StripeKeyFingerprint: stripeKeyFingerprint,
BaseURL: viper.GetString("base-url"),
AskFallbackURL: viper.GetString("domains-ask-fallback-url"),
+2 -2
View File
@@ -181,8 +181,8 @@ deterministically and offline, with **no live Stripe account**.
`m.OnCall = func(c stripetest.Call) { stmts.Note(stripetest.Line(c)) }` puts
each Stripe call among the statements of a `dbtest.Counted` handle.
`stripetest.KeyMode` answers the mode of the key the SDK holds when it is
called (`"test"` under `Install`); a test passes it where the console passes
its configured key mode.
called (`"test"` under `Install`); a test passes its result,
`stripetest.KeyMode()`, where the console passes the mode boot derived.
- **Cover:** `fulfillment.ReconcileSubscription` scenarios (empty-payload
provisioning, idempotent replay, ordering, cancellation, multi-ladder guard)
and the webhook→`plan-transitions` end-to-end tests.
@@ -79,7 +79,7 @@ func TestReconcileSkipsUnreachableSubscription(t *testing.T) {
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:setup"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
core := coreSubID(t, database, fx.stripeSubID)
@@ -89,7 +89,7 @@ func TestReconcileSkipsUnreachableSubscription(t *testing.T) {
// Any Stripe call from here is a guard that did not fire.
mock.Err = errors.New("stripetest: reconcile must not call stripe")
err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:guard")
err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:guard")
if tc.wantSkip {
if err != nil {
t.Fatalf("a skipped reconcile must change nothing and report nothing, got: %v", err)
@@ -135,7 +135,7 @@ func TestSwitchPlanRefusesUnreachableMapping(t *testing.T) {
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:setup"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
prod2, corePrice2, stripePrice2 := secondTier(t, database, fx)
@@ -147,7 +147,7 @@ func TestSwitchPlanRefusesUnreachableMapping(t *testing.T) {
stampSubscriptionMapping(t, database, fx.stripeSubID, tc.livemode, tc.syncStatus)
}
err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, corePrice2, time.Now(), "member:switch")
err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, corePrice2, time.Now(), "member:switch")
if !tc.wantRefuse {
if err != nil {
t.Fatalf("an unverified mapping must switch as it does today, got: %v", err)
@@ -178,7 +178,7 @@ func TestSweepSkipsWithoutClaimingTheRow(t *testing.T) {
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:setup"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
core := coreSubID(t, database, fx.stripeSubID)
@@ -194,7 +194,7 @@ func TestSweepSkipsWithoutClaimingTheRow(t *testing.T) {
stampSubscriptionMapping(t, database, fx.stripeSubID, recordedLive, "synced")
mock.Err = errors.New("stripetest: the sweeper must not call stripe")
if _, err := fulfillment.SweepDueScheduledChanges(ctx, database, discardLogger(), stripetest.KeyMode); err != nil {
if _, err := fulfillment.SweepDueScheduledChanges(ctx, database, discardLogger(), stripetest.KeyMode()); err != nil {
t.Fatalf("sweep: %v", err)
}
if n := scalarInt(t, database,
@@ -214,7 +214,7 @@ func TestSweepSkipsWithoutClaimingTheRow(t *testing.T) {
canceled := fx.activeSub(fx.planItem())
canceled.Status = stripe.SubscriptionStatusCanceled
mock.Err, mock.Sub = nil, canceled
if _, err := fulfillment.SweepDueScheduledChanges(ctx, database, discardLogger(), stripetest.KeyMode); err != nil {
if _, err := fulfillment.SweepDueScheduledChanges(ctx, database, discardLogger(), stripetest.KeyMode()); err != nil {
t.Fatalf("second sweep: %v", err)
}
if n := scalarInt(t, database,
@@ -73,7 +73,7 @@ func withStatus(s *stripe.Subscription, status stripe.SubscriptionStatus) *strip
func reconcile(t *testing.T, database *sql.DB, stripeSub, reason string) {
t.Helper()
if err := fulfillment.ReconcileSubscription(context.Background(), database, discardLogger(), stripetest.KeyMode, stripeSub, reason); err != nil {
if err := fulfillment.ReconcileSubscription(context.Background(), database, discardLogger(), stripetest.KeyMode(), stripeSub, reason); err != nil {
t.Fatalf("reconcile %s (%s): %v", stripeSub, reason, err)
}
}
@@ -377,7 +377,7 @@ func TestReconcile_ReRunsAfterABlockedEndingResumeNothing(t *testing.T) {
reconcile(t, database, fx.stripeSubID, "stripe:subscription.deleted:replay")
mock.List = []*stripe.Subscription{subs[fx.stripeSubID]}
if err := fulfillment.ReconcileLatestSubscriptionForCustomer(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeCustomerID, "eager:checkout-return"); err != nil {
if err := fulfillment.ReconcileLatestSubscriptionForCustomer(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeCustomerID, "eager:checkout-return"); err != nil {
t.Fatalf("checkout return: %v", err)
}
// The invoice reconcile reconciles the invoice's subscription with this
+19 -21
View File
@@ -149,10 +149,10 @@ func logUnreachableMapping(logger *slog.Logger, msg, rowID, stripeID string, liv
// the figure shown. A downgrade takes effect at renewal through a Stripe
// subscription schedule, unless a live commitment with
// early_termination_policy='block' defers it to the commitment boundary.
// Upgrades are never gated by a commitment. stripeKeyMode answers the mode of
// the API key the Stripe SDK calls with; it is asked before the Stripe call
// and again by the reconcile that follows it, and must not be nil.
func SwitchPlan(ctx context.Context, db *sql.DB, logger *slog.Logger, stripeKeyMode func() string, orgID, ladderID, targetPriceID string, prorationDate time.Time, reason string) error {
// Upgrades are never gated by a commitment. keyMode is the mode of the key
// the process runs under; it is checked before the Stripe call and handed to
// the reconcile that follows it.
func SwitchPlan(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, orgID, ladderID, targetPriceID string, prorationDate time.Time, reason string) error {
if logger == nil {
logger = slog.Default()
}
@@ -184,7 +184,6 @@ func SwitchPlan(ctx context.Context, db *sql.DB, logger *slog.Logger, stripeKeyM
// the subscription, or the item on it (stripe-environment-stamp D5).
// Nothing has changed at this point, so the refusal leaves the
// subscription exactly as it was.
keyMode := stripeKeyMode()
for _, m := range []struct {
rowID, stripeID string
livemode sql.NullBool
@@ -208,7 +207,7 @@ func SwitchPlan(ctx context.Context, db *sql.DB, logger *slog.Logger, stripeKeyM
return err
}
if !down {
return upgradeNow(ctx, db, logger, stripeKeyMode, billingQ, ls, priceMapping.StripePriceID.String, prorationDate, reason)
return upgradeNow(ctx, db, logger, keyMode, billingQ, ls, priceMapping.StripePriceID.String, prorationDate, reason)
}
deferToBoundary, boundary, derr := commitmentDisposition(ls.sub)
@@ -235,7 +234,7 @@ func SwitchPlan(ctx context.Context, db *sql.DB, logger *slog.Logger, stripeKeyM
slog.String("subscription_id", ls.coreSubID), slog.Time("boundary", boundary))
return nil
}
return downgradeAtRenewal(ctx, db, logger, stripeKeyMode, billingQ, ls, targetPriceID, priceMapping.StripePriceID.String, reason)
return downgradeAtRenewal(ctx, db, logger, keyMode, billingQ, ls, targetPriceID, priceMapping.StripePriceID.String, reason)
}
// upgradeNow sends an upgrade as a pending update invoiced at once
@@ -244,7 +243,7 @@ func SwitchPlan(ctx context.Context, db *sql.DB, logger *slog.Logger, stripeKeyM
// card leaves the subscription, the member's tier and every scheduled change
// as they were; a payment needing the cardholder's approval returns the
// invoice's hosted page. Only an applied upgrade drops a scheduled move.
func upgradeNow(ctx context.Context, db *sql.DB, logger *slog.Logger, stripeKeyMode func() string, billingQ *billing.Queries, ls ladderSubscription, targetStripePriceID string, prorationDate time.Time, reason string) error {
func upgradeNow(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, billingQ *billing.Queries, ls ladderSubscription, targetStripePriceID string, prorationDate time.Time, reason string) error {
now := time.Now()
if prorationDate.IsZero() || prorationDate.After(now) || now.Sub(prorationDate) > PreviewValidFor {
return ErrStalePreview
@@ -327,7 +326,7 @@ func upgradeNow(ctx context.Context, db *sql.DB, logger *slog.Logger, stripeKeyM
}
// Converge: reconcile updates the core item in place and supersedes the
// ladder attachment to the new tier via the transition primitive.
return ReconcileSubscription(ctx, db, logger, stripeKeyMode, ls.stripeSubID, reason)
return ReconcileSubscription(ctx, db, logger, keyMode, ls.stripeSubID, reason)
}
// downgradeAtRenewal attaches a two-phase subscription schedule
@@ -336,7 +335,7 @@ func upgradeNow(ctx context.Context, db *sql.DB, logger *slog.Logger, stripeKeyM
// and reconcile moves the tier when Stripe enters the second phase. A
// cancellation armed at period end is cleared first, so the downgrade
// replaces it; an existing schedule (an earlier downgrade) is rewritten.
func downgradeAtRenewal(ctx context.Context, db *sql.DB, logger *slog.Logger, stripeKeyMode func() string, billingQ *billing.Queries, ls ladderSubscription, targetPriceID, targetStripePriceID, reason string) error {
func downgradeAtRenewal(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, billingQ *billing.Queries, ls ladderSubscription, targetPriceID, targetStripePriceID, reason string) error {
current, err := subscription.Get(ls.stripeSubID, nil)
if err != nil {
return fmt.Errorf("switch: read subscription before downgrade: %w", err)
@@ -412,7 +411,7 @@ func downgradeAtRenewal(ctx context.Context, db *sql.DB, logger *slog.Logger, st
}
logger.Info("switch: downgrade scheduled for renewal",
slog.String("subscription_id", ls.coreSubID), slog.Time("effective_at", time.Unix(current.CurrentPeriodEnd, 0)))
return ReconcileSubscription(ctx, db, logger, stripeKeyMode, ls.stripeSubID, reason)
return ReconcileSubscription(ctx, db, logger, keyMode, ls.stripeSubID, reason)
}
// KeepCurrentPlan drops whatever move is scheduled on the member's active
@@ -420,9 +419,9 @@ func downgradeAtRenewal(ctx context.Context, db *sql.DB, logger *slog.Logger, st
// downgrade schedule the console attached, clears a cancellation armed at
// period end, supersedes the active scheduled changes, and reconciles.
// Nothing is charged. With nothing scheduled it makes no Stripe call.
// stripeKeyMode is handed to the reconcile, which asks it; it must not be nil
// when the move reaches the reconcile.
func KeepCurrentPlan(ctx context.Context, db *sql.DB, logger *slog.Logger, stripeKeyMode func() string, orgID, ladderID, reason string) error {
// keyMode is the mode of the key the process runs under; it is handed to the
// reconcile.
func KeepCurrentPlan(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, orgID, ladderID, reason string) error {
if logger == nil {
logger = slog.Default()
}
@@ -456,7 +455,7 @@ func KeepCurrentPlan(ctx context.Context, db *sql.DB, logger *slog.Logger, strip
if err := billingQ.SupersedeActiveScheduledChanges(ctx, ls.coreSubID); err != nil {
return fmt.Errorf("keep: supersede scheduled changes: %w", err)
}
return ReconcileSubscription(ctx, db, logger, stripeKeyMode, ls.stripeSubID, reason)
return ReconcileSubscription(ctx, db, logger, keyMode, ls.stripeSubID, reason)
}
// SwitchPreview is what a paid→paid switch costs and when
@@ -550,10 +549,9 @@ func PreviewSwitch(ctx context.Context, db *sql.DB, logger *slog.Logger, orgID,
// CancelSubscription cancels the member's active subscription on ladderID. timing
// selects immediate vs period-end (default period-end). A committed cancellation
// under 'block' defers to the commitment boundary; under 'fee' it is refused;
// under 'allow'/evergreen it follows the requested timing. stripeKeyMode is
// handed to the reconcile, which asks it; it must not be nil when the move
// reaches the reconcile.
func CancelSubscription(ctx context.Context, db *sql.DB, logger *slog.Logger, stripeKeyMode func() string, orgID, ladderID string, timing CancelTiming, reason string) error {
// under 'allow'/evergreen it follows the requested timing. keyMode is the
// mode of the key the process runs under; it is handed to the reconcile.
func CancelSubscription(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, orgID, ladderID string, timing CancelTiming, reason string) error {
if logger == nil {
logger = slog.Default()
}
@@ -595,7 +593,7 @@ func CancelSubscription(ctx context.Context, db *sql.DB, logger *slog.Logger, st
if err := billingQ.SupersedeActiveScheduledChanges(ctx, ls.coreSubID); err != nil {
return fmt.Errorf("cancel: supersede prior scheduled changes: %w", err)
}
return ReconcileSubscription(ctx, db, logger, stripeKeyMode, ls.stripeSubID, reason)
return ReconcileSubscription(ctx, db, logger, keyMode, ls.stripeSubID, reason)
default: // CancelAtPeriodEnd
if !ls.sub.CurrentPeriodEnd.Valid {
@@ -615,7 +613,7 @@ func CancelSubscription(ctx context.Context, db *sql.DB, logger *slog.Logger, st
}
// Reconcile syncs the local cancel_at_period_end flag; status stays active
// until the period closes, when the firing path ends the provision.
return ReconcileSubscription(ctx, db, logger, stripeKeyMode, ls.stripeSubID, reason)
return ReconcileSubscription(ctx, db, logger, keyMode, ls.stripeSubID, reason)
}
}
@@ -35,13 +35,13 @@ func TestPlanChangeRefusesAnOrganizationWithoutAPool(t *testing.T) {
}
t.Run("a switch", func(t *testing.T) {
err := fulfillment.SwitchPlan(ctx, db, nil, stripetest.KeyMode, org.OrgID, ladder.ID, price.ID, time.Time{}, "test")
err := fulfillment.SwitchPlan(ctx, db, nil, stripetest.KeyMode(), org.OrgID, ladder.ID, price.ID, time.Time{}, "test")
if !errors.Is(err, fulfillment.ErrNoActivePaidSubscription) {
t.Errorf("SwitchPlan = %v, want ErrNoActivePaidSubscription", err)
}
})
t.Run("a cancellation", func(t *testing.T) {
err := fulfillment.CancelSubscription(ctx, db, nil, stripetest.KeyMode, org.OrgID, ladder.ID, fulfillment.CancelAtPeriodEnd, "test")
err := fulfillment.CancelSubscription(ctx, db, nil, stripetest.KeyMode(), org.OrgID, ladder.ID, fulfillment.CancelAtPeriodEnd, "test")
if !errors.Is(err, fulfillment.ErrNoActivePaidSubscription) {
t.Errorf("CancelSubscription = %v, want ErrNoActivePaidSubscription", err)
}
+37 -37
View File
@@ -121,7 +121,7 @@ func onTier2(t *testing.T) (*sql.DB, fixture, *stripetest.MockBackend, string, s
mock := &stripetest.MockBackend{Sub: onTier(fx, stripePrice2)}
t.Cleanup(stripetest.Install(mock))
stripeLike(mock)
if err := fulfillment.ReconcileSubscription(context.Background(), database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:setup"); err != nil {
if err := fulfillment.ReconcileSubscription(context.Background(), database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
return database, fx, mock, prod2, corePrice2, stripePrice2
@@ -136,7 +136,7 @@ func onTier1(t *testing.T) (*sql.DB, fixture, *stripetest.MockBackend, string, s
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
stripeLike(mock)
if err := fulfillment.ReconcileSubscription(context.Background(), database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:setup"); err != nil {
if err := fulfillment.ReconcileSubscription(context.Background(), database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
prod2, corePrice2, stripePrice2 := secondTier(t, database, fx)
@@ -150,7 +150,7 @@ func TestUpgradeIsAPendingUpdateAtThePreviewMoment(t *testing.T) {
mock.Sub = onTier(fx, stripePrice2) // what Stripe holds once the invoice is paid
moment := time.Now().Add(-10 * time.Minute).Truncate(time.Second)
if err := fulfillment.SwitchPlan(context.Background(), database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, corePrice2, moment, "member:switch"); err != nil {
if err := fulfillment.SwitchPlan(context.Background(), database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, corePrice2, moment, "member:switch"); err != nil {
t.Fatalf("upgrade: %v", err)
}
var upd *stripetest.Call
@@ -191,7 +191,7 @@ func TestStalePreviewChargesNothing(t *testing.T) {
} {
t.Run(name, func(t *testing.T) {
database, fx, mock, prod2, corePrice2, _ := onTier1(t)
err := fulfillment.SwitchPlan(context.Background(), database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, corePrice2, moment, "member:switch")
err := fulfillment.SwitchPlan(context.Background(), database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, corePrice2, moment, "member:switch")
if !errors.Is(err, fulfillment.ErrStalePreview) {
t.Fatalf("err = %v, want ErrStalePreview", err)
}
@@ -221,7 +221,7 @@ func pendingSub(fx fixture, invoiceID string, piStatus stripe.PaymentIntentStatu
func TestDeclinedUpgradeChangesNothing(t *testing.T) {
database, fx, mock, prod2, corePrice2, _ := onTier1(t)
ctx := context.Background()
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel"); err != nil {
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel"); err != nil {
t.Fatalf("arm cancellation: %v", err)
}
core := coreSubID(t, database, fx.stripeSubID)
@@ -229,7 +229,7 @@ func TestDeclinedUpgradeChangesNothing(t *testing.T) {
declined := pendingSub(fx, "in_declined", stripe.PaymentIntentStatusRequiresPaymentMethod)
declined.CancelAtPeriodEnd = true
mock.UpdateSub = declined
err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, corePrice2, time.Now(), "member:switch")
err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, corePrice2, time.Now(), "member:switch")
if !errors.Is(err, fulfillment.ErrUpgradeDeclined) {
t.Fatalf("err = %v, want ErrUpgradeDeclined", err)
}
@@ -253,7 +253,7 @@ func TestUpgradeNeedingApprovalReturnsTheInvoicePage(t *testing.T) {
database, fx, mock, prod2, corePrice2, _ := onTier1(t)
mock.UpdateSub = pendingSub(fx, "in_3ds", stripe.PaymentIntentStatusRequiresAction)
err := fulfillment.SwitchPlan(context.Background(), database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, corePrice2, time.Now(), "member:switch")
err := fulfillment.SwitchPlan(context.Background(), database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, corePrice2, time.Now(), "member:switch")
var approval *fulfillment.UpgradeNeedsApprovalError
if !errors.As(err, &approval) {
t.Fatalf("err = %v, want UpgradeNeedsApprovalError", err)
@@ -275,7 +275,7 @@ func TestAbandonedAttemptIsVoidedFirst(t *testing.T) {
mock.Sub = pendingSub(fx, "in_old", stripe.PaymentIntentStatusRequiresAction)
mock.UpdateSub = onTier(fx, stripePrice2)
if err := fulfillment.SwitchPlan(context.Background(), database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, corePrice2, time.Now(), "member:switch"); err != nil {
if err := fulfillment.SwitchPlan(context.Background(), database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, corePrice2, time.Now(), "member:switch"); err != nil {
t.Fatalf("upgrade: %v", err)
}
voided, updated := -1, -1
@@ -297,7 +297,7 @@ func TestDowngradeSchedulesForRenewal(t *testing.T) {
database, fx, mock, prod2, corePrice2, stripePrice2 := onTier2(t)
periodEnd := mock.Sub.CurrentPeriodEnd
if err := fulfillment.SwitchPlan(context.Background(), database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
if err := fulfillment.SwitchPlan(context.Background(), database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
t.Fatalf("downgrade: %v", err)
}
for _, c := range mock.CallsTo("POST", "/v1/subscriptions/"+fx.stripeSubID) {
@@ -344,10 +344,10 @@ func TestDowngradeSchedulesForRenewal(t *testing.T) {
func TestDowngradeReplacesAnArmedCancellation(t *testing.T) {
database, fx, mock, _, _, _ := onTier2(t)
ctx := context.Background()
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel"); err != nil {
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel"); err != nil {
t.Fatalf("arm cancellation: %v", err)
}
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
t.Fatalf("downgrade: %v", err)
}
if mock.Sub.CancelAtPeriodEnd {
@@ -367,7 +367,7 @@ func TestDowngradeReplacesAnArmedCancellation(t *testing.T) {
func TestUpgradeDropsAScheduledDowngrade(t *testing.T) {
database, fx, mock, _, _, _ := onTier2(t)
ctx := context.Background()
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
t.Fatalf("downgrade: %v", err)
}
prod3, corePrice3, stripePrice3 := addTier(t, database, fx, "top", 6000)
@@ -376,7 +376,7 @@ func TestUpgradeDropsAScheduledDowngrade(t *testing.T) {
upgraded.Schedule = schedule // Stripe keeps the schedule attached (sandbox Q1)
mock.Sub = upgraded
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, corePrice3, time.Now(), "member:switch"); err != nil {
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, corePrice3, time.Now(), "member:switch"); err != nil {
t.Fatalf("upgrade: %v", err)
}
if !mock.Sent("POST", "/v1/subscription_schedules/"+mockSchedule+"/release") {
@@ -395,10 +395,10 @@ func TestUpgradeDropsAScheduledDowngrade(t *testing.T) {
func TestKeepDropsAScheduledDowngrade(t *testing.T) {
database, fx, mock, prod2, _, _ := onTier2(t)
ctx := context.Background()
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
t.Fatalf("downgrade: %v", err)
}
if err := fulfillment.KeepCurrentPlan(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, "member:keep"); err != nil {
if err := fulfillment.KeepCurrentPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, "member:keep"); err != nil {
t.Fatalf("keep: %v", err)
}
if !mock.Sent("POST", "/v1/subscription_schedules/"+mockSchedule+"/release") {
@@ -417,10 +417,10 @@ func TestKeepDropsAScheduledDowngrade(t *testing.T) {
func TestKeepDropsAScheduledCancellation(t *testing.T) {
database, fx, mock, _, _, _ := onTier1(t)
ctx := context.Background()
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel"); err != nil {
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel"); err != nil {
t.Fatalf("cancel: %v", err)
}
if err := fulfillment.KeepCurrentPlan(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, "member:keep"); err != nil {
if err := fulfillment.KeepCurrentPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, "member:keep"); err != nil {
t.Fatalf("keep: %v", err)
}
if mock.Sub.CancelAtPeriodEnd {
@@ -436,7 +436,7 @@ func TestKeepDropsAScheduledCancellation(t *testing.T) {
func TestKeepWithNothingScheduledCallsNothing(t *testing.T) {
database, fx, mock, _, _, _ := onTier1(t)
before := len(mock.Calls)
if err := fulfillment.KeepCurrentPlan(context.Background(), database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, "member:keep"); err != nil {
if err := fulfillment.KeepCurrentPlan(context.Background(), database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, "member:keep"); err != nil {
t.Fatalf("keep: %v", err)
}
if n := len(mock.Calls) - before; n != 0 {
@@ -449,11 +449,11 @@ func TestKeepWithNothingScheduledCallsNothing(t *testing.T) {
func TestCancellationReleasesAScheduledDowngradeFirst(t *testing.T) {
database, fx, mock, _, _, _ := onTier2(t)
ctx := context.Background()
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
t.Fatalf("downgrade: %v", err)
}
mark := len(mock.Calls)
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel"); err != nil {
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel"); err != nil {
t.Fatalf("cancel: %v", err)
}
released, flagged := -1, -1
@@ -488,7 +488,7 @@ func TestRefusedCancellationRecordsNothing(t *testing.T) {
}
return nil, nil, false
}
if err := fulfillment.CancelSubscription(context.Background(), database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel"); !errors.Is(err, refusal) {
if err := fulfillment.CancelSubscription(context.Background(), database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel"); !errors.Is(err, refusal) {
t.Fatalf("err = %v, want the refusal", err)
}
core := coreSubID(t, database, fx.stripeSubID)
@@ -502,14 +502,14 @@ func TestRefusedCancellationRecordsNothing(t *testing.T) {
func TestLandedDowngradeClosesItsRow(t *testing.T) {
database, fx, mock, prod2, _, _ := onTier2(t)
ctx := context.Background()
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
t.Fatalf("downgrade: %v", err)
}
landed := fx.activeSub(fx.planItem())
landed.Schedule = mock.Sub.Schedule
mock.Sub = landed
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "stripe:customer.subscription.updated"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "stripe:customer.subscription.updated"); err != nil {
t.Fatalf("reconcile: %v", err)
}
core := coreSubID(t, database, fx.stripeSubID)
@@ -533,7 +533,7 @@ func TestLandedDowngradeClosesItsRow(t *testing.T) {
func TestLandedDowngradeEndsTheReplacedItem(t *testing.T) {
database, fx, mock, _, _, _ := onTier2(t)
ctx := context.Background()
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
t.Fatalf("downgrade: %v", err)
}
landed := fx.activeSub(&stripe.SubscriptionItem{ID: "si_next_" + fx.sfx, Quantity: 1, Price: &stripe.Price{ID: fx.stripePriceID}})
@@ -542,7 +542,7 @@ func TestLandedDowngradeEndsTheReplacedItem(t *testing.T) {
core := coreSubID(t, database, fx.stripeSubID)
for i := range 2 { // the second pass must change nothing
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "stripe:customer.subscription.updated"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "stripe:customer.subscription.updated"); err != nil {
t.Fatalf("reconcile %d: %v", i, err)
}
}
@@ -562,12 +562,12 @@ func TestLandedDowngradeEndsTheReplacedItem(t *testing.T) {
func TestReleasedScheduleSupersedesItsRow(t *testing.T) {
database, fx, mock, prod2, _, _ := onTier2(t)
ctx := context.Background()
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
t.Fatalf("downgrade: %v", err)
}
mock.Sub.Schedule = nil // released in the Dashboard
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "stripe:customer.subscription.updated"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "stripe:customer.subscription.updated"); err != nil {
t.Fatalf("reconcile: %v", err)
}
core := coreSubID(t, database, fx.stripeSubID)
@@ -584,11 +584,11 @@ func TestReleasedScheduleSupersedesItsRow(t *testing.T) {
func TestClearedCancellationSupersedesItsRow(t *testing.T) {
database, fx, mock, _, _, _ := onTier1(t)
ctx := context.Background()
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel"); err != nil {
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel"); err != nil {
t.Fatalf("cancel: %v", err)
}
mock.Sub.CancelAtPeriodEnd = false // cleared in the Dashboard
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "stripe:customer.subscription.updated"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "stripe:customer.subscription.updated"); err != nil {
t.Fatalf("reconcile: %v", err)
}
core := coreSubID(t, database, fx.stripeSubID)
@@ -597,10 +597,10 @@ func TestClearedCancellationSupersedesItsRow(t *testing.T) {
}
setCommitment(t, database, core, "block")
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel"); err != nil {
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel"); err != nil {
t.Fatalf("committed cancel: %v", err)
}
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "stripe:customer.subscription.updated"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "stripe:customer.subscription.updated"); err != nil {
t.Fatalf("reconcile: %v", err)
}
if n := rowCount(t, database, core, "cancellation", "term_boundary", "scheduled"); n != 1 {
@@ -614,7 +614,7 @@ func TestClearedCancellationSupersedesItsRow(t *testing.T) {
func TestLaterUpgradeReleasesTheConsolesScheduleOnly(t *testing.T) {
database, fx, mock, _, _, _ := onTier2(t)
ctx := context.Background()
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
t.Fatalf("downgrade: %v", err)
}
_, _, stripePrice3 := addTier(t, database, fx, "top", 6000)
@@ -622,7 +622,7 @@ func TestLaterUpgradeReleasesTheConsolesScheduleOnly(t *testing.T) {
upgraded.Schedule = mock.Sub.Schedule
mock.Sub = upgraded
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "stripe:customer.subscription.updated"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "stripe:customer.subscription.updated"); err != nil {
t.Fatalf("reconcile: %v", err)
}
if !mock.Sent("POST", "/v1/subscription_schedules/"+mockSchedule+"/release") {
@@ -635,7 +635,7 @@ func TestLaterUpgradeReleasesTheConsolesScheduleOnly(t *testing.T) {
mark := len(mock.Calls)
mock.Sub.Schedule = &stripe.SubscriptionSchedule{ID: "sub_sched_other"}
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "stripe:customer.subscription.updated"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "stripe:customer.subscription.updated"); err != nil {
t.Fatalf("reconcile: %v", err)
}
for _, c := range mock.Calls[mark:] {
@@ -650,7 +650,7 @@ func TestLaterUpgradeReleasesTheConsolesScheduleOnly(t *testing.T) {
func TestSweeperLeavesAPeriodEndDowngradeToStripe(t *testing.T) {
database, fx, mock, _, _, _ := onTier2(t)
ctx := context.Background()
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
t.Fatalf("downgrade: %v", err)
}
core := coreSubID(t, database, fx.stripeSubID)
@@ -661,7 +661,7 @@ func TestSweeperLeavesAPeriodEndDowngradeToStripe(t *testing.T) {
}
mark := len(mock.Calls)
if _, err := fulfillment.SweepDueScheduledChanges(ctx, database, discardLogger(), stripetest.KeyMode); err != nil {
if _, err := fulfillment.SweepDueScheduledChanges(ctx, database, discardLogger(), stripetest.KeyMode()); err != nil {
t.Fatalf("sweep: %v", err)
}
for _, c := range mock.Calls[mark:] {
@@ -676,7 +676,7 @@ func TestSweeperLeavesAPeriodEndDowngradeToStripe(t *testing.T) {
landed := fx.activeSub(fx.planItem())
landed.Schedule = mock.Sub.Schedule
mock.Sub = landed
if _, err := fulfillment.SweepDueScheduledChanges(ctx, database, discardLogger(), stripetest.KeyMode); err != nil {
if _, err := fulfillment.SweepDueScheduledChanges(ctx, database, discardLogger(), stripetest.KeyMode()); err != nil {
t.Fatalf("sweep: %v", err)
}
if n := rowCount(t, database, core, "plan_switch", "period_end", "applied"); n != 1 {
+8 -9
View File
@@ -42,9 +42,9 @@ import (
// It is convergent and idempotent: running it again with unchanged Stripe state
// is a no-op, and it never bails early on a pre-existing mapping.
//
// stripeKeyMode answers the mode of the API key the Stripe SDK calls with;
// it is asked once, before the fetch, and must not be nil.
func ReconcileSubscription(ctx context.Context, db *sql.DB, logger *slog.Logger, stripeKeyMode func() string, stripeSubscriptionID, reason string) error {
// keyMode is the mode of the key the process runs under; the row is checked
// against it before the fetch.
func ReconcileSubscription(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, stripeSubscriptionID, reason string) error {
if stripeSubscriptionID == "" {
return fmt.Errorf("reconcile: empty stripe subscription id")
}
@@ -58,7 +58,6 @@ func ReconcileSubscription(ctx context.Context, db *sql.DB, logger *slog.Logger,
// and the retry would fail the same way (stripe-environment-stamp D5).
// A row that carries no stamp, and a deployment whose key mode is
// unknown, reconcile as they do today.
keyMode := stripeKeyMode()
if skipUnreachableSubscription(ctx, db, logger, stripeSubscriptionID, keyMode) {
return nil
}
@@ -288,10 +287,10 @@ func reconcileSuspended(ctx context.Context, entQ *entitlements.Queries, subUUID
// ReconcileLatestSubscriptionForCustomer lists the customer's most recent
// subscription from the Stripe API and reconciles it. Used by the post-checkout
// return path, which knows the customer but not the subscription id. It returns
// nil (no error) when the customer has no subscription yet. stripeKeyMode is
// handed to ReconcileSubscription, which asks it; it must not be nil when the
// customer has a subscription.
func ReconcileLatestSubscriptionForCustomer(ctx context.Context, db *sql.DB, logger *slog.Logger, stripeKeyMode func() string, stripeCustomerID, reason string) error {
// nil (no error) when the customer has no subscription yet. keyMode is the
// mode of the key the process runs under; it is handed to
// ReconcileSubscription.
func ReconcileLatestSubscriptionForCustomer(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, stripeCustomerID, reason string) error {
if stripeCustomerID == "" {
return fmt.Errorf("reconcile: empty stripe customer id")
}
@@ -303,7 +302,7 @@ func ReconcileLatestSubscriptionForCustomer(ctx context.Context, db *sql.DB, log
iter := subscription.List(listParams)
for iter.Next() {
s := iter.Subscription()
return ReconcileSubscription(ctx, db, logger, stripeKeyMode, s.ID, reason)
return ReconcileSubscription(ctx, db, logger, keyMode, s.ID, reason)
}
if err := iter.Err(); err != nil {
return fmt.Errorf("reconcile: list subscriptions for customer %s: %w", stripeCustomerID, err)
@@ -59,7 +59,7 @@ func TestReconcile_ReplayNoDuplicateProvisions(t *testing.T) {
ctx := context.Background()
for i := 0; i < 2; i++ {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:replay"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:replay"); err != nil {
t.Fatalf("reconcile run %d: %v", i, err)
}
}
@@ -84,7 +84,7 @@ func TestReconcile_QuantityOnlyChangeUpdatesBounds(t *testing.T) {
t.Cleanup(stripetest.Install(mock))
ctx := context.Background()
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:qty1"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:qty1"); err != nil {
t.Fatalf("reconcile qty=1: %v", err)
}
core := coreSubID(t, database, fx.stripeSubID)
@@ -95,7 +95,7 @@ func TestReconcile_QuantityOnlyChangeUpdatesBounds(t *testing.T) {
item := fx.planItem()
item.Quantity = 3
mock.Sub = fx.activeSub(item)
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:qty3"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:qty3"); err != nil {
t.Fatalf("reconcile qty=3: %v", err)
}
@@ -125,7 +125,7 @@ func TestReconcile_PastDueSuspendsAndResumeReactivates(t *testing.T) {
t.Cleanup(stripetest.Install(mock))
ctx := context.Background()
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:setup"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
core := coreSubID(t, database, fx.stripeSubID)
@@ -134,7 +134,7 @@ func TestReconcile_PastDueSuspendsAndResumeReactivates(t *testing.T) {
pastDue := fx.activeSub(fx.planItem())
pastDue.Status = stripe.SubscriptionStatusPastDue
mock.Sub = pastDue
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:past_due"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:past_due"); err != nil {
t.Fatalf("reconcile past_due: %v", err)
}
if s := scalarStr(t, database,
@@ -143,7 +143,7 @@ func TestReconcile_PastDueSuspendsAndResumeReactivates(t *testing.T) {
}
mock.Sub = fx.activeSub(fx.planItem())
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:resume"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:resume"); err != nil {
t.Fatalf("reconcile resume: %v", err)
}
after := subProvision(t, database, core)
@@ -168,7 +168,7 @@ func TestReconcile_SuspendedIncumbentSuperseded(t *testing.T) {
t.Cleanup(stripetest.Install(mock))
ctx := context.Background()
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:setup"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
core := coreSubID(t, database, fx.stripeSubID)
@@ -177,7 +177,7 @@ func TestReconcile_SuspendedIncumbentSuperseded(t *testing.T) {
pastDue := fx.activeSub(fx.planItem())
pastDue.Status = stripe.SubscriptionStatusPastDue
mock.Sub = pastDue
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:past_due"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:past_due"); err != nil {
t.Fatalf("reconcile past_due: %v", err)
}
incumbent := scalarStr(t, database,
@@ -225,7 +225,7 @@ func TestReconcile_SupersededWhileSuspendedNotResurrected(t *testing.T) {
t.Cleanup(stripetest.Install(mock))
ctx := context.Background()
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:setup"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
core := coreSubID(t, database, fx.stripeSubID)
@@ -233,7 +233,7 @@ func TestReconcile_SupersededWhileSuspendedNotResurrected(t *testing.T) {
pastDue := fx.activeSub(fx.planItem())
pastDue.Status = stripe.SubscriptionStatusPastDue
mock.Sub = pastDue
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:past_due"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:past_due"); err != nil {
t.Fatalf("reconcile past_due: %v", err)
}
incumbent := scalarStr(t, database,
@@ -299,7 +299,7 @@ func TestReconcile_CancellationGuardRespectsGrantHolder(t *testing.T) {
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:setup"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
@@ -326,7 +326,7 @@ func TestReconcile_CancellationGuardRespectsGrantHolder(t *testing.T) {
canceled := fx.activeSub(fx.planItem())
canceled.Status = stripe.SubscriptionStatusCanceled
mock.Sub = canceled
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:canceled"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:canceled"); err != nil {
t.Fatalf("reconcile canceled: %v", err)
}
@@ -371,7 +371,7 @@ func TestReconcile_CancellationDefersToPlanOnNonDefaultLadder(t *testing.T) {
// The subscription holds the default-ladder plan position.
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:setup"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
@@ -427,7 +427,7 @@ func TestReconcile_CancellationDefersToPlanOnNonDefaultLadder(t *testing.T) {
canceled := fx.activeSub(fx.planItem())
canceled.Status = stripe.SubscriptionStatusCanceled
mock.Sub = canceled
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:canceled"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:canceled"); err != nil {
t.Fatalf("reconcile canceled: %v", err)
}
@@ -221,7 +221,7 @@ func TestReconcile_ProvisionsFromAPI(t *testing.T) {
fx := seedFixture(t, database)
t.Cleanup(stripetest.Install(&stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}))
if err := fulfillment.ReconcileSubscription(context.Background(), database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:5.1"); err != nil {
if err := fulfillment.ReconcileSubscription(context.Background(), database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:5.1"); err != nil {
t.Fatalf("reconcile: %v", err)
}
core := coreSubID(t, database, fx.stripeSubID)
@@ -242,7 +242,7 @@ func TestReconcile_IdempotentReplay(t *testing.T) {
ctx := context.Background()
for i := 0; i < 2; i++ {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:5.2"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:5.2"); err != nil {
t.Fatalf("reconcile run %d: %v", i, err)
}
}
@@ -267,10 +267,10 @@ func TestReconcile_OrderIndependent(t *testing.T) {
t.Cleanup(stripetest.Install(&stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}))
ctx := context.Background()
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:checkout"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:checkout"); err != nil {
t.Fatalf("reconcile (checkout): %v", err)
}
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:subscription.created"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:subscription.created"); err != nil {
t.Fatalf("reconcile (subscription.created): %v", err)
}
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 1 {
@@ -287,7 +287,7 @@ func TestReconcile_ConvergesAddedItem(t *testing.T) {
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:5.4a"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:5.4a"); err != nil {
t.Fatalf("reconcile (1 item): %v", err)
}
core := coreSubID(t, database, fx.stripeSubID)
@@ -318,7 +318,7 @@ func TestReconcile_ConvergesAddedItem(t *testing.T) {
fx.planItem(),
&stripe.SubscriptionItem{ID: "si_addon_" + fx.sfx, Quantity: 1, Price: &stripe.Price{ID: addonStripePrice}},
)
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:5.4b"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:5.4b"); err != nil {
t.Fatalf("reconcile (2 items): %v", err)
}
if n := itemCount(t, database, core); n != 2 {
@@ -335,7 +335,7 @@ func TestReconcile_CancellationEnds(t *testing.T) {
t.Cleanup(stripetest.Install(mock))
ctx := context.Background()
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:active"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:active"); err != nil {
t.Fatalf("reconcile (active): %v", err)
}
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 1 {
@@ -345,7 +345,7 @@ func TestReconcile_CancellationEnds(t *testing.T) {
canceled := fx.activeSub(fx.planItem())
canceled.Status = stripe.SubscriptionStatusCanceled
mock.Sub = canceled
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:canceled"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:canceled"); err != nil {
t.Fatalf("reconcile (canceled): %v", err)
}
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 0 {
@@ -367,7 +367,7 @@ func TestReconcileLatestForCustomer(t *testing.T) {
mock := &stripetest.MockBackend{Sub: sub, List: []*stripe.Subscription{sub}}
t.Cleanup(stripetest.Install(mock))
if err := fulfillment.ReconcileLatestSubscriptionForCustomer(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeCustomerID, "test:eager"); err != nil {
if err := fulfillment.ReconcileLatestSubscriptionForCustomer(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeCustomerID, "test:eager"); err != nil {
t.Fatalf("eager reconcile: %v", err)
}
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 1 {
@@ -376,7 +376,7 @@ func TestReconcileLatestForCustomer(t *testing.T) {
t.Run("backend error surfaces", func(t *testing.T) {
mock.Err = errors.New("stripe down")
if err := fulfillment.ReconcileLatestSubscriptionForCustomer(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeCustomerID, "test:eager-fail"); err == nil {
if err := fulfillment.ReconcileLatestSubscriptionForCustomer(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeCustomerID, "test:eager-fail"); err == nil {
t.Error("expected error to surface from eager reconcile")
}
mock.Err = nil
@@ -405,7 +405,7 @@ func TestReconcile_MultiLadderPositions(t *testing.T) {
// Appended at rank 0 — mirrors the primary fixture ladder's shape.
t.Cleanup(stripetest.Install(&stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}))
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:5.7"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:5.7"); err != nil {
t.Fatalf("reconcile: %v", err)
}
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 1 {
@@ -436,7 +436,7 @@ func TestReconcile_ConvergesInPlacePriceSwap(t *testing.T) {
// Member starts on tier 1.
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:5.8a"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:5.8a"); err != nil {
t.Fatalf("reconcile tier1: %v", err)
}
core := coreSubID(t, database, fx.stripeSubID)
@@ -449,7 +449,7 @@ func TestReconcile_ConvergesInPlacePriceSwap(t *testing.T) {
// Switch: same Stripe item id, new price. Reconcile must converge in place.
mock.Sub = fx.activeSub(&stripe.SubscriptionItem{ID: "si_plan_" + fx.sfx, Quantity: 1, Price: &stripe.Price{ID: stripePrice2}})
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:5.8b"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:5.8b"); err != nil {
t.Fatalf("reconcile swap: %v", err)
}
@@ -526,7 +526,7 @@ func TestSwitchPlan_ConvergesToNewTier(t *testing.T) {
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
// Member starts on tier 1 (subscription-backed) via reconcile.
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:setup"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 1 {
@@ -538,7 +538,7 @@ func TestSwitchPlan_ConvergesToNewTier(t *testing.T) {
// so the post-switch Stripe state must already reflect the new price.
mock.Sub = fx.activeSub(&stripe.SubscriptionItem{ID: "si_plan_" + fx.sfx, Quantity: 1, Price: &stripe.Price{ID: stripePrice2}})
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, corePrice2, time.Now(), "member:switch"); err != nil {
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, corePrice2, time.Now(), "member:switch"); err != nil {
t.Fatalf("switch: %v", err)
}
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 0 {
@@ -557,7 +557,7 @@ func TestCancelSubscription_ImmediateEnds(t *testing.T) {
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:setup"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 1 {
@@ -568,7 +568,7 @@ func TestCancelSubscription_ImmediateEnds(t *testing.T) {
canceled := fx.activeSub(fx.planItem())
canceled.Status = stripe.SubscriptionStatusCanceled
mock.Sub = canceled
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fulfillment.CancelImmediate, "member:cancel"); err != nil {
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fulfillment.CancelImmediate, "member:cancel"); err != nil {
t.Fatalf("cancel immediate: %v", err)
}
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 0 {
@@ -586,7 +586,7 @@ func TestCancelSubscription_PeriodEndSchedules(t *testing.T) {
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:setup"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
@@ -594,7 +594,7 @@ func TestCancelSubscription_PeriodEndSchedules(t *testing.T) {
pe := fx.activeSub(fx.planItem())
pe.CancelAtPeriodEnd = true
mock.Sub = pe
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel"); err != nil {
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel"); err != nil {
t.Fatalf("cancel period-end: %v", err)
}
@@ -630,7 +630,7 @@ func TestPreviewSwitch_UpgradeStatesAmountDueAtAPinnedMoment(t *testing.T) {
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:setup"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
_, corePrice2, _ := secondTier(t, database, fx)
@@ -669,7 +669,7 @@ func TestPreviewSwitch_DegradesWhenUnavailable(t *testing.T) {
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:setup"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
_, corePrice2, _ := secondTier(t, database, fx)
@@ -691,7 +691,7 @@ func TestFiring_WebhookCloseoutMarksApplied(t *testing.T) {
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:setup"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
@@ -699,7 +699,7 @@ func TestFiring_WebhookCloseoutMarksApplied(t *testing.T) {
pe := fx.activeSub(fx.planItem())
pe.CancelAtPeriodEnd = true
mock.Sub = pe
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel"); err != nil {
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel"); err != nil {
t.Fatalf("cancel period-end: %v", err)
}
core := coreSubID(t, database, fx.stripeSubID)
@@ -711,7 +711,7 @@ func TestFiring_WebhookCloseoutMarksApplied(t *testing.T) {
canceled := fx.activeSub(fx.planItem())
canceled.Status = stripe.SubscriptionStatusCanceled
mock.Sub = canceled
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "webhook:deleted"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "webhook:deleted"); err != nil {
t.Fatalf("webhook reconcile: %v", err)
}
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 0 {
@@ -731,7 +731,7 @@ func TestFiring_SweeperBackstopsDueCancellation(t *testing.T) {
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:setup"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
core := coreSubID(t, database, fx.stripeSubID)
@@ -751,7 +751,7 @@ func TestFiring_SweeperBackstopsDueCancellation(t *testing.T) {
canceled := fx.activeSub(fx.planItem())
canceled.Status = stripe.SubscriptionStatusCanceled
mock.Sub = canceled
n, err := fulfillment.SweepDueScheduledChanges(ctx, database, discardLogger(), stripetest.KeyMode)
n, err := fulfillment.SweepDueScheduledChanges(ctx, database, discardLogger(), stripetest.KeyMode())
if err != nil {
t.Fatalf("sweep: %v", err)
}
@@ -766,7 +766,7 @@ func TestFiring_SweeperBackstopsDueCancellation(t *testing.T) {
}
// Idempotent: a second sweep finds nothing due.
if n2, _ := fulfillment.SweepDueScheduledChanges(ctx, database, discardLogger(), stripetest.KeyMode); n2 != 0 {
if n2, _ := fulfillment.SweepDueScheduledChanges(ctx, database, discardLogger(), stripetest.KeyMode()); n2 != 0 {
t.Errorf("second sweep fired = %d, want 0", n2)
}
}
@@ -792,13 +792,13 @@ func TestCancel_CommitmentBlockDefersToBoundary(t *testing.T) {
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:setup"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
core := coreSubID(t, database, fx.stripeSubID)
setCommitment(t, database, core, "block")
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel"); err != nil {
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel"); err != nil {
t.Fatalf("cancel: %v", err)
}
// Access persists at the current tier; the cancellation is deferred.
@@ -822,13 +822,13 @@ func TestCancel_CommitmentFeeRefused(t *testing.T) {
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:setup"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
core := coreSubID(t, database, fx.stripeSubID)
setCommitment(t, database, core, "fee")
err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fulfillment.CancelImmediate, "member:cancel")
err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fulfillment.CancelImmediate, "member:cancel")
if !errors.Is(err, fulfillment.ErrCommitmentFee) {
t.Errorf("err = %v, want ErrCommitmentFee", err)
}
@@ -846,7 +846,7 @@ func TestSwitch_CommitmentDoesNotGateUpgrade(t *testing.T) {
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:setup"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
core := coreSubID(t, database, fx.stripeSubID)
@@ -854,7 +854,7 @@ func TestSwitch_CommitmentDoesNotGateUpgrade(t *testing.T) {
prod2, corePrice2, stripePrice2 := secondTier(t, database, fx) // above the paid tier — an upgrade
mock.Sub = fx.activeSub(&stripe.SubscriptionItem{ID: "si_plan_" + fx.sfx, Quantity: 1, Price: &stripe.Price{ID: stripePrice2}})
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, corePrice2, time.Now(), "member:switch"); err != nil {
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, corePrice2, time.Now(), "member:switch"); err != nil {
t.Fatalf("upgrade switch: %v", err)
}
if n := activeAttachCount(t, database, fx.ladderID, prod2); n != 1 {
@@ -877,10 +877,10 @@ func TestPlanChange_RejectsWhenNoPaidSubscription(t *testing.T) {
// No reconcile → no subscription-backed attachment exists on fx.ladderID.
_, corePrice2, _ := secondTier(t, database, fx)
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, corePrice2, time.Now(), "member:switch"); !errors.Is(err, fulfillment.ErrNoActivePaidSubscription) {
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, corePrice2, time.Now(), "member:switch"); !errors.Is(err, fulfillment.ErrNoActivePaidSubscription) {
t.Errorf("switch err = %v, want ErrNoActivePaidSubscription", err)
}
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel"); !errors.Is(err, fulfillment.ErrNoActivePaidSubscription) {
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel"); !errors.Is(err, fulfillment.ErrNoActivePaidSubscription) {
t.Errorf("cancel err = %v, want ErrNoActivePaidSubscription", err)
}
}
@@ -894,7 +894,7 @@ func TestCancel_CommitmentAllowProceeds(t *testing.T) {
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:setup"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
core := coreSubID(t, database, fx.stripeSubID)
@@ -903,7 +903,7 @@ func TestCancel_CommitmentAllowProceeds(t *testing.T) {
canceled := fx.activeSub(fx.planItem())
canceled.Status = stripe.SubscriptionStatusCanceled
mock.Sub = canceled
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fulfillment.CancelImmediate, "member:cancel"); err != nil {
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fulfillment.CancelImmediate, "member:cancel"); err != nil {
t.Fatalf("cancel: %v", err)
}
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 0 {
@@ -941,7 +941,7 @@ func TestCancel_ReissuesOrgDefault(t *testing.T) {
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:setup"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 1 {
@@ -951,7 +951,7 @@ func TestCancel_ReissuesOrgDefault(t *testing.T) {
canceled := fx.activeSub(fx.planItem())
canceled.Status = stripe.SubscriptionStatusCanceled
mock.Sub = canceled
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fulfillment.CancelImmediate, "member:cancel"); err != nil {
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fulfillment.CancelImmediate, "member:cancel"); err != nil {
t.Fatalf("cancel: %v", err)
}
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 0 {
@@ -1021,7 +1021,7 @@ func TestReconcile_StampsTheEnvironmentFromTheObject(t *testing.T) {
t.Cleanup(stripetest.Install(&stripetest.MockBackend{Sub: sub}))
ctx := context.Background()
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "test:environment-stamp"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:environment-stamp"); err != nil {
t.Fatalf("reconcile: %v", err)
}
+2 -2
View File
@@ -56,7 +56,7 @@ func TestUnixToNullTime(t *testing.T) {
// TestReconcileSubscription_EmptyID guards against an empty subscription id
// reaching the Stripe API.
func TestReconcileSubscription_EmptyID(t *testing.T) {
if err := ReconcileSubscription(context.Background(), nil, nil, stripetest.KeyMode, "", "test"); err == nil {
if err := ReconcileSubscription(context.Background(), nil, nil, stripetest.KeyMode(), "", "test"); err == nil {
t.Error("expected error for empty subscription id")
}
}
@@ -64,7 +64,7 @@ func TestReconcileSubscription_EmptyID(t *testing.T) {
// TestReconcileLatestSubscriptionForCustomer_EmptyCustomer guards against an
// empty customer id reaching the Stripe API.
func TestReconcileLatestSubscriptionForCustomer_EmptyCustomer(t *testing.T) {
if err := ReconcileLatestSubscriptionForCustomer(context.Background(), nil, nil, stripetest.KeyMode, "", "test"); err == nil {
if err := ReconcileLatestSubscriptionForCustomer(context.Background(), nil, nil, stripetest.KeyMode(), "", "test"); err == nil {
t.Error("expected error for empty customer id")
}
}
@@ -33,7 +33,7 @@ const releasePath = "/v1/subscription_schedules/" + mockSchedule + "/release"
func laterUpgrade(t *testing.T) (*sql.DB, fixture, *stripetest.MockBackend, string) {
t.Helper()
database, fx, mock, _, _, _ := onTier2(t)
if err := fulfillment.SwitchPlan(context.Background(), database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
if err := fulfillment.SwitchPlan(context.Background(), database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
t.Fatalf("downgrade: %v", err)
}
prod3, _, stripePrice3 := addTier(t, database, fx, "top", 6000)
@@ -52,7 +52,7 @@ func TestRefusedReleaseKeepsTheDowngrade(t *testing.T) {
mock.PathErrs = map[string]error{releasePath: &stripe.Error{
Type: stripe.ErrorTypeAPI, HTTPStatusCode: 500, Msg: "release refused"}}
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "stripe:customer.subscription.updated"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "stripe:customer.subscription.updated"); err != nil {
t.Fatalf("reconcile: %v", err)
}
if n := len(mock.CallsTo("POST", releasePath)); n != 1 {
@@ -67,7 +67,7 @@ func TestRefusedReleaseKeepsTheDowngrade(t *testing.T) {
}
mock.PathErrs = nil
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "stripe:customer.subscription.updated"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "stripe:customer.subscription.updated"); err != nil {
t.Fatalf("second reconcile: %v", err)
}
if n := len(mock.CallsTo("POST", releasePath)); n != 2 {
@@ -94,7 +94,7 @@ func TestNextReconcileSupersedesADowngradeLeftAfterItsRelease(t *testing.T) {
return stripeAnswer(c)
}
err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "stripe:customer.subscription.updated")
err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "stripe:customer.subscription.updated")
if !errors.Is(err, context.Canceled) {
t.Fatalf("reconcile = %v, want the write after the release to fail", err)
}
@@ -109,7 +109,7 @@ func TestNextReconcileSupersedesADowngradeLeftAfterItsRelease(t *testing.T) {
t.Errorf("top tier attach = %d, want 1 (committed before the release)", n)
}
if err := fulfillment.ReconcileSubscription(context.Background(), database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "stripe:customer.subscription.updated"); err != nil {
if err := fulfillment.ReconcileSubscription(context.Background(), database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "stripe:customer.subscription.updated"); err != nil {
t.Fatalf("next reconcile: %v", err)
}
if n := rowCount(t, database, core, "plan_switch", "period_end", "superseded"); n != 1 {
@@ -126,19 +126,19 @@ func TestNextReconcileSupersedesADowngradeLeftAfterItsRelease(t *testing.T) {
func TestReleaseLeavesATermBoundarySwitchScheduled(t *testing.T) {
database, fx, mock, _, _, _ := onTier2(t)
ctx := context.Background()
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
t.Fatalf("downgrade: %v", err)
}
core := coreSubID(t, database, fx.stripeSubID)
setCommitment(t, database, core, "block")
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode, fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
t.Fatalf("deferred downgrade: %v", err)
}
landed := fx.activeSub(fx.planItem())
landed.Schedule = mock.Sub.Schedule
mock.Sub = landed
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "stripe:customer.subscription.updated"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "stripe:customer.subscription.updated"); err != nil {
t.Fatalf("reconcile: %v", err)
}
if !mock.Sent("POST", releasePath) {
@@ -249,7 +249,7 @@ func startReconcile(ctx context.Context, database *sql.DB, fx fixture) *backgrou
r := &backgroundReconcile{finished: make(chan struct{})}
go func() {
defer close(r.finished)
r.err = fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, fx.stripeSubID, "stripe:customer.subscription.updated")
r.err = fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "stripe:customer.subscription.updated")
}()
return r
}
@@ -39,7 +39,7 @@ func downgraded(e *traceEnv) (plan, top tier) {
l := e.ladder(plan, plus, top)
e.mock.Sub = e.sub(stripe.SubscriptionStatusActive, e.item("si_a", plus, 1))
e.reconcile("test:setup")
if err := fulfillment.SwitchPlan(e.ctx, e.w.DB, e.logger, stripetest.KeyMode, e.org.ID, l.ID, plan.price.ID, time.Time{}, "member:switch"); err != nil {
if err := fulfillment.SwitchPlan(e.ctx, e.w.DB, e.logger, stripetest.KeyMode(), e.org.ID, l.ID, plan.price.ID, time.Time{}, "member:switch"); err != nil {
e.t.Fatalf("downgrade: %v", err)
}
return plan, top
@@ -108,7 +108,7 @@ var ruleCases = []traceCase{
}},
{"cancellation_cleared", func(e *traceEnv) string {
_, _, l := onPlan(e, false)
if err := fulfillment.CancelSubscription(e.ctx, e.w.DB, e.logger, stripetest.KeyMode, e.org.ID, l.ID, fulfillment.CancelAtPeriodEnd, "member:cancel"); err != nil {
if err := fulfillment.CancelSubscription(e.ctx, e.w.DB, e.logger, stripetest.KeyMode(), e.org.ID, l.ID, fulfillment.CancelAtPeriodEnd, "member:cancel"); err != nil {
e.t.Fatalf("cancel: %v", err)
}
e.w.ScheduleCancellation(world.Subscription{ID: e.coreSubscription()}, time.Now().Add(30*24*time.Hour))
@@ -119,7 +119,7 @@ var ruleCases = []traceCase{
{"term_boundary_switch_pending", func(e *traceEnv) string {
plan, _, l := onPlanOn(e, "Plus")
setCommitment(e.t, e.w.DB, e.coreSubscription(), "block")
if err := fulfillment.SwitchPlan(e.ctx, e.w.DB, e.logger, stripetest.KeyMode, e.org.ID, l.ID, plan.price.ID, time.Time{}, "member:switch"); err != nil {
if err := fulfillment.SwitchPlan(e.ctx, e.w.DB, e.logger, stripetest.KeyMode(), e.org.ID, l.ID, plan.price.ID, time.Time{}, "member:switch"); err != nil {
e.t.Fatalf("deferred downgrade: %v", err)
}
e.start()
+15 -17
View File
@@ -33,11 +33,10 @@ const stalePeriodEndAfter = 24 * time.Hour
// SweepDueScheduledChanges fires every scheduled change whose effective_at has
// passed and returns the number applied. Individual failures are logged and
// skipped so one bad row does not stall the sweep. stripeKeyMode answers the
// mode of the API key the Stripe SDK calls with; it is asked once per due
// row before the row is claimed, and again by each reconcile the row runs,
// and must not be nil when a row is due.
func SweepDueScheduledChanges(ctx context.Context, db *sql.DB, logger *slog.Logger, stripeKeyMode func() string) (int, error) {
// skipped so one bad row does not stall the sweep. keyMode is the mode of the
// key the process runs under; each due row is checked against it before the
// row is claimed, and each reconcile the row runs receives it.
func SweepDueScheduledChanges(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string) (int, error) {
if logger == nil {
logger = slog.Default()
}
@@ -48,7 +47,7 @@ func SweepDueScheduledChanges(ctx context.Context, db *sql.DB, logger *slog.Logg
}
fired := 0
for _, ch := range due {
if ferr := fireScheduledChange(ctx, db, logger, stripeKeyMode, ch); ferr != nil {
if ferr := fireScheduledChange(ctx, db, logger, keyMode, ch); ferr != nil {
logger.Error("sweep: fire scheduled change failed",
slog.String("scheduled_change_id", ch.ScheduledChangeID), slog.Any("error", ferr))
continue
@@ -61,7 +60,7 @@ func SweepDueScheduledChanges(ctx context.Context, db *sql.DB, logger *slog.Logg
// fireScheduledChange claims a due scheduled change and applies its effect
// (Stripe mutation + reconcile). Claiming first makes it idempotent: a concurrent
// firer or a webhook that already realized the change loses the claim and skips.
func fireScheduledChange(ctx context.Context, db *sql.DB, logger *slog.Logger, stripeKeyMode func() string, ch billing.SubscriptionScheduledChange) error {
func fireScheduledChange(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, ch billing.SubscriptionScheduledChange) error {
billingQ := billing.New(db)
stripeQ := internalstripe.New(db)
@@ -76,7 +75,6 @@ func fireScheduledChange(ctx context.Context, db *sql.DB, logger *slog.Logger, s
// same statement, so a change skipped after the claim would never fire
// again once the ids became reachable. Skipping first leaves the row
// scheduled for a later run (stripe-environment-stamp D5).
keyMode := stripeKeyMode()
if skipUnreachableChange(ctx, logger, billingQ, stripeQ, ch, subMapping, stripeSubID, keyMode) {
return nil
}
@@ -87,7 +85,7 @@ func fireScheduledChange(ctx context.Context, db *sql.DB, logger *slog.Logger, s
// the target price (plan-switch-timing D5). Claiming it here and setting
// the price directly would fight the schedule.
if ch.ChangeType == "plan_switch" && ch.EffectiveTrigger == "period_end" {
return reconcilePeriodEndDowngrade(ctx, db, logger, stripeKeyMode, billingQ, ch, stripeSubID)
return reconcilePeriodEndDowngrade(ctx, db, logger, keyMode, billingQ, ch, stripeSubID)
}
// Claim: scheduled -> applied. A loser gets ErrNoRows and no-ops.
@@ -100,9 +98,9 @@ func fireScheduledChange(ctx context.Context, db *sql.DB, logger *slog.Logger, s
switch ch.ChangeType {
case "cancellation":
return fireCancellation(ctx, db, logger, stripeKeyMode, billingQ, ch, stripeSubID)
return fireCancellation(ctx, db, logger, keyMode, billingQ, ch, stripeSubID)
case "plan_switch":
return firePlanSwitch(ctx, db, logger, stripeKeyMode, billingQ, stripeQ, ch, stripeSubID)
return firePlanSwitch(ctx, db, logger, keyMode, billingQ, stripeQ, ch, stripeSubID)
default:
// quantity_change is not produced by the member move flows.
return nil
@@ -140,12 +138,12 @@ func reconcilePeriodEndDowngrade(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
stripeKeyMode func() string,
keyMode string,
billingQ *billing.Queries,
ch billing.SubscriptionScheduledChange,
stripeSubID string,
) error {
if err := ReconcileSubscription(ctx, db, logger, stripeKeyMode, stripeSubID, "sweep:period_end"); err != nil {
if err := ReconcileSubscription(ctx, db, logger, keyMode, stripeSubID, "sweep:period_end"); err != nil {
return fmt.Errorf("fire: reconcile period-end downgrade %s: %w", ch.ScheduledChangeID, err)
}
if after, err := billingQ.GetScheduledChangeByID(ctx, ch.ScheduledChangeID); err == nil &&
@@ -165,7 +163,7 @@ func fireCancellation(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
stripeKeyMode func() string,
keyMode string,
billingQ *billing.Queries,
ch billing.SubscriptionScheduledChange,
stripeSubID string,
@@ -177,7 +175,7 @@ func fireCancellation(
logger.Warn("fire: stripe cancel (continuing to reconcile)",
slog.String("stripe_subscription_id", stripeSubID), slog.Any("error", cerr))
}
return ReconcileSubscription(ctx, db, logger, stripeKeyMode, stripeSubID, "fire:cancellation")
return ReconcileSubscription(ctx, db, logger, keyMode, stripeSubID, "fire:cancellation")
}
// firePlanSwitch moves the subscription's first current item to the claimed
@@ -186,7 +184,7 @@ func firePlanSwitch(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
stripeKeyMode func() string,
keyMode string,
billingQ *billing.Queries,
stripeQ *internalstripe.Queries,
ch billing.SubscriptionScheduledChange,
@@ -216,7 +214,7 @@ func firePlanSwitch(
}); uerr != nil {
return fmt.Errorf("fire: stripe modify item for %s: %w", ch.ScheduledChangeID, uerr)
}
return ReconcileSubscription(ctx, db, logger, stripeKeyMode, stripeSubID, "fire:plan_switch")
return ReconcileSubscription(ctx, db, logger, keyMode, stripeSubID, "fire:plan_switch")
}
// stampedMapping is one recorded Stripe id and the environment stamp on
+2 -2
View File
@@ -57,7 +57,7 @@ func (e *traceEnv) answer() {
// through the console, the row due a minute ago.
func cancelAtPeriodEnd(e *traceEnv) {
_, _, l := onPlan(e, false)
if err := fulfillment.CancelSubscription(e.ctx, e.w.DB, e.logger, stripetest.KeyMode, e.org.ID, l.ID, fulfillment.CancelAtPeriodEnd, "member:cancel"); err != nil {
if err := fulfillment.CancelSubscription(e.ctx, e.w.DB, e.logger, stripetest.KeyMode(), e.org.ID, l.ID, fulfillment.CancelAtPeriodEnd, "member:cancel"); err != nil {
e.t.Fatalf("cancel: %v", err)
}
e.due("1 minute")
@@ -72,7 +72,7 @@ func deferredSwitch(e *traceEnv) tier {
e.mock.Sub = e.sub(stripe.SubscriptionStatusActive, e.item("si_a", plus, 1), e.item("si_x", extra, 1))
e.reconcile("test:setup")
setCommitment(e.t, e.w.DB, e.coreSubscription(), "block")
if err := fulfillment.SwitchPlan(e.ctx, e.w.DB, e.logger, stripetest.KeyMode, e.org.ID, l.ID, plan.price.ID, time.Time{}, "member:switch"); err != nil {
if err := fulfillment.SwitchPlan(e.ctx, e.w.DB, e.logger, stripetest.KeyMode(), e.org.ID, l.ID, plan.price.ID, time.Time{}, "member:switch"); err != nil {
e.t.Fatalf("deferred downgrade: %v", err)
}
e.due("1 minute")
+2 -2
View File
@@ -234,13 +234,13 @@ func (e *traceEnv) start() {
// reconcile runs the reconcile of the fixture's subscription with reason.
func (e *traceEnv) reconcile(reason string) string {
return result(fulfillment.ReconcileSubscription(e.ctx, e.db, e.logger, stripetest.KeyMode, e.subID, reason))
return result(fulfillment.ReconcileSubscription(e.ctx, e.db, e.logger, stripetest.KeyMode(), e.subID, reason))
}
// sweep runs the scheduled-change sweep on the code's handle and prints the
// count it returns with its error.
func (e *traceEnv) sweep() string {
n, err := fulfillment.SweepDueScheduledChanges(e.ctx, e.db, e.logger, stripetest.KeyMode)
n, err := fulfillment.SweepDueScheduledChanges(e.ctx, e.db, e.logger, stripetest.KeyMode())
return fmt.Sprintf("fired %d, %s", n, result(err))
}
+16 -20
View File
@@ -16,8 +16,7 @@
// HTTP handler now live inside this tree — internal/integrations/stripe/
// store, .../workflows, and .../web respectively (openspec/changes/
// integration-extraction tasks 3.1-3.3). The `stripe_*` DB roles/grants
// still live in core's baseline (task 3.5), and ConfigSpec below still
// predates the single-stripego.Key-assignment cleanup (task 3.4). See
// still live in core's baseline (task 3.5). See
// internal/stripetest, internal/fulfillment, and the billing/checkout code
// for the parts of Stripe's integration that stay in core as the
// payments-provider-seam follow-up (design.md Decision 6) — the billing
@@ -180,7 +179,7 @@ func (Adapter) CSRFExemptPaths() []string {
//
// The environment check's activity holds the mode and the fingerprint of
// the key this process runs under, read here from the same viper key
// Startup assigns to stripego.Key, so the activity's record and its log
// start hands to UseKey, so the activity's record and its log
// lines name the key the SDK is actually calling with
// (stripe-environment-stamp D3). ModeForKey's error is ignored: boot
// already refused an unrecognized prefix, and a worker is not the place to
@@ -201,19 +200,13 @@ func (Adapter) RegisterWorkflows(w worker.Worker, database *sql.DB, logger *slog
w.RegisterActivity(workflows.NewPublicDetailsActivities(database, logger, mode, FingerprintForKey(apiKey)))
}
// Startup sets the stripe-go package-level API key — the single place this
// happens process-wide (design.md Decision 5; task 3.4), replacing the two
// duplicate assignments that used to live in cmd/start.go and
// internal/server/server.go — then hand-starts Stripe's long-running
// webhook-processor and outbox-poller workflows, replacing the two
// ExecuteWorkflow calls that used to live in cmd/start.go. Each workflow
// start is independently non-fatal: a failure is logged and boot continues,
// matching today's convention.
// Startup hand-starts Stripe's long-running webhook-processor and
// outbox-poller workflows, replacing the two ExecuteWorkflow calls that used
// to live in cmd/start.go. It does not assign the stripe-go API key: start
// sets it once at boot through UseKey, before any goroutine runs. Each
// workflow start is independently non-fatal: a failure is logged and boot
// continues, matching today's convention.
func (Adapter) Startup(ctx context.Context, c client.Client, taskQueue string, database *sql.DB, logger *slog.Logger) error {
if apiKey := viper.GetString("stripe-api-key"); apiKey != "" {
stripego.Key = apiKey
}
// Webhook events are processed one workflow per event, started by the
// endpoint. The polling processor that preceded them (through 2026-09)
// is terminated if a deployment still runs one, and the rows it left
@@ -292,11 +285,14 @@ func ModeForKey(apiKey string) (string, error) {
return "", errors.New("stripe-api-key must begin with sk_live_, rk_live_, sk_test_, or rk_test_")
}
// SDKKeyMode is the mode of the API key the Stripe SDK holds, "" when it holds
// none or one boot would refuse. Checkout takes it as a function, so
// internal/server does not read stripe.Key.
func SDKKeyMode() string {
return stripemod.ModeForKey(stripego.Key)
// UseKey assigns the stripe-go package-level API key when the trimmed key is
// not empty and does nothing otherwise. start calls it once, after the mode
// and the fingerprint are derived and before any goroutine starts; nothing
// assigns the key afterwards, so every later reader sees one value.
func UseKey(apiKey string) {
if key := strings.TrimSpace(apiKey); key != "" {
stripego.Key = key
}
}
// FingerprintForKey is the API key's identity in the console's own
+93 -18
View File
@@ -4,10 +4,18 @@
package stripe
import (
"context"
"errors"
"io"
"log/slog"
"strings"
"testing"
"github.com/spf13/viper"
stripego "github.com/stripe/stripe-go/v81"
"go.temporal.io/sdk/client"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
)
// TestModeForKey covers stripe-integration-infrastructure ("Stripe mode is
@@ -105,26 +113,93 @@ func TestFingerprintForKey(t *testing.T) {
}
}
// TestSDKKeyMode covers the request-time reader of the SDK's key: the mode of
// the key the SDK holds, "" for none or one boot would refuse.
func TestSDKKeyMode(t *testing.T) {
// useKeyCase is one row of TestUseKey: the key the SDK holds before the call,
// the key handed to UseKey, and the key the SDK holds after it.
type useKeyCase struct {
name string
before string
given string
want string
}
func checkUseKey(t *testing.T, tc useKeyCase) {
t.Helper()
saved := stripego.Key
t.Cleanup(func() { stripego.Key = saved })
for _, tc := range []struct {
name string
key string
want string
}{
{"unset", "", ""},
{"live secret key", "sk_live_abc123", ModeLive},
{"test restricted key", "rk_test_abc123", ModeTest},
{"unrecognised", "not-a-key", ""},
stripego.Key = tc.before
UseKey(tc.given)
if stripego.Key != tc.want {
t.Errorf("stripego.Key after UseKey(%q) with %q before = %q, want %q", tc.given, tc.before, stripego.Key, tc.want)
}
}
// TestUseKey covers the one place the SDK's key is assigned: an empty key and
// a key of only whitespace leave it as it was, a key is assigned.
func TestUseKey(t *testing.T) {
for _, tc := range []useKeyCase{
{"empty key", "sk_test_before", "", "sk_test_before"},
{"whitespace only", "sk_test_before", " \t ", "sk_test_before"},
{"empty key, none before", "", "", ""},
{"a key", "", "sk_test_usekey", "sk_test_usekey"},
{"a key with whitespace around it", "", " sk_test_usekey\n", "sk_test_usekey"},
{"a key replaces the one before", "sk_test_before", "sk_test_usekey", "sk_test_usekey"},
} {
t.Run(tc.name, func(t *testing.T) {
stripego.Key = tc.key
if got := SDKKeyMode(); got != tc.want {
t.Errorf("SDKKeyMode() with key %q = %q, want %q", tc.key, got, tc.want)
}
})
t.Run(tc.name, func(t *testing.T) { checkUseKey(t, tc) })
}
}
// bootClient is the smallest client Startup runs against: every call it
// makes is answered and nothing is recorded or reached. The embedded
// interfaces are nil, so a call Startup does not make today panics here.
type bootClient struct{ client.Client }
func (bootClient) TerminateWorkflow(context.Context, string, string, string, ...any) error {
return nil
}
func (bootClient) ExecuteWorkflow(context.Context, client.StartWorkflowOptions, any, ...any) (client.WorkflowRun, error) {
return nil, nil
}
func (bootClient) ScheduleClient() client.ScheduleClient { return bootSchedules{} }
type bootSchedules struct{ client.ScheduleClient }
func (bootSchedules) GetHandle(context.Context, string) client.ScheduleHandle {
return bootScheduleHandle{}
}
func (bootSchedules) Create(context.Context, client.ScheduleOptions) (client.ScheduleHandle, error) {
return bootScheduleHandle{}, nil
}
type bootScheduleHandle struct{ client.ScheduleHandle }
func (bootScheduleHandle) Describe(context.Context) (*client.ScheduleDescription, error) {
return nil, errors.New("no such schedule")
}
func (bootScheduleHandle) Trigger(context.Context, client.ScheduleTriggerOptions) error { return nil }
// TestStartupLeavesTheSDKKeyAlone: with the SDK holding the boot key and the
// configuration naming another, the startup hook leaves the SDK's key as boot
// set it.
func TestStartupLeavesTheSDKKeyAlone(t *testing.T) {
database := dbtest.Open(t)
savedKey := stripego.Key
savedConfig := viper.Get("stripe-api-key")
t.Cleanup(func() {
stripego.Key = savedKey
viper.Set("stripe-api-key", savedConfig)
})
stripego.Key = "sk_test_boot"
viper.Set("stripe-api-key", "sk_test_hook")
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := (Adapter{}).Startup(context.Background(), bootClient{}, "test-queue", database, logger); err != nil {
t.Fatalf("Startup: %v", err)
}
if stripego.Key != "sk_test_boot" {
t.Errorf("stripego.Key after Startup = %q, want %q: Startup assigned the key", stripego.Key, "sk_test_boot")
}
}
@@ -360,7 +360,7 @@ func (r *invoiceReconcile) ensureSubscription(ctx context.Context) error {
}
reason := "stripe:invoice-reconcile:" + r.trig.Ref
if err := fulfillment.ReconcileSubscription(ctx, r.db, r.logger, sdkKeyMode, subID, reason); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, r.db, r.logger, sdkKeyMode(), subID, reason); err != nil {
if transientError(err) {
return fmt.Errorf("stripe invoice %s (%s): reconcile subscription %s first: %w", r.id, r.trig.Ref, subID, err)
}
@@ -226,7 +226,7 @@ func nextInvoiceNumber(t *testing.T, ctx context.Context, database *sql.DB, baID
func mappedSubscription(t *testing.T, ctx context.Context, database *sql.DB, m *stripetest.MockBackend, fx planFixture, subID string) string {
t.Helper()
m.Sub = cannedActiveSub(fx, subID)
if err := fulfillment.ReconcileSubscription(ctx, database, quietLogger(), stripetest.KeyMode, subID, "test"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, quietLogger(), stripetest.KeyMode(), subID, "test"); err != nil {
t.Fatalf("reconcile subscription %s: %v", subID, err)
}
sm, err := internalstripe.New(database).GetSubscriptionMappingByStripeID(ctx, sql.NullString{String: subID, Valid: true})
@@ -10,10 +10,9 @@ import (
)
// sdkKeyMode is the mode of the API key the Stripe SDK holds, read when it
// is asked. The Stripe integration owns that key, so its activities read it
// here and hand the reading to the reconcile they call. The adapter's
// SDKKeyMode reads the same key, but this package cannot import the adapter,
// which imports this package.
// is called. start sets that key at boot, before the worker exists, so the
// reading is the boot mode; the activities pass it to the reconcile they
// call.
func sdkKeyMode() string {
return internalstripe.ModeForKey(stripego.Key)
}
@@ -56,7 +56,7 @@ func (a *WebhookActivities) handleCheckoutSessionEvent(ctx context.Context, evt
}
reason := fmt.Sprintf("stripe:checkout.session.completed:%s", evt.ProviderEventID)
if err := fulfillment.ReconcileSubscription(ctx, a.DB, a.Logger, sdkKeyMode, session.Subscription, reason); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, a.DB, a.Logger, sdkKeyMode(), session.Subscription, reason); err != nil {
return "", fmt.Errorf("reconcile subscription: %w", err)
}
@@ -93,7 +93,7 @@ func (a *WebhookActivities) handleSubscriptionEvent(ctx context.Context, evt Web
}
reason := fmt.Sprintf("stripe:%s:%s", evt.EventType, evt.ProviderEventID)
if err := fulfillment.ReconcileSubscription(ctx, a.DB, a.Logger, sdkKeyMode, sub.ID, reason); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, a.DB, a.Logger, sdkKeyMode(), sub.ID, reason); err != nil {
return "", fmt.Errorf("reconcile subscription: %w", err)
}
+5 -5
View File
@@ -33,10 +33,10 @@ type BillingCheckoutHandler struct {
// EntitlementsQ reads the organization's position on the ladders of the
// product being bought (plans.Offer).
EntitlementsQ entitlements.Querier
// KeyMode answers the environment of the API key the Stripe SDK holds
// ("test", "live", or "" when it holds none), read per request as the SDK
// key was; see MemberProductsHandler.StripeMode for what the mode gates.
KeyMode func() string
// StripeMode is the environment derived from the API key at boot
// (server.Config.StripeMode); see MemberProductsHandler.StripeMode for
// what the mode gates.
StripeMode string
}
// HandleCheckout creates a Stripe Checkout Session and redirects the member.
@@ -71,7 +71,7 @@ func (h *BillingCheckoutHandler) HandleCheckout(w http.ResponseWriter, r *http.R
}
account := billingAccounts[0]
keyMode := h.KeyMode()
keyMode := h.StripeMode
move, err := h.checkoutOffer(ctx, userSession.OrgID, target, keyMode)
if err != nil {
h.Logger.Error("failed to read what checkout offers", slog.String("price_id", target.price.PriceID), slog.Any("error", err))
@@ -118,7 +118,7 @@ func newCheckoutEnv(t *testing.T, keyPrefix string) *checkoutEnv {
AuthConfig: &auth.Config{SessionManager: sm},
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
BaseURL: "https://example.test",
KeyMode: func() string { return internalstripe.ModeForKey(keyPrefix + "checkoutenv") },
StripeMode: internalstripe.ModeForKey(keyPrefix + "checkoutenv"),
},
session: sctx,
orgID: orgID,
@@ -86,7 +86,7 @@ func postCheckout(t *testing.T, q billing.Querier, priceID string) *httptest.Res
AuthConfig: authCfg,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
BaseURL: "https://example.test",
KeyMode: func() string { return "test" },
StripeMode: "test",
}
form := url.Values{"price_id": {priceID}}
req := httptest.NewRequest(http.MethodPost, "/billing/checkout", strings.NewReader(form.Encode())).WithContext(sctx)
@@ -109,7 +109,7 @@ func (w *checkoutOfferWorld) post(orgID, priceID string, billingQ billing.Querie
AuthConfig: authCfg,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
BaseURL: "https://example.test",
KeyMode: func() string { return "test" },
StripeMode: "test",
}
form := url.Values{"price_id": {priceID}}
req := httptest.NewRequestWithContext(sctx, http.MethodPost, "/billing/checkout", strings.NewReader(form.Encode()))
@@ -393,7 +393,7 @@ func TestCheckoutAnswersHTMXWithAClientRedirect(t *testing.T) {
AuthConfig: &auth.Config{SessionManager: sm},
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
BaseURL: "https://example.test",
KeyMode: func() string { return "test" },
StripeMode: "test",
}
post := func(t *testing.T, opts ...authtest.RequestOpt) *httptest.ResponseRecorder {
t.Helper()
+3 -3
View File
@@ -215,7 +215,7 @@ func (h *MemberProductsHandler) PostSwitch(w http.ResponseWriter, r *http.Reques
prorationDate = time.Unix(sec, 0)
}
err = fulfillment.SwitchPlan(r.Context(), h.Database, h.Logger, h.KeyMode, session.OrgID, ladderID, target.PriceID, prorationDate, "member:switch")
err = fulfillment.SwitchPlan(r.Context(), h.Database, h.Logger, h.StripeMode, session.OrgID, ladderID, target.PriceID, prorationDate, "member:switch")
var approval *fulfillment.UpgradeNeedsApprovalError
switch {
case errors.Is(err, fulfillment.ErrStalePreview), errors.Is(err, fulfillment.ErrUpgradeDeclined), errors.As(err, &approval):
@@ -259,7 +259,7 @@ func (h *MemberProductsHandler) PostKeep(w http.ResponseWriter, r *http.Request)
http.Error(w, "ladder_id is required", http.StatusBadRequest)
return
}
err := fulfillment.KeepCurrentPlan(r.Context(), h.Database, h.Logger, h.KeyMode, session.OrgID, ladderID, "member:keep")
err := fulfillment.KeepCurrentPlan(r.Context(), h.Database, h.Logger, h.StripeMode, session.OrgID, ladderID, "member:keep")
h.renderPlansAfterMove(w, r.Context(), session.OrgID, err, "keep")
}
@@ -312,7 +312,7 @@ func (h *MemberProductsHandler) PostCancel(w http.ResponseWriter, r *http.Reques
timing = fulfillment.CancelImmediate
}
err := fulfillment.CancelSubscription(r.Context(), h.Database, h.Logger, h.KeyMode, session.OrgID, ladderID, timing, "member:cancel")
err := fulfillment.CancelSubscription(r.Context(), h.Database, h.Logger, h.StripeMode, session.OrgID, ladderID, timing, "member:cancel")
h.renderPlansAfterMove(w, r.Context(), session.OrgID, err, "cancel")
}
+3 -9
View File
@@ -30,13 +30,10 @@ type MemberProductsHandler struct {
Templates *SafeTemplates
// StripeMode is the environment derived from the API key at boot
// (server.Config.StripeMode): the purchasability gate refuses a price
// whose mapping the key cannot reach (stripe-environment-stamp D5).
// whose mapping the key cannot reach (stripe-environment-stamp D5). Each
// plan move checks the ids it sends against it and hands it to the
// reconcile that follows.
StripeMode string
// KeyMode answers the mode of the API key the Stripe SDK holds
// (server.Config.StripeKeyMode). A switch asks it before it calls Stripe,
// and every move's reconcile asks it afterwards, so a move that reaches
// Stripe needs it set.
KeyMode func() string
// Database is used to resolve Stripe price mappings when determining
// whether a plan is purchasable. May be nil (e.g. in tests or when Stripe
// is not configured), in which case plans are treated as not purchasable.
@@ -57,8 +54,6 @@ type MemberProductsConfig struct {
// StripeMode is the environment derived from the API key at boot; see
// MemberProductsHandler.StripeMode.
StripeMode string
// KeyMode: see MemberProductsHandler.KeyMode.
KeyMode func() string
// StripeKeyFingerprint: see MemberProductsHandler.StripeKeyFingerprint.
StripeKeyFingerprint string
}
@@ -78,7 +73,6 @@ func NewMemberProductsHandler(cfg MemberProductsConfig) (*MemberProductsHandler,
Templates: NewSafeTemplates(tmpl, cfg.Logger),
Database: cfg.Database,
StripeMode: cfg.StripeMode,
KeyMode: cfg.KeyMode,
StripeKeyFingerprint: cfg.StripeKeyFingerprint,
}, nil
+11 -9
View File
@@ -140,8 +140,8 @@ func TestNewHandlerPages(t *testing.T) {
}
// TestCheckoutThroughTheRouter: Checkout, built from the server's
// configuration, asks the configured key mode once and sends Stripe the
// configured base URL to return to.
// configuration, checks the posted price against the boot mode and sends
// Stripe the configured base URL to return to.
func TestCheckoutThroughTheRouter(t *testing.T) {
const sessionURL = "https://checkout.example.test/c/pay/cs_test_routes"
db := dbtest.Open(t)
@@ -150,11 +150,10 @@ func TestCheckoutThroughTheRouter(t *testing.T) {
price := w.Price(plan, 1500)
w.MapPrice(price)
w.Ladder("Checkout", plan)
asked := 0
env := newRouteEnv(t, func(c *Config) {
onDatabase(db)(c)
c.StripeKeyMode = func() string { asked++; return "test" }
})
livePlan := w.Product("Live plan")
livePrice := w.Price(livePlan, 2500)
w.MapPriceIn(livePrice, true)
env := newRouteEnv(t, onDatabase(db))
o, _, cookie := member(t, w, env, w.OrgType("cart"), "Buyer")
w.MapAccountIn(w.BillingAccount(o, "Primary"), false)
mock := &stripetest.MockBackend{Handle: func(c stripetest.Call) (any, error, bool) {
@@ -172,8 +171,11 @@ func TestCheckoutThroughTheRouter(t *testing.T) {
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != sessionURL {
t.Fatalf("checkout = %d to %q, want 303 to the session", rec.Code, rec.Header().Get("Location"))
}
if asked != 1 {
t.Errorf("the key mode was asked %d times, want 1", asked)
live := httptest.NewRequest(http.MethodPost, "/billing/checkout", strings.NewReader(url.Values{"price_id": {livePrice.ID}}.Encode()))
live.Header.Set("Content-Type", "application/x-www-form-urlencoded")
live.AddCookie(cookie)
if rec := env.serve(live); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "That product is no longer available") {
t.Errorf("checkout of a live-mode price under the test key = %d %q, want 422 containing %q", rec.Code, strings.TrimSpace(rec.Body.String()), "That product is no longer available")
}
sessions := mock.CallsTo(http.MethodPost, "/v1/checkout/sessions")
if len(sessions) != 1 || sessions[0].Form.Get("success_url") != routesBaseURL+"/?checkout=success" {
-1
View File
@@ -115,7 +115,6 @@ func routeConfig(kept *Deps) Config {
StripeAPIKey: "sk_test_routes",
StripeWebhookSecret: "whsec_routes",
StripeMode: "test",
StripeKeyMode: func() string { return "test" },
StripeKeyFingerprint: "fingerprint-routes",
BaseURL: routesBaseURL,
RouteMounts: []RouteMount{fakeMount(kept)},
@@ -108,7 +108,7 @@ func seedSwitchTimingMember(t *testing.T) switchTimingMember {
m.mock = &stripetest.MockBackend{Sub: m.sub(1)}
t.Cleanup(stripetest.Install(m.mock))
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode, m.stripeSubID, "test:setup"); err != nil {
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), m.stripeSubID, "test:setup"); err != nil {
t.Fatalf("setup reconcile: %v", err)
}
@@ -122,7 +122,7 @@ func seedSwitchTimingMember(t *testing.T) switchTimingMember {
m.sctx = sctx
m.h, err = server.NewMemberProductsHandler(server.MemberProductsConfig{
EntitlementsQ: eq, BillingQ: bq, AuthConfig: &auth.Config{SessionManager: sm}, Logger: discardLogger(), Database: database,
KeyMode: stripetest.KeyMode,
StripeMode: stripetest.KeyMode(),
})
if err != nil {
t.Fatalf("new handler: %v", err)
+6 -16
View File
@@ -54,11 +54,6 @@ type Config struct {
// key in force (stripe-integration-infrastructure, "Stripe mode is
// derived from the API key").
StripeMode string
// StripeKeyMode answers the mode of the API key the Stripe SDK holds at
// the moment of the call; Checkout, a member's plan moves and the
// reconcile on the checkout return read it per request where StripeMode
// is the boot-time value.
StripeKeyMode func() string
// StripeKeyFingerprint is the SHA-256 digest of the API key, derived
// at boot beside the mode and never the key itself. The Stripe
// provider page's Environment check section compares it with the
@@ -379,10 +374,7 @@ func Start(ctx context.Context, cfg Config) error {
// each mount in cfg.RouteMounts and cfg.UIMounts, every core route, and the
// middleware that runs before them. It sets authConfig.Failures to the
// member template set. cfg.DomainsQ must be set; Start refuses a Config
// without it before it connects to anything. With cfg.StripeAPIKey set,
// cfg.StripeKeyMode must be set too: Checkout, a member's plan moves and the
// reconcile on the checkout return call it, and a nil one fails those
// requests.
// without it before it connects to anything.
func NewHandler(cfg Config, authConfig *auth.Config) (http.Handler, error) {
rt, err := newRouter(cfg, authConfig)
if err != nil {
@@ -572,7 +564,6 @@ func registerCoreHandlers(httpRequestRouter *http.ServeMux, cfg Config, authConf
Logger: cfg.Logger,
Database: cfg.Database,
StripeMode: cfg.StripeMode,
KeyMode: cfg.StripeKeyMode,
StripeKeyFingerprint: cfg.StripeKeyFingerprint,
})
@@ -619,10 +610,9 @@ func registerCoreHandlers(httpRequestRouter *http.ServeMux, cfg Config, authConf
}
memberDomainsHandler.RegisterRoutes(httpRequestRouter)
// Register billing checkout handler. The stripe-go package-level API key
// is set exactly once, by the Stripe integration's Startup hook
// (internal/integrations/stripe.Adapter.Startup) — not here (design.md
// Decision 5; openspec/changes/integration-extraction task 3.4).
// Register billing checkout handler. start sets the stripe-go
// package-level API key once, at boot, through the Stripe integration's
// UseKey — not here; the handler reads the boot mode, cfg.StripeMode.
if cfg.StripeAPIKey != "" {
billingCheckout := &BillingCheckoutHandler{
Database: cfg.Database,
@@ -631,7 +621,7 @@ func registerCoreHandlers(httpRequestRouter *http.ServeMux, cfg Config, authConf
Logger: cfg.Logger,
BaseURL: cfg.BaseURL,
EntitlementsQ: cfg.EntitlementsQ,
KeyMode: cfg.StripeKeyMode,
StripeMode: cfg.StripeMode,
}
httpRequestRouter.HandleFunc("POST /billing/checkout", billingCheckout.HandleCheckout)
}
@@ -953,7 +943,7 @@ func eagerReconcileOnReturn(ctx context.Context, cfg Config, orgID string) {
rctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := fulfillment.ReconcileLatestSubscriptionForCustomer(
rctx, cfg.Database, cfg.Logger, cfg.StripeKeyMode, mapping.StripeCustomerID.String, "eager:checkout-return",
rctx, cfg.Database, cfg.Logger, cfg.StripeMode, mapping.StripeCustomerID.String, "eager:checkout-return",
); err != nil {
cfg.Logger.Warn("eager reconcile on checkout return failed (webhook will reconcile)",
slog.String("org_id", orgID), slog.Any("error", err))
+2 -2
View File
@@ -455,8 +455,8 @@ func Install(m *MockBackend) func() {
// KeyMode is the mode of the API key the Stripe SDK holds, read when it is
// called, as the console's own configuration reads it: "test" once Install
// has set its key, "" when the SDK holds no key. A test passes it where the
// console passes its configured key mode.
// has set its key, "" when the SDK holds no key. A test passes its result,
// stripetest.KeyMode(), where the console passes the mode boot derived.
func KeyMode() string {
return stripestore.ModeForKey(stripe.Key)
}
+4 -6
View File
@@ -18,14 +18,12 @@ import (
type Activities struct {
db *sql.DB
logger *slog.Logger
keyMode func() string
keyMode string
}
// NewActivities constructs an Activities ready for registration. keyMode
// answers the mode of the API key the Stripe SDK holds when the sweep runs;
// the worker is built before the key is set, so it is a function, not a
// value read here.
func NewActivities(db *sql.DB, logger *slog.Logger, keyMode func() string) *Activities {
// NewActivities constructs an Activities ready for registration. keyMode is
// the mode of the key the process runs under, derived at boot.
func NewActivities(db *sql.DB, logger *slog.Logger, keyMode string) *Activities {
if logger == nil {
logger = slog.Default()
}
+5 -5
View File
@@ -36,10 +36,10 @@ type WorkerConfig struct {
// here because this package deliberately holds no viper dependency —
// every other worker input arrives the same way.
DomainsConnectTarget string
// StripeKeyMode answers the mode of the API key the Stripe SDK holds,
// asked when a billing sweep runs: the worker is built before the
// Stripe integration's Startup sets the key.
StripeKeyMode func() string
// StripeMode is the mode of the API key the process runs under ("test",
// "live", or "" with no key), derived at boot; the billing sweep passes
// it to the plan changes it fires.
StripeMode string
// WorkflowProviders registers each installed integration's Temporal
// workflows/activities (see WorkflowProvider below). Populated by the
// composition root (cmd/start.go) from the integration registry
@@ -165,7 +165,7 @@ func NewWorker(c client.Client, cfg WorkerConfig) (*Worker, error) {
// 4. Billing Domain — periodic sweep that fires due subscription scheduled
// changes (backstop to the Stripe-webhook-primary firing path).
w.RegisterWorkflow(wfBilling.SweepScheduledChangesWorkflow)
w.RegisterActivity(wfBilling.NewActivities(cfg.Database, cfg.Logger, cfg.StripeKeyMode))
w.RegisterActivity(wfBilling.NewActivities(cfg.Database, cfg.Logger, cfg.StripeMode))
// 5. Domains Domain — external-claim verification polls DNS for the
// domain-control challenge and activates the claim; it creates no
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-23
@@ -0,0 +1,396 @@
## Context
`member-console start` (`cmd/start.go`, `startCmd.Run`) runs its whole boot on `context.Background()` and opens its listener last, inside `server.Start`. Before the listener it reaches three services besides the database: Temporal (with an OAuth token from the identity provider when Temporal authorization is configured), the session store (one `PING`), and the identity provider's discovery document. Temporal's connection retries within `temporal-connect-timeout` and then exits 1; a discovery or session store failure makes `server.Start` return an error that `Run` logs as `server failed to start` before returning, so the process exits 0 (`cmd/root.go` exits 1 only when `Execute` returns an error).
The consequence the issue reports is a deadlock with an on-demand TLS proxy whose ask is `GET /domains/ask`: the ask needs only the database, but it is not served until the console has reached the identity provider, which may sit behind the same proxy.
Three other facts shape the design.
1. **Handlers capture the Temporal client at registration.** `server.Deps.TemporalClient` and the handler configurations built from it (`member_domains.go`, `operator_partials.go`, the FedWiki `web/` handlers, the Discourse and Stripe adapters) copy a `client.Client` once, when routes are registered. A client that arrives later reaches none of them; a nil one makes the Stripe webhook answer 200 without starting a workflow and the Discourse webhook call a nil interface.
2. **The Stripe SDK's key is set by a Temporal hook.** `stripego.Key` is assigned only in the Stripe adapter's `Startup` (`internal/integrations/stripe/stripe.go`), which runs inside the Temporal block. Checkout and the plan moves read `SDKKeyMode`, the mode of whatever key the SDK holds at the moment of the call; the worker's billing activities and the eager reconcile on the checkout return read the same function.
3. **The auth configuration is filled once, by `auth.Setup`.** `auth.Config` carries `OAuthConfig`, `Verifier`, `Provider`, `EndSessionEndpoint` and `RevocationEndpoint` as exported fields that 48 test files set by hand. Nothing in the package can be ready later.
## Goals / Non-Goals
**Goals:**
- The ask, the static files, the webhooks and existing sessions are served as soon as the database steps have run, whatever Temporal, the identity provider and the session store are doing.
- Sign-in answers 503 until a usable discovery document is published, and recovers without a restart.
- Temporal is retried for the life of the process; no state of Temporal or the identity provider stops the process.
- Every handler sees a connection that arrives after boot, and sees none before it is usable.
- Nothing changes the Stripe SDK's key or the configuration after the listener opens.
- `start` exits non-zero whenever it stops.
**Non-Goals:**
- Graceful shutdown on a signal.
- Request deadlines while Temporal goes away after connecting (#208).
- A health or readiness route.
- Retrying the database at boot. Postgres is not behind the proxy, so its absence cannot deadlock; `start` still exits 1 and an orchestrator restarts it.
## Decisions
### D1. What serves before what
The listener opens once these have run, in this order: the integrations' declaration check, the secret files, configuration validation, the Stripe mode and fingerprint, the database open and its checks (`openDB`), the SDK key, the overrides, the boot data state, the connection binding, the route and workflow mounts, the authentication setup, the router, and the bound listener. The authentication setup probes the session store once and fetches nothing from the identity provider. Temporal's connection and the discovery loop are background work that starts after the port is bound, so a port in use or a failed authentication setup exits having started nothing, and is not waited on.
Every route answers at once. A route that needs a dependency that is not ready answers for that dependency alone (D2, D5, D7, D8, D14, D15): sign-in needs the discovery document; a session's re-derivation needs the verifier; a Temporal-backed action needs the connection; a request that carries or writes a session needs the session store.
Reason: the issue's principle is that routes whose only dependency is the database serve once migrations have run. Gating per route, not per process, is what keeps the ask independent of the identity provider; a process-wide readiness would recreate the deadlock wherever the proxy or an orchestrator acted on it.
### D2. The answer while a dependency is missing
503 Service Unavailable with `Retry-After: 5`. A browser gets the styled error page with a heading that names what is unavailable and one action that repeats the request (copy table rows 1 to 3). An htmx request gets the heading as plain text; 5xx is in every page's `noSwap` list, so nothing is swapped and `error-handler.js` shows its existing 5xx toast. The page shows no internal detail.
Reason: 503 is the status for a temporary condition (RFC 9110, section 15.6.4), and `Retry-After` is its standard companion (section 10.2.3). 502 and 504 say a proxy's upstream failed, which is not what happened. A redirect to a waiting page would add a route and a state. Five seconds is short enough that a person who retries meets an attempt the request itself started (D3) and long enough that an automated client does not spin.
### D3. How the discovery document is held
`auth.Setup` builds a `Config` whose discovery is two holders, each an `atomic.Pointer` to an immutable value written once:
- **the verifier** carries the `*oauth2.Config`, the verifier, the provider and the revocation endpoint, which is what re-deriving a session's roles and revoking its refresh token need; it is published when the document's issuer is the configured one;
- **the sign-in value** carries the end-session endpoint and a pointer to the verifier value it was published with, which is what sign-in needs besides the verifier; it is stored only inside the matching-issuer branch, after the verifier, and only from a document that carries an end-session endpoint.
A background loop (`(*Config).Discover`, started by the bound server's `Serve` once the port is bound) fetches the document from `oidc-idp-issuer-url` with `oidc.NewProvider`, through a context carrying a dedicated `*http.Client` whose `Timeout` is ten seconds, under a context with the same deadline. The provider is built with `oidc.InsecureIssuerURLContext(ctx, issuer)`, so a document whose `issuer` differs does not fail inside go-oidc with an untyped error (v3.12.0 returns a plain `fmt.Errorf`); `providerEndpoints` compares the document's `issuer` claim with the configured issuer and returns a typed `issuerMismatchError`, which the attempt marks `AtCap`. The provider still checks every ID token against the configured issuer. The first attempt whose issuer matches publishes the verifier; the first of the attempts whose issuer matches and whose document also carries `end_session_endpoint` publishes the sign-in value, pointing at the stored verifier, so the verifier can come from the first matching document and the endpoint from a later one. A document whose issuer does not match stores nothing, whatever else it carries. The loop ends when both are published. Neither is ever replaced, and an attempt after the verifier is published fetches the document again and publishes only the sign-in value. `Discover` and `Setup` read every configuration key they need once, before the listener opens.
A request to `/login`, `/register` or `/callback` that finds the sign-in holder empty (it is never filled while the verifier is empty) sends a non-blocking wake on a channel with a buffer of one and answers 503 at once; it does not wait for the attempt. The loop's wait ends on a wake, but no sooner than one second after the previous attempt began, and a wake does not shorten a wait that follows a document the loop marked `AtCap` (D4, D17), so a burst of sign-in requests makes at most one attempt per second, one at a time, and an unusable document is fetched at the cap and no faster.
Every handler that needs the discovery reads each pointer it needs once per request and uses that one value for the whole request. `/login`, `/register`, `/callback` and `POST /logout` load the sign-in pointer and fall back to the verifier pointer only when it is nil, so the endpoint and the verifier they use always come from one published state; without that rule, `POST /logout` could load a nil verifier, then find an endpoint stored in between, and take the end-session path with no verifier to mint the hint or revoke tokens with. Re-derivation loads the verifier pointer. A `Config` that has no holders (every hand-built configuration, which is every test that builds an `auth.Config` literal) reads values built from its exported fields, exactly as it does today: both accessors return a value whenever `OAuthConfig` is set, copying the fields as they are, an empty `EndSessionEndpoint` and a nil `Verifier` included. The gates and the branch of D5 test the pointer and not the endpoint's string, so a hand-built `Config` with an empty endpoint keeps today's answers, and `refreshIdentity` keeps its nil-`Verifier` check, applied to the value. `Setup` never sets the exported fields, so a running console has one path.
Reason: an atomic pointer to an immutable value is the cheapest correct way to publish one value from one writer to many readers; `sync.Once` and `sync.OnceValue` would cache a failure, and a mutex around a lazy fetch would make a request wait on the identity provider. Two holders, not one, because what a signed-in session needs (the verifier) must not wait for what only sign-in needs (the end-session endpoint): one value published only when both are present would stop re-deriving every session's roles for as long as a provider's document lacks the endpoint (D4). The provider keeps the context's client for its key set (go-oidc `oidc.go`, `remoteKeySet`), so the dedicated client also bounds every later JWKS fetch, which today uses `http.DefaultClient` with no timeout. A per-request fetch under `singleflight` would put the identity provider's latency inside sign-in requests and would still need the cache. Endpoints from configuration (`ProviderConfig`) would add configuration keys and drop the issuer check; discovery from an internal URL is a deployment workaround that keeps the coupling.
### D4. A discovered document that cannot be used
A document whose issuer does not match (`providerEndpoints` returns `issuerMismatchError`) publishes neither value. A document whose issuer matches but that publishes no `end_session_endpoint` publishes the verifier and not the sign-in value. In both cases the loop logs the document at Error (copy row 10), tries again at the backoff cap, and sign-in stays 503; everything else keeps serving, and signed-in sessions are re-derived whenever the verifier is published.
Reason: a fix at the provider then needs no restart, and the ask keeps answering, which is the issue's point. Exiting non-zero, as the current spec says, would bring the deadlock back for exactly this misconfiguration, since every restart would fetch the document again before serving. Publishing the verifier alone keeps the guarantee the current spec gives sessions: a role removed at the provider is gone within the interval, which an all-or-nothing publication would turn into the session's seven days for as long as the document lacks the endpoint. While the issuer does not match, no verifier exists and no session is re-derived; F7 names that window.
### D5. Sign-out before discovery
`POST /logout` with the sign-in holder empty destroys the console session, mints no hint, logs copy row 20 and lands on `/` (`HX-Redirect: /` with 200 for the account menu's htmx request, 303 otherwise). It revokes the session's refresh tokens at the revocation endpoint when the verifier is published and the document carries one, and revokes nothing when the verifier is empty. `GET /logout` and `/logout-callback` are unchanged; neither needs the discovery.
Reason: the existing guarantee is that leaving the provider's page still signs out of the console; ending the console session is the part the console can always do, and revoking the refresh token is the part the verifier holder lets it do. A 503 that kept the session would leave a person signed in who asked to sign out. The provider's session stays, as it does today for a person who never completes the provider's round trip.
### D6. Temporal connects in the background
When `temporal-host` is set, `start` hands an empty holder (D7) to the router and, once the listener's port is bound (D19), starts `connectTemporal` in a goroutine. One attempt is: `client.DialContext` under a 20-second deadline, the worker preconditions (`newCheckedWorker`), the worker's construction and `worker.Start`. An attempt that fails at any step closes the client it dialled and is retried with the policy of D17, except the open-runs refusal (D10). After a successful attempt the connector publishes the client, then runs each integration's `Startup` hook, the recompute and plan-act pollers, the desired-state relay and read-back schedules, the Stripe environment check and public-details read, and the three sweep schedules, in today's order, and logs copy row 14 once.
`temporal-connect-timeout` keeps its name and its default of 90 seconds and becomes the time after which the connector's log lines are Error instead of Warn; the first line after it passes is written at once. Retrying continues.
Reason: an exit on a budget is what turns an unreachable Temporal into a restart loop that serves nothing. Keeping the key keeps every existing configuration valid; retiring it would change every host's configuration for no gain. Twenty seconds covers the token request's own ten-second timeout and the SDK's five-second system-info call. A worker that cannot start is retried rather than fatal because `worker.Start` can fail on a namespace call that a later attempt passes.
### D7. Handlers read the connection per request
A new leaf package, `internal/workflows/clientholder`, holds the Temporal client and the sentinel `ErrNotConnected`: `Holder.Client()` returns nil until `Publish`, then the published client for the life of the process; a nil `*Holder` returns nil, so a hand-built configuration without one works. A deployment without `temporal-host` gets a holder that is never published; `start` always builds one. `Publish` stores once; a second call panics, because two publishers are a programming error. `server.Config`, `server.Deps` and every handler configuration that held a `client.Client` hold the `*clientholder.Holder` instead (a field named `TemporalClient` is renamed `Temporal`), and each handler reads `Client()` once when a request arrives and keeps its existing nil branch. The Stripe and Discourse adapters read it inside the functions they build at registration, not when they build them.
The connector publishes after `worker.Start` has returned and before the first `Startup` hook runs. The atomic store and load order the two: a handler that sees the client sees a worker that is polling, and every hook's sweep runs after the first request that could have recorded a row with the client visible.
Reason: a value captured at registration is the reason a late connection reaches nobody. Publishing after the worker starts means a workflow a handler starts has a worker; publishing before the hooks means the Stripe webhook sweep, the outbox sweep and FedWiki's lifecycle settle pass see every row recorded while the holder was empty. A structural test refuses a struct field of type `client.Client` in `internal/server` and in the integrations' `web` packages, so a captured client cannot come back unnoticed.
### D8. The Stripe webhook before Temporal is connected
The handler verifies the signature and records the event as today. Its `StartProcessing` reads the holder; with no client it returns `clientholder.ErrNotConnected` (whose text is `Temporal is not connected`), and the handler answers 503 with `Retry-After: 5` and the body of copy row 4, logs copy row 22 at Warn, and keeps the row `received`. Any other start failure still answers 500.
Reason: Stripe retries a non-2xx delivery, so 503 keeps the endpoint's rule that a 2xx means recorded and started. Recording before answering keeps the event even if Stripe gives up; the redelivery's duplicate branch, or the boot sweep that runs once Temporal connects, starts its workflow, and a second start of the same event id is a no-op while the first run is running, and a start after it completed returns at the activity's status guard (F34). Answering 200 and relying on the sweep alone would depend on publication order for correctness; D7 gives that order, but the 503 does not need it.
### D9. The Discourse webhook before Temporal is connected
`StartReconcile` reads the holder; with no client it returns the same sentinel error, `process` records the row `failed` with `start reconcile: Temporal is not connected` (copy row 5), and the handler answers 200, as it answers every recorded event.
Reason: the receiver's rule is that a recorded event is acknowledged and the periodic sweep is the correctness net; a 5xx would only make Discourse redeliver an event already recorded. The nil interface call that panics today is gone.
### D10. The FedWiki removed-types refusal after a late connect
The precondition runs inside the connection attempt. When it finds an open run of a removed type, the connector starts no worker, publishes no client, logs the existing `refused to start the Temporal worker` line at Error with the same decay as D17, keeps the dialled client, and reads the list again at the backoff cap; once no run is open the attempt continues to the worker. When the list cannot be read, the attempt fails like any other: the client is closed and the connection is retried.
Reason: exiting would make each restart serve the ask for a few seconds and then stop. Publishing no client keeps every Temporal-backed action on its "not available" answer and the Stripe webhook on 503, so nothing starts work that the missing types would strand. Reading the list again means an operator who terminates the runs needs no restart, the same reason as D4. A list error says nothing about the runs; today the restart retried it, and the loop must do the same.
### D11. Which integrations need Temporal at boot
`config.ConfigKey` gains `NeedsTemporal`. A key that carries it belongs to a RequiredGroup (`CheckSpecs` refuses it otherwise, copy row 8). When every key of such a group is set and `temporal-host` is empty, `ValidateStart` adds copy row 7 to its aggregated errors. Stripe declares it on `stripe-api-key` and Discourse on `discourse-base-url`. FedWiki does not.
Reason: Stripe and Discourse receive events from outside that are processed only on Temporal; without it, Stripe's renewals, cancellations and payment failures are stored and never applied, and Discourse's events fail and wait for a sweep that is itself a Temporal schedule. The same gap is the one the Stripe key comment on the issue asks to refuse, and Discourse is the second sighting, so one declaration covers both. FedWiki receives no events: each of its actions answers that it is not available, and nothing is stored for a worker that never comes. Declaring the need on the group, not on a hard-coded list in core, keeps core free of integration names.
### D12. The Stripe key is set at boot; the boot mode is the only mode
`start` calls `stripeintegration.UseKey(key)`, which assigns the trimmed key to `stripego.Key` when it is not empty (the mode and the fingerprint are derived from the trimmed key, so the SDK sends the value they describe), once the mode and the fingerprint are derived and before any goroutine starts: it is the first step of the serving half (D19), so the boot test sees it. The adapter's `Startup` no longer writes it. Nothing writes the key after that call, which runs before any goroutine and before the listener.
`SDKKeyMode`, `server.Config.StripeKeyMode` and `workflows.WorkerConfig.StripeKeyMode` are removed. The handlers that took a `KeyMode func() string` (`BillingCheckoutHandler`, the member products and plans handlers) read the boot mode, `StripeMode`. The fulfillment entry points that took `stripeKeyMode func() string` (`SwitchPlan`, `KeepCurrentPlan`, `CancelSubscription`, `ReconcileSubscription`, `ReconcileLatestSubscriptionForCustomer`, `SweepDueScheduledChanges`) take `keyMode string`; the server passes `StripeMode`, the billing activities a `StripeMode string` from `WorkerConfig`, and the Stripe workflows' activities the result of their package-private `sdkKeyMode()`, which reads the key boot set.
Reason: the function existed to read a key that might not be set yet; with the key set before any reader exists, its value is fixed for the life of the process, so a string says what is true and removes the race between the hook's write and the requests' reads. The test helper `stripetest.KeyMode` stays; tests call it (`stripetest.KeyMode()`) where they passed it, which evaluates it at the moment the function would have.
### D13. Configuration writes end before the listener
Every `viper.Set` stays where it is today: the secret files, before `openDB`, and the overrides, inside `serveConsole`; both run before the first `go` statement of `serveConsole`. The background loops and hooks only read configuration, and `Discover`, the connector and the session pool's dial (which reads `valkey-addr` on every dial today; item 4.1 makes it read once) take each key they need once, before the listener opens, so a later read of viper cannot race a test's cleanup. A comment above the first `go` statement says so.
Reason: viper is not safe for a write concurrent with reads. Today the only writes are `resolveSecretPairs` and `config.ApplyOverlay`, both at boot; the new goroutines must not be the first to change that.
### D14. The session store
`auth.Setup` probes the store once with the existing dial timeouts (connect 5 seconds, read and write 3 seconds). An error reply that refuses authentication (a `redis.Error` beginning `WRONGPASS`, `NOAUTH` or `NOPERM`, or an `ERR` reply to `AUTH` during the dial) fails `Setup`, and `start` exits 1. Any other probe error is logged at Error (copy row 17) and `Setup` continues; the pool dials again on each request. That covers a refused or timed-out connection, a TLS handshake that fails, and the error replies that say the store is busy, not that it refuses the console: `LOADING` (the store answers `PING` with it while it loads its dataset, and `AUTH` is allowed then), `BUSY` and `MASTERDOWN`.
The classification is by origin, not by error type. The store handed to the session manager is wrapped (`taggedStore{scs.Store}`, implementing `Find`, `Commit` and `Delete` and the `Ctx` variants scs prefers) so that every error the store returns is wrapped in `storeError`. `NewAwaitingDiscovery` does the wrapping and sets the session manager's `ErrorFunc`, so `Setup` and the server's tests get both from one place. The `ErrorFunc` answers 503, `Retry-After: 5` and copy row 3 exactly when `errors.As(err, &storeError)`, and the styled 500 page, as the default does today, for any other error (a session the codec cannot decode); each writes one structured log line (copy row 19) in place of scs's unstructured one. The page's action is `validReturnTo(r.URL.RequestURI())` for a GET or HEAD and `/` when it refuses or for any other method, because a request line such as `//host/x` reaches `LoadAndSave` before the mux.
The session layer is wrapped in a latch writer, which lives in `internal/auth` beside the `ErrorFunc` (`internal/auth` cannot import `internal/server`): `(*Config).LoadAndSave(next)` wraps the response writer and calls the session manager's `LoadAndSave`, and `preAuthStack` takes that method. scs commits inside the handler's first `WriteHeader` or `Write` and then passes that write on to the response anyway, so a handler that set headers before its first write (`LoginHandler`'s `Location`, `bounceToLogin`'s `HX-Redirect`) would have them copied into the 503 and its body appended to the page. The `ErrorFunc`, handed the latch writer, deletes what a handler may have set (every header whose name begins `HX-`, such as `HX-Redirect`, `HX-Refresh` and `HX-Trigger`, plus `Location`, `Set-Cookie` and `Content-Type`; a handler such as the workspace partial puts a value in the session and sets `HX-Refresh: true` without writing, and htmx 4 reloads the page on it), writes its 503 or 500, and marks the latch; the latch drops every later `WriteHeader` and `Write`. Requests to `/static/`, `/domains/ask`, `/favicon.ico` and the paths the integrations declare self-authenticated (the webhooks) skip the session middleware.
Reason: an authentication refusal proves the configuration is wrong, and the existing spec keeps a wrong or missing password fatal; no answer, and a store that answers `LOADING` while it reads its dataset, are states that change, and a restart of everything together, which is the issue's own scenario, meets the second, and the ask needs no session. A type list cannot tell the store from the codec: `GobCodec.Decode` returns `io.EOF` and `io.ErrUnexpectedEOF` for an empty or truncated value, which a list that included them would answer 503 with `Retry-After` for the cookie's week, and redigo returns a failed TLS handshake and a protocol error as types no list names. The 503 page's own stylesheet request carries the session cookie, so without the skip it would 503 as well and the page would render unstyled; the ask and the webhooks read no session either, so a caller that happens to carry a cookie must not depend on the store. A request without a cookie that writes a session (`/login`, `/register`, a bounce that stores its return path) reaches the store only when scs commits, since `Load` makes no store call for an empty token; the latch is what makes that commit's 503 the whole answer.
### D15. A bounded grant needs Temporal before it is written
The issuance handler, after it parses and checks the end date and before it calls `ConferGrant`, refuses a submission with `valid_until` while `Temporal.Client()` is nil, with copy row 6 as the field error on the end date, so a past or malformed date keeps its own field error. The grant management handler refuses, before its write and inside the `ValidUntil` callback, a save whose new bound is set (`bound.Valid`) and differs from the grant's current one, under the same condition and in the same place relative to the parse. A save that clears the bound is not refused. The "no scheduler" branches after the write, and their messages, are removed; the branches for a start that fails while connected stay.
Reason: the expiration workflow is the only thing that ends a bounded grant, so writing the grant without it creates an entitlement that never ends. Refusing before the write covers both a deployment without Temporal and the window before it connects. Clearing a bound starts no workflow (the schedule is started only when the result's `ValidUntil` is valid), so refusing it would block a write that needs nothing from Temporal.
### D16. No probe route
No route is added. An anonymous `GET /` keeps answering 302 to `/login`; it reads neither the identity provider, nor Temporal, nor the session store (no cookie).
Reason: `/` already answers from the console alone, and a liveness route would be new surface the issue does not need. A readiness route that included Temporal or the identity provider would make an orchestrator restart the console into the deadlock.
### D17. One retry loop for both dependencies
A new leaf package, `internal/retryloop`, runs an attempt function until it succeeds or the context ends, with an injected clock and random source:
| Parameter | Value |
|---|---|
| First wait | 0.5 seconds |
| Growth | doubling per failed attempt |
| Cap | 15 seconds |
| Jitter | each wait plus a uniform random fraction up to half of it |
| Wake | ends the wait, no sooner than 1 second after the last attempt began; it does not end a wait that follows an `AtCap` result |
| A result that says "retry at the cap" (D4, D10) | the next wait is the cap |
| Log: first failure | at once |
| Log: error text differs from the last logged | at once, at most one line per 30 seconds |
| Log: otherwise | one line per 5 minutes, carrying the attempts since the last line |
| Level | Warn until the escalation time, Error after; the first line after it is written at once; a failure's own level is a floor (Error for copy rows 10, 15 and 16, from the first line) |
| Escalation time | Temporal: `temporal-connect-timeout` (90 seconds by default); discovery: 90 seconds |
| Success | one Info line with the attempts and the elapsed time |
An attempt returns a plain error, which the loop logs with the policy's failure message at Warn, or a `retryloop.Failure{Message, Level, Err}`, optionally marked `AtCap`, which carries its own message and level floor. Every line an attempt's failure produces goes through the decay: the callers log nothing from inside an attempt, so a `worker.Start` that fails on every attempt writes the bounded count below and not a line per attempt.
Each line carries `attempts`, `elapsed`, `retry_in` and `error`. With a steady error, an hour of retries writes at most 1 + 1 + 12 lines; with an error text that changes on every attempt, at most 120 more.
`dialWithRetry` and its budget are removed from `internal/workflows/client.go`; `NewClient` becomes a single `DialContext` attempt, and the connector drives it through the loop.
Reason: one policy, one test of it, and one place where the log bound is proved. The cap keeps the recovery delay under about 23 seconds after a long outage, at four attempts a minute. Log decay keyed on the error text keeps a change of cause visible (refused, then an authorization failure once Temporal is up) without the 720 lines an hour that a Warn per attempt at the old 5-second cap would write.
### D18. `start` exits non-zero when serving fails
When the serving half returns, `Run` logs `server failed to start` as today and calls `os.Exit(1)`, as every other failure in the command does. The serving half closes the server when its context ends and returns, which only the tests use.
Reason: an orchestrator that restarts only on failure would leave a process that exited 0 stopped. `RunE` would make cobra print the command's usage on an error unless silenced, and the command's failures are already logged as structured lines.
### D19. Tests use the real server and fake dependencies
The serving half of `Run` becomes `serveConsole(ctx, logger, boot, database, integs, listen)`, which takes the opened database, the values `Run` derived before the database opened (the Stripe mode, the key's fingerprint and the integrations' specs, carried in `boot`) and a function that opens the listener, so a test passes `net.Listen("tcp", "127.0.0.1:0")` and learns the address.
`server.Serve` is two steps: `server.Bind(cfg, listen)` runs `auth.Setup`, `NewHandler` and `listen` and returns the bound server, and `(*Bound).Serve(ctx)` starts the discovery loop and serves. `serveConsole` starts `connectTemporal` between the two, so a port in use or a `WRONGPASS` at `Setup` exits 1 with neither the connector nor the discovery loop started. When its context ends, `serveConsole` calls `Shutdown` with a bounded context, then waits (a `sync.WaitGroup`) for `connectTemporal` and `Discover` to return before it returns itself, so a test's cleanup restores viper keys, `stripe.Key` and the discovery timeout with nothing still running; the test's `listen` sends the address on a channel, which is the happens-before edge a read of `stripe.Key` needs. The boot test (`cmd`, a database test) runs it with `temporal-host` naming a listener that accepts, counts and closes each connection (a closed port records nothing, so a test that waits for a stray dial could not fail), the connector's first wait and cap shortened to 50 milliseconds, `oidc-idp-issuer-url` naming an `httptest` server whose answer the test switches, and the stack's session store. Temporal's recovery and the publication order are proved by the connector's own test, with an injected dial, worker and hooks.
Reason: the stack's Temporal is shared with any application running on the same worktree's stack, on the same task queue, so a worker the test started there could take that application's tasks or hand it the test's. An address that accepts and drops every connection proves the serving claims; injected fakes prove the order without a shared server.
### D20. Docs
`docs/production-deployment.md` section 7 lists the steps in the order the code runs them (configuration validation first, then the database steps, the overrides and boot data, the session store probe, and the listener), says that the ask, the webhooks and signed-in pages serve before Temporal and the identity provider answer, that sign-in answers 503 until a usable discovery document is published, and that `start` keeps retrying Temporal and logs at Error after `temporal-connect-timeout`. `docs/environment-reference.md` rewrites the `temporal-connect-timeout` row and says on `temporal-host` that a configured Stripe or Discourse needs it. `docs/fedwiki-setup.md` says the ask answers as soon as the console serves, whatever Temporal and the identity provider do. `docs/identity-provider-setup.md` and `internal/auth/README.md` replace "refuses to start" with "keeps sign-in unavailable, and retries". `docs/temporal-authorization-setup.md` says that an internal token URL keeps the Temporal connection off the TLS proxy, which the console no longer needs before it serves. `docs/stripe.md` and `docs/discourse-setup.md` say that `temporal-host` is required. The code comments that state the old order (`cmd/start.go` above the Temporal block and `startStripeEnvironmentCheck`, `server.Start` and `NewHandler`'s doc comments, the comment above the checkout handler's registration, the Stripe adapter's `Startup` comment) change with the code they describe.
### D21. The change in flight beside this one
`runtime-database-user` modifies "Boot-time configuration validation" and "Connection strings and secrets never reach the logs" in `startup-configuration`, "A production deployment guide exists under docs/" in `deployment-docs`, and "Conformant integration registration" in `discourse-integration-infrastructure`. This change modifies none of them; it modifies "Bootstrap configuration via ConfigSpec" in `discourse-integration-infrastructure`, which that change does not touch, and adds its own requirements elsewhere, so the two changes archive in either order without one dropping the other's text.
## The boot sequence
Before, as `cmd/start.go` runs today:
1. `config.CheckSpecs` over the integrations' declarations; exit 1.
2. `resolveSecretFiles`; exit 1.
3. `config.ValidateStart`; exit 1.
4. The Stripe mode and fingerprint from the key; exit 1 on an unknown prefix.
5. `openDB(dbRuntime)`: the runtime pool, the migrations and the boot grant on the migration connection when `db-migrate-dsn` is set, the runtime-user checks; exit 1.
6. The overrides (the last `viper.Set`); exit 1.
7. `ensureBootDataState`; exit 1.
8. `bindIntegrationConnections`; exit 1.
9. The route, workflow, UI and card mounts.
10. When `temporal-host` is set: `workflows.NewClient`, retried within `temporal-connect-timeout`; exit 1 when it runs out.
11. The worker preconditions, the worker, `worker.Start`; exit 1.
12. The `Startup` hooks (FedWiki, Stripe, which sets `stripego.Key`, Discourse).
13. The pollers, the relay, the read-back schedules.
14. The Stripe environment check and public-details read.
15. The sweep schedules. Without `temporal-host`: a Warn and the desired-state backlog.
16. `server.Start`: `auth.Setup` (the session store `PING`, then `oidc.NewProvider` and the end-session check), `NewHandler`, `ListenAndServe`. A failure is logged and the process exits 0.
After:
1. `config.CheckSpecs`, now also refusing `NeedsTemporal` outside a group; exit 1.
2. `resolveSecretFiles`; exit 1.
3. `config.ValidateStart`, now also refusing a configured group that needs Temporal without `temporal-host`; exit 1.
4. The Stripe mode and fingerprint; exit 1 on an unknown prefix.
5. `openDB(dbRuntime)`, unchanged; exit 1. `Run` then calls the serving half, `serveConsole`, passing the mode, the fingerprint and the specs it derived; its steps follow.
6. `stripeintegration.UseKey`, then the overrides (the last `viper.Set`); exit 1.
7. `ensureBootDataState`; exit 1.
8. `bindIntegrationConnections`; exit 1.
9. The mounts.
10. An empty Temporal holder.
11. `server.Bind`: `auth.Setup` (the session store probe: an authentication refusal exits 1, any other probe error logs and continues; the tagged store and the session manager with its `ErrorFunc`; a `Config` with empty discovery holders; nothing is fetched from the identity provider), `NewHandler`, and the bound listener. A failure at any of these exits 1 with nothing started.
12. When `temporal-host` is set, `connectTemporal` starts in a goroutine; without it, the Warn and the backlog as today.
13. `(*Bound).Serve`: the discovery loop starts in a goroutine and the server serves. When serving stops, `start` exits 1.
In the background, `connectTemporal`: the attempts of D6 until one succeeds, then `Publish`, then today's steps 12 to 15 in today's order. The discovery loop: the attempts of D3 until the verifier and the end-session endpoint are published.
## The readiness holders
| | Discovery | Temporal |
|---|---|---|
| Type | two `atomic.Pointer`s inside `auth.Config`, each to an unexported immutable value: the verifier (OAuth configuration, verifier, provider, revocation endpoint) and the sign-in value (the end-session endpoint and a pointer to the verifier value it was published with) | `clientholder.Holder` (an `atomic.Pointer` to an entry holding a `client.Client`) |
| Writer | the discovery loop, once for each: the verifier from the first matching document, the sign-in value from the first matching document that carries an end-session endpoint, which may be a later one | `connectTemporal`, once; a second `Publish` panics |
| Readers | the verifier pointer: the middleware's interval re-derivation, and `POST /logout` only when the sign-in pointer is nil (the revocation); the sign-in pointer: `/login`, `/register`, `/callback` and `POST /logout` (the endpoint, and through the pointer it holds the verifier for the hint and the revocation) | every handler of route group E, the Stripe and Discourse webhooks |
| Empty means | not fetched yet, or not usable (D4): the verifier stays empty while the issuer does not match, the sign-in value while no matching document carries an end-session endpoint | never published (a deployment without `temporal-host`), or not connected yet, or refused (D10) |
| Read rule | one `Load` per request; the request uses that value throughout | one `Client()` per request, at its start |
| Replaced | never | never |
| Ordering | the value is fully built before `Store` | `Publish` after `worker.Start` returns and before the first `Startup` hook |
| Closed | never | at process exit only |
A request that read an empty holder answers for that state even if the holder is filled while it runs; the next request sees the value. No reader blocks.
## Routes while a dependency is missing
| Route group | Temporal not connected | Discovery not published | Session store not answering |
|---|---|---|---|
| `GET /domains/ask` | serves | serves | serves (no session load) |
| `/static/`, `/static/<key>/` | serves | serves | serves (no session load) |
| `/favicon.ico` (no route serves it; the styled 404 answers) | answers | answers | answers (no session load) |
| Unmatched paths (styled 404), `GET /logout`, `GET /domains` | serve | serve | serve without a cookie; 503 with one |
| `POST /webhooks/stripe` | records, 503 with `Retry-After` | serves | serves (no session load) |
| `POST /webhooks/discourse` | records `failed`, 200 | serves | serves (no session load) |
| `/login`, `/register`, `/callback` | serve | 503 until the verifier and the end-session endpoint are both published; the styled page, nothing written to the session | 503, and nothing the handler wrote follows it |
| `POST /logout` | serves | ends the console session, revokes the refresh tokens when the verifier is published, lands on `/` | 503 |
| `/logout-callback` | serves | serves | serves without a cookie; 503 with one |
| Signed-in pages and partials | serve; their Temporal-backed parts answer as without Temporal | serve; roles are re-derived whenever the verifier is published, and otherwise keep the session's last known identity and roles | 503 |
| Temporal-backed actions (domain claims, FedWiki site actions, Stripe environment check and public details, drains, schedule health) | the existing "not available" answer; a best-effort drain or schedule read does nothing | as signed-in pages | 503 |
| Issuing or changing a bounded grant | refused before writing (copy row 6) | as signed-in pages | 503 |
| Checkout and plan moves | serve; the Stripe key is set at boot | as signed-in pages | 503 |
| Anonymous `GET /` | 302 to `/login` | 302 to `/login` | 302 to `/login` |
## Failure table
| # | Way serving early breaks | Where | Handling |
|---|---|---|---|
| F1 | Handlers capture the Temporal client at registration and refuse forever; the Stripe webhook answers 200 without starting | `stripe.go` `RegisterRoutes`, `server.go` `registerIntegrationRoutes` and `registerCoreHandlers`, `fedwiki.go` `RegisterRoutes` | D7: a holder read per request; a structural test |
| F2 | The Stripe webhook answers 200 with no workflow and no later sweep | `stripe/web/webhook.go` | D8: recorded, 503 |
| F3 | The Discourse webhook calls a nil interface and panics | `discourse.go` `StartReconcile` | D9: the sentinel error, the row `failed` |
| F4 | A client for an unreachable Temporal stalls handlers past the 8-second write deadline | SDK default call timeout 10 seconds, FedWiki start timeout 10 seconds, `server.go` WriteTimeout | D7: nothing is published before an attempt has succeeded; after connect, #208 |
| F5 | `/login` and `POST /logout` dereference a nil OAuth configuration | `auth.go` `LoginHandler`, `LogoutHandler` | D3, D5: gated on the holders |
| F6 | Discovery fields swapped while handlers read them | `auth.Config` | D3: two atomic pointers, each to an immutable value written once |
| F7 | A session cannot be re-derived while no verifier is published. The window is unbounded while the provider's document names a different issuer (D4), and lasts until the first fetch succeeds after a cold start. A removed operator role or a disabled account keeps working for the session's seven days in that window | `refreshIdentity` | D3, D4: the verifier is published on its own as soon as the issuer matches, so a document without `end_session_endpoint` does not open the window; with no verifier `refreshIdentity` keeps the last known state and asks nothing, and no new session is made without discovery because `/callback` is gated. Named in Risks |
| F8 | `stripe.Key` written by a hook while requests read it | `stripe.go` `Startup` | D12: set before any goroutine |
| F9 | Checkout and the plan moves read the SDK key's mode while pages read the boot mode | `billing.go`, `member_plans.go` | D12: the boot mode only |
| F10 | A bounded grant written with no expiry workflow | `operator_enrollment.go` | D15 |
| F11 | A retry loop that never ends floods the log | `client.go` `dialWithRetry` | D17: log decay with a proved bound |
| F12 | A loop that spins on a permanent error | discovery, Temporal authorization | D17: capped backoff; D4 and D10 retry at the cap |
| F13 | One discovery attempt hangs forever | `auth.Setup`, `http.DefaultClient` | D3: a dedicated client and a deadline |
| F14 | A probe that includes Temporal or the identity provider restarts the console into the deadlock | | D16: no such probe |
| F15 | `viper.Set` racing reads in the background | `overlay.go`, `resolveSecretPairs` | D13 |
| F16 | The FedWiki removed-types refusal arrives after the listener opened | `newCheckedWorker` | D10 |
| F17 | Lifecycle requests and drains started in the window | `requests.go`, `fedwiki.go` | Handlers refuse without a client; the settle passes and the pollers run after connect |
| F18 | The environment check clears verification after the listener opens | `startStripeEnvironmentCheck` | Harmless: an unverified mapping blocks nothing |
| F19 | The session store down: scs answers a plain 500 and logs an unstructured line | scs `defaultErrorFunc` | D14: an `ErrorFunc` |
| F20 | The process exits 0 when serving fails | `startCmd.Run` | D18 |
| F21 | Tests that build `auth.Config` and key-mode literals break | 48 and 22 test files | D3 keeps the exported fields as the path of a hand-built `Config`; D12 moves key-mode callers to `stripetest.KeyMode()` on purpose |
| F22 | An htmx request gets a 503 body swapped into its target | `form-conventions` | Not a risk: 5xx is in `noSwap`; the existing toast shows |
| F23 | A request without a cookie that writes a session reaches the store only at commit (`Load` makes no store call for an empty token), and scs commits inside the handler's first write, then passes that write on; the handler's `Location` or `HX-Redirect` header and body follow the 503 | scs `commitAndWriteSessionCookie` (`/login`, `/register`, a bounce that stores a return path) | D14: the latch writer; the `ErrorFunc` removes what the handler set and the latch drops every later write |
| F24 | The 503 page's stylesheet request carries the cookie and fails too | `/static/` behind `LoadAndSave` | D14: `/static/` skips the session middleware |
| F25 | A caller of the ask or a webhook that carries a cookie depends on the store | `preAuthStack` | D14: those paths skip the session middleware |
| F26 | A commit that fails after the handler began its answer: scs calls `ErrorFunc` from inside the handler's first write, so its answer goes first and the handler's headers and body follow | scs `sessionResponseWriter` | D14: the latch; this is F23's case, and no request is accepted with a handler's output after the 503 |
| F41 | A store error classified by type: the codec's `io.EOF` and `io.ErrUnexpectedEOF` read as an outage, and a failed TLS handshake or protocol error reads as a decode failure | `ErrorFunc` | D14: the store is tagged at the source; the `ErrorFunc` classifies by `errors.As(err, &storeError)` |
| F42 | A document whose issuer matches but that lacks `end_session_endpoint` stops every session's re-derivation if the two are published together | `Discover` | D3, D4: two holders; the verifier is published alone |
| F43 | `serveConsole` returns while its goroutines and handlers run, and a test's cleanup races them | `serveConsole` | D19: `Shutdown` with a bounded context, then a wait for `connectTemporal` and `Discover` |
| F44 | A background loop starts, then a port in use or a failed `Setup` exits 1 and kills it mid-task | `serveConsole` | D19: `Bind` runs `Setup`, `NewHandler` and `listen` before `connectTemporal` and `Discover` start |
| F45 | Every attempt of a `worker.Start` that fails logs inside the attempt, outside the decay | `connectTemporal` | D17: the attempt returns a `retryloop.Failure`; the loop logs every line |
| F27 | A session the store returns but the codec cannot decode answers 500 on every request for that cookie, for the cookie's life | scs `Load` | Unchanged, now with a structured line; a candidate issue (expire the cookie) |
| F28 | `/register` needs no discovery for its redirect, but its flow ends at `/callback`, which does | `RegistrationHandler` | Gated with sign-in |
| F29 | Sign-out reads the token endpoint and the revocation endpoint from the discovery | `idTokenHint`, `revokeRefreshTokens` | D5: both read the same per-request value; empty means no hint and no revocation |
| F30 | The removed-types list cannot be read after a late connect: today the restart retried it | `checkNoRemovedRuns` | D10: a failed attempt |
| F31 | The worker fails to build or start after a late connect: today exit 1 | `newCheckedWorker`, `worker.Start` | D6: a failed attempt |
| F32 | The key-mode function also reaches the worker's billing activities and the eager reconcile on the checkout return | `worker.go`, `server.go` `eagerReconcileOnReturn` | D12: the mode is a string everywhere |
| F33 | The provider's JWKS fetch uses `http.DefaultClient` with no timeout | go-oidc `remoteKeySet` | D3: the provider keeps the dedicated client |
| F34 | The Stripe redelivery and the post-connect sweep start the same row's workflow. The workflow id is the event id and the conflict policy is `USE_EXISTING`, which is a no-op only for a running execution: the default reuse policy runs a completed id again, so a sweep that read a row as `received` before a redelivery's run completed starts a second run, and `ProcessWebhookEvent` marks `processing` with no status guard, so the finished event is dispatched twice | `SweepUnfinishedWebhookEvents`, the duplicate branch, `ProcessWebhookEvent` | Made idempotent in the activity: its first `UPDATE` applies only to a row at `received`, `processing` or `failed` and the activity returns when it touches no row. The check cannot live in the sweep or the duplicate branch, which read the status and start the workflow as two steps. A quick fix listed in the proposal |
| F35 | A burst of sign-in requests while the identity provider refuses fast | the wake | D3: at most one attempt per second, one at a time; a wake does not shorten a wait that follows an unusable document, so such a document is fetched at the cap |
| F46 | The 503 page's action built from `RequestURI` unchecked: a request line `//host/x` becomes a protocol-relative link | `ErrorFunc` | D14: `validReturnTo`, and `/` when it refuses |
| F47 | Clearing a grant's end date refused though it starts no workflow | `operator_enrollment.go` | D15: only a set, changed bound is refused |
| F36 | Two publishers of the Temporal client | `clientholder` | `Publish` panics on a second call |
| F37 | A Temporal that goes away after publication | | Out of scope (#208); the holder is never emptied, so handlers fail on their own errors as they do today |
| F38 | One `ErrorFunc` line per failing request during a store outage | `ErrorFunc` | Accepted: the same count as scs's line today, now structured |
| F39 | The boot test connects a worker to the stack's shared Temporal | | D19: an address that drops every connection, and injected fakes |
| F40 | Two changes in flight modify `startup-configuration` and `deployment-docs` | | D21 |
## Copy
Every sentence a person reads that this change adds or changes. The UI copy rules (`AGENTS.md`, `openspec/specs/ui-vocabulary/spec.md` "Copy earns its place") apply: no filler phrase, no em dash, facts only. Angle brackets mark a value filled in at run time. Every entry waits for approval.
### Pages and answers
| # | Where | Text |
|---|---|---|
| 1 | `/login` and `/callback` before discovery: the styled page's heading and action (to `/login`); the heading alone for htmx | heading `Sign-in unavailable`, action `Try again` |
| 2 | `/register` before discovery (action to `/register`) | heading `Registration unavailable`, action `Try again` |
| 3 | A request the session store cannot serve (action to the request's path for a GET or HEAD when `validReturnTo` accepts it, `/` otherwise) | heading `Sign-in unavailable`, action `Try again` |
| 4 | `POST /webhooks/stripe` before Temporal connects, the 503 body Stripe shows in its delivery log | `Temporal is not connected` |
| 5 | `core.webhook_events.error_message` of a Discourse event before Temporal connects | `start reconcile: Temporal is not connected` |
| 6 | Issue grant and grant management, the end date's field error while Temporal is not connected | `An end date needs the expiration scheduler, which is not connected.` |
Reused unchanged: the existing "not available" answers of the Temporal-backed actions (for example `Domain verification is not available right now.`, `Site creation service is not available`, `Temporal is unavailable.`) and the recovery page's message `An unexpected error occurred. Try again.` for a session that cannot be decoded.
Changed, a ride-along listed in the proposal's quick fixes:
| # | Where | Text |
|---|---|---|
| 24 | The htmx 5xx toast (`internal/embeds/static/error-handler.js`) | `Server error. Try again later.` in place of `Server error. Please try again later.` No test and no screen carries the string, so `make screens` lists nothing new; `filler-copy` does not scan JavaScript, so task 3.9 checks it. |
Removed: `Grant issued, but expiration scheduling FAILED (no scheduler); it will not auto-expire. Revoke it manually to end it.` and the management path's `Grant saved, but expiration scheduling FAILED (no scheduler); it will not auto-expire. Revoke it manually to end it.`, which D15 makes unreachable.
### Configuration refusals
| # | Where | Text |
|---|---|---|
| 7 | `ValidateStart`, a configured group that needs Temporal without `temporal-host` | `<group> needs Temporal, which processes its events: set temporal-host (or leave <keys> empty to disable it)`, `<keys>` joined with ` and ` as the partial-group refusal joins them; for Stripe: `Stripe needs Temporal, which processes its events: set temporal-host (or leave stripe-api-key and stripe-webhook-secret empty to disable it)` |
| 8 | `CheckSpecs`, a declaration error | `<key>: declares NeedsTemporal without a RequiredGroup; the need belongs to the group whose keys enable the integration` |
### Log lines
| # | Where, level | Text |
|---|---|---|
| 9 | discovery loop, a failed attempt (Warn, Error after 90 seconds) | `identity provider discovery failed; sign-in is unavailable`, attributes `issuer`, `attempts`, `elapsed`, `retry_in`, `error` |
| 10 | discovery loop, a document that cannot be used (Error from the first line) | `identity provider discovery document cannot be used; sign-in is unavailable`, attributes `issuer`, `retry_in`, `error` (the text of `issuerMismatchError` or of `providerEndpoints`' missing-endpoint error) |
| 11 | discovery loop, both values published (Info) | `identity provider discovery succeeded; sign-in is available`, attributes `issuer`, `attempts`, `elapsed` |
| 12 | discovery loop at publication, reused (Warn) | `the identity provider publishes no revocation_endpoint; refresh tokens will not be revoked at sign-out` |
| 13 | connector, a failed attempt (Warn, Error after `temporal-connect-timeout`), reused | `Temporal not ready yet, retrying`, attributes `attempts`, `elapsed`, `retry_in`, `error` |
| 14 | connector, connected (Info), reused | `connected to Temporal server`, attributes `host`, `namespace`, `attempts`, `elapsed`; `connected to Temporal after retry` goes |
| 15 | connector, open runs of a removed type (Error from the first line), message reused, the error text changed | message `refused to start the Temporal worker`, attributes `error`, `retry_in`; the error reads `1 FedWiki workflow run of types this release removed is still open: <types>. Let it finish on the previous release, or terminate it; the worker starts once none is open.`, and in the plural `<n> FedWiki workflow runs of types this release removed are still open: <types>. Let them finish on the previous release, or terminate them; the worker starts once none is open.` (`internal/integrations/fedwiki/fedwiki.go`, `openRunsError`; the old advice, "before starting this release", is wrong once the console is serving) |
| 16 | connector, the worker fails to build or start (Error from the first line), reused | `failed to create Temporal worker` and `failed to start Temporal worker`, attributes `error`, `retry_in` |
| 17 | `auth.Setup`, the session store gives no answer, or an error reply that is not an authentication refusal such as `LOADING` (Error) | `session store not reachable at startup; requests that need a session answer 503`, attributes `addr`, `error` |
| 18 | `start`, the session store refuses authentication (Error), reused | `server failed to start` with the error `failed to connect to Valkey: <error>`, then exit 1 |
| 19 | `ErrorFunc` (Error) | `session store request failed` for a store failure; `session could not be decoded` otherwise; attributes `method`, `path`, `error` |
| 20 | `POST /logout` while the end-session endpoint is not published (Info) | `session ended; the identity provider's session was not ended because its end-session endpoint is not known` |
| 21 | `start`, serving stopped (Error), reused | `server failed to start`, then exit 1 |
| 22 | Stripe webhook before Temporal connects (Warn) | `stripe webhook event recorded before Temporal connected; answering 503 so Stripe redelivers it`, attributes `event_id`, `event_type` |
### Flag help
| # | Flag | Text |
|---|---|---|
| 23 | `--temporal-connect-timeout` (changed) | `Time after startup from which failed Temporal connection attempts log at Error; retrying continues (0 = 90s)` |
## Tests
- **The retry loop** (`internal/retryloop`): an injected clock and attempt function; the waits follow D17's table, jitter included, from a seeded source; a wake ends a wait no sooner than one second after the last attempt; a "retry at the cap" result waits the cap; the loop stops on context end; the count, level and order of log records for an hour of a steady error, of alternating errors and of a recovery.
- **Discovery** (`internal/auth`): an `httptest` identity provider that answers 503, then a document without `end_session_endpoint`, then a usable one; `/login` answers 503 with `Retry-After` and writes no session until publication, then 302 to the provider's authorization endpoint without a restart; a request wakes the loop; an attempt against a server that never answers ends within the deadline; sign-out before publication ends the session and lands on `/`; a signed-in session past its interval passes before publication; a document without `end_session_endpoint` publishes the verifier alone, a session past its interval is re-derived and loses a role removed at the provider while `/login` still answers 503; a document with another issuer publishes nothing, even when it carries `end_session_endpoint`, and no session is re-derived.
- **The not-ready golden** (`internal/server/testdata/routes-not-ready.golden`): the route table with the discovery holder empty and, from section 5, the Temporal holder empty, every row's anonymous and member answers; its diff from `routes.golden` is the list of routes that change.
- **The session store** (`internal/auth`, `internal/server`): an authentication refusal fails `Setup`; a refused address and a store that answers `-LOADING` do not; an `ErrorFunc` 503 for an error the tagged store returns (a network error, a `tls.RecordHeaderError`) and 500 for a decode error, including a store that returns an empty value; a request without a cookie whose commit fails gets the 503 alone, with no `Location`, no `HX-` header (`HX-Redirect`, `HX-Refresh`) and no handler body; `/static/`, `/favicon.ico`, the ask and a declared webhook path are served while the store refuses.
- **The holder and the handlers**: a handler built with an empty holder answers its "not available" answer, and the same handler answers normally once the holder is published, without being rebuilt; the shared operator harnesses and the plan-management harness publish a holder over a fake whose `ExecuteWorkflow` succeeds, so every bounded save in the existing tests keeps its result; the Stripe webhook records and answers 503, then 200 on the redelivery after publication; the Discourse webhook records `failed` with copy row 5; the bounded grant refusals write nothing and clearing an end date is saved; the structural test; a Stripe event at a finished status is not dispatched again by a second run of its activity.
- **The connector** (`cmd`): injected dial, worker and hooks; refused attempts, then a success; `Publish` before the first hook, every hook after; the removed-types refusal starts no worker and publishes nothing, then the worker starts once the fake list is empty; a list error and a worker start error are retried.
- **The boot test** (`cmd`, a database test): `serveConsole` on `127.0.0.1:0` with a Temporal address that accepts, counts and closes each connection and a switchable identity provider; within one second, the ask for a seeded placement is 200, an unknown name 404, `/static/app.css` 200, `/login` 503, a signed Stripe webhook 503 with its row recorded; `stripe.Key` holds the configured key before the first answer; after the switch, `/login` 302 within the loop's wait; a port already in use makes `serveConsole` return an error with no dial recorded in the 500 milliseconds after it returns; after a normal stop, no dial is recorded in those 500 milliseconds either; with `valkey-addr` naming a refused port, the ask answers 200 and 404 with and without a cookie, `/static/app.css` 200 and an anonymous `/` 302 to `/login`.
- **Races**: `go test -race -p 1` after `test/reset-test-db.sh` on every touched package (the packages share one test database, and parallel packages make count-based tests claim each other's rows), with requests in flight across the discovery publication and the Temporal publication.
## Risks / Trade-offs
- **A console that serves without Temporal for a long time.** The ask, sign-in and signed-in pages work, so a broken Temporal is easier to miss than a crash loop. The log escalates to Error after `temporal-connect-timeout` and keeps a line every five minutes, and every Temporal-backed action says it is not available.
- **A session is not re-derived while no verifier is published.** After a cold start the window lasts until the first fetch succeeds. While the provider's document names another issuer it is unbounded: a role removed at the provider, or a disabled account, keeps working for the session's seven days, where the console used to refuse to start. A document that lacks only `end_session_endpoint` does not open the window, because the verifier is published on its own. The Error log at the cap names the cause until it is fixed.
- **The 503 page when the session store is down.** Every signed-in request answers it until the store is back, the same reach as today's plain 500, with a heading a person can read.
- **Bounded grants on a deployment without Temporal.** They can no longer be issued at all. A grant with an end date that nothing ends was the worse outcome.
- **The holder rename touches many handler configurations.** The change is mechanical, the structural test guards it, and the not-ready golden shows each route that answers differently.
@@ -0,0 +1,69 @@
## Why
`member-console start` opens its HTTP port only after two outside services have answered. When `temporal-host` is set it connects to Temporal first, fetching an OAuth token from the identity provider when Temporal authorization is configured, and exits when the connect budget (`temporal-connect-timeout`, 90 seconds by default) runs out. Then `server.Start` reads the identity provider's discovery document from `oidc-idp-issuer-url` (`internal/auth/auth.go`, `oidc.NewProvider`) before it listens. Until both have succeeded no route is served, `GET /domains/ask` included, although the ask needs only the database.
A TLS-terminating proxy calls the ask before it issues or loads an on-demand certificate. When the identity provider's host is itself issued on demand by that proxy, a restart of the proxy and the console together, with an empty certificate cache, deadlocks: the proxy presents no certificate for the identity provider until the ask answers, and the console answers no ask until it has reached the identity provider through the proxy. Every on-demand host stays without TLS until an operator points the proxy's ask somewhere else. Restarting both reproduces it. When the Temporal token URL names the identity provider's public host, the Temporal connect joins the loop; pointing it at an internal address takes it out, but the discovery fetch has to use the public issuer name, so the loop stays. The process also exits and restarts into the same wait each time the Temporal budget runs out.
The same ordering hides three smaller faults. The Stripe SDK's API key is set only by the Stripe integration's startup hook, which runs inside the Temporal block, so a request served before Temporal answers would call Stripe with no key, and Checkout and the plan moves would read the mode of that missing key. A Stripe key with no `temporal-host` already leaves the SDK without a key and stores webhooks nothing processes. And `start` exits with status 0 when serving fails, because it logs the error and returns.
## What Changes
- **The listener opens after the database steps.** `start` validates the configuration, opens and checks the database, applies the operator's overrides, ensures the boot data state and binds the integration connections, then builds an authentication setup that fetches nothing from the identity provider and binds the listener. It starts the background work only once the port is bound, and it waits for neither Temporal nor the identity provider. Routes whose only dependency is the database (the ask first), the provider webhooks and existing sessions are served at once.
- **Sign-in waits for the discovery document; nothing else does.** A background loop fetches the identity provider's discovery document, each attempt bounded by its own HTTP client and timeout. It publishes two write-once values, each an immutable value behind an atomic pointer: the verifier (the OAuth configuration, the verifier, the provider and the revocation endpoint), once the document's issuer matches, and the sign-in value (the end-session endpoint with the verifier it was published with), once a document with the configured issuer carries an end-session endpoint. Sessions already signed in have their roles re-derived whenever the verifier is published, even while the document is unusable for sign-in. Until both are published `/login`, `/register` and `/callback` answer 503 with `Retry-After` (the styled page for a browser, the heading as text for an htmx request), and a request to one of them wakes the loop. A document that cannot be used (an issuer mismatch, no `end_session_endpoint`) keeps sign-in at 503 and is retried at the backoff cap with an Error log, so a fix at the provider needs no restart. Sign-out without an end-session endpoint ends the console session, revokes the refresh tokens when the verifier is published, and lands on `/`.
- **Temporal connects in the background and never stops the process.** The connection is retried for the life of the process with capped backoff, jitter and log decay. `temporal-connect-timeout` keeps its name and default and becomes the time after which the log escalates from Warn to Error. Once connected, the console runs the worker preconditions, starts the worker, publishes the connection to the handlers, and only then runs the integrations' startup hooks, pollers and schedules, so their sweeps see rows recorded while Temporal was not connected.
- **Temporal-backed actions read the connection per request.** Handlers hold a holder, read when the request arrives, never a client captured when the route was registered. No connection means what Temporal not configured means today: the action's existing "not available" answer.
- **Webhooks before Temporal.** The Stripe webhook records the event and answers 503 until Temporal is connected, so Stripe redelivers and the 2xx-only-once-started rule holds. The Discourse webhook records the event `failed` with the reason and answers 200 as it does for any recorded event; its periodic sweep converges the person.
- **The FedWiki removed-types check no longer exits.** An open run of a removed workflow type starts no worker and publishes no connection, the console keeps serving and logs at Error, and the list is read again at the backoff cap, so the worker starts once the runs are closed. A list that cannot be read is a failed connection attempt and is retried.
- **A bounded grant needs Temporal.** Issuing a grant with an end date, or saving a new or changed end date on a grant, is refused before anything is written while Temporal is not connected or not configured, because its expiration workflow is the bound's only enforcement. Clearing an end date needs nothing from Temporal and is not refused.
- **The session store.** An authentication refusal from the session store at boot (a wrong or missing password) still stops the start, now with a non-zero exit; a store that does not answer, or that answers `LOADING` while it reads its dataset, no longer holds the listener. The store is wrapped so that every error it returns is tagged; a request whose session the store cannot load or save answers 503 through the session manager's `ErrorFunc`, which answers only for a tagged error, with a structured log line, and a writer that drops whatever the handler wrote after the 503. Routes that read no session (`/static/`, the ask, the webhooks, `/favicon.ico`) skip the session middleware.
- **The Stripe key is set at boot.** `start` hands the configured key, trimmed as the mode and the fingerprint are, to the Stripe SDK once it has derived the key's mode, before any route or background work. Checkout, the plan moves, the reconcile on the checkout return and the billing sweep read the boot mode; `SDKKeyMode` and the key-mode functions in the server and worker configuration go.
- **An integration whose events are processed on Temporal needs `temporal-host`.** A configuration key can declare that its group needs Temporal; Stripe and Discourse declare it, and a configured group without `temporal-host` is refused at boot with the other configuration problems.
- **No new route.** There is no probe route. An anonymous `GET /` keeps answering 302 to `/login` without contacting the identity provider, Temporal or the session store.
- **A not-ready route table.** A second golden beside `internal/server/testdata/routes.golden` records every route's answers with the discovery document and the Temporal connection both missing, so the routes that change are reviewed as data.
## Quick fixes inside this change
Listed here because each is small and was found while investigating #141.
- **`start` exits non-zero when serving fails.** Today a failed bind, a session store that refuses the password and a failed discovery are logged as `server failed to start` and the process exits 0, because the command's `Run` returns and the root command sees no error. An orchestrator that restarts only on failure leaves such a process stopped. `start` now exits 1, as every other failure in the command does. It lands with the section that moves the boot order.
- **The first-boot order in `docs/production-deployment.md` section 7.** The section lists configuration validation after the database steps; the code validates first. The section is rewritten to the order the code runs after this change, with what serves before Temporal and the identity provider answer.
- **A finished Stripe event is not processed twice.** Until now the boot sweep ran before the listener, so it never overlapped a live redelivery. After this change a sweep that read a row as `received` can start a second run after a redelivery's run completed, because a completed workflow id may be started again, and the activity marks the row `processing` with no status guard. The activity's first update now applies only to a row at `received`, `processing` or `failed` and returns when it touches no row. The guard lives in the activity because the sweep's status read and the workflow start are two steps that the workflow id's reuse policy does not close; the existing duplicate-branch check reads the status in the same way and has the same gap.
- **The retry toast.** The htmx 5xx toast in `internal/embeds/static/error-handler.js` reads `Server error. Try again later.` in place of `Server error. Please try again later.`, which the copy rules refuse. No test and no screen carries the string, so `make screens` lists nothing new; `filler-copy` does not scan JavaScript, so a task checks it.
- **The FedWiki refusal's advice.** The text of the removed-types refusal says "before starting this release"; after this change the console is already serving, so it is reworded (copy row 15).
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `temporal-connection`: modified "Resilient Temporal startup connection" (background connection with no exit, the escalation time, publication before the startup hooks, retries and log decay). Added "A Temporal-backed action reads the connection when it runs".
- `oidc-login`: modified "Sign-out ends the session before the identity provider is asked" (the end-session endpoint comes from the published discovery document; sign-out without it), "Sign-out is a POST" (the answer names the end-session URL only once one is published) and "A session's identity is re-derived from the provider on an interval" (what happens while no verifier is published). Added "Sign-in waits for the identity provider's discovery document, and nothing else does".
- `startup-configuration`: modified "The session store's credentials and transport are configurable" (an error reply stops the start; an unanswered probe does not hold the listener; 503 for a request the store cannot serve). Added "The console serves before Temporal and the identity provider answer".
- `integration-config-declaration`: added "A declared integration may need Temporal".
- `stripe-integration-infrastructure`: modified "Stripe webhook endpoint ingests events asynchronously" (503 until Temporal is connected), "Temporal workflow polls and executes integration outbox entries" and "Each Stripe webhook event is processed by its own workflow" (the sweeps run once Temporal connects; a finished event is not processed again), and "Stripe mode is derived from the API key" (the SDK's key is set at boot; every surface reads the boot mode).
- `fedwiki-sites`: renamed "The console refuses to start while a run of a removed FedWiki workflow type is open" to "The console starts no worker while a run of a removed FedWiki workflow type is open", and modified it.
- `discourse-integration-infrastructure`: modified "Bootstrap configuration via ConfigSpec" (the group needs Temporal). Added "A webhook event that arrives before Temporal connects is recorded failed".
- `domain-authorization`: added "The ask answers as soon as the console serves".
- `error-pages`: added "A request whose dependency is not ready answers 503".
- `plan-enrollment-administration`: added "A grant with an end date needs Temporal before it is written".
- `deployment-docs`: added "The first-boot section states the order start runs".
## Impact
- **Code:** `cmd/start.go` (the boot order, the Stripe key, the Temporal connector, the exit status), a new `cmd/temporal_connect.go`, `internal/auth/auth.go` and `internal/auth/valkey.go` (the two discovery holders and the loop, the session store probe, the tagged store and `ErrorFunc`), `internal/stripetest` (the SDK key helpers), `internal/embeds/static/error-handler.js` (the toast), `internal/server/server.go` and the handlers that start Temporal work (`member_domains.go`, `operator_domains.go`, `operator_enrollment.go`, `operator_entitlement_recompute.go`, `operator_integrations_stripe.go`, `operator_partials.go`, `operator_plan_acts.go`, `billing.go`, `member_plans.go`), `internal/workflows/client.go`, `internal/workflows/worker.go`, `internal/config/spec.go` and `validate.go`, `internal/fulfillment` (the mode is a string), the FedWiki, Stripe and Discourse integrations (`fedwiki.go` and its `web/` handlers, `stripe.go` and `web/webhook.go`, `discourse.go` and its `web/` handlers). New packages: `internal/retryloop` (backoff, jitter, log decay, escalation, wake) and `internal/workflows/clientholder` (the Temporal holder). The Stripe workflows' `ProcessWebhookEvent` (the status guard) and the FedWiki refusal text change too.
- **Config keys:** none added or removed. `temporal-connect-timeout` keeps its default and now sets when the log escalates. `stripe-api-key` and `discourse-base-url` declare that their groups need `temporal-host`.
- **Behaviour a deployment sees:** a configured Stripe or Discourse group without `temporal-host` no longer starts. `start` no longer exits when Temporal is unreachable. Sign-in answers 503 while the identity provider has not answered. A bounded grant cannot be issued, or given a new end date, without Temporal. A refused session store or a failed bind exits 1 instead of 0.
- **Tests:** a boot test in `cmd` that serves the real handler with a Temporal address that drops every connection and a fake identity provider; unit tests of the retry loop, the holders, the gates and the webhooks; a not-ready route golden; the key-mode tests moved to the boot mode on purpose; `go test -race` on the touched packages.
- **Docs:** `docs/production-deployment.md` (section 7), `docs/environment-reference.md` (`temporal-connect-timeout`, the integrations that need `temporal-host`), `docs/fedwiki-setup.md` (what the ask's bound depends on), `docs/identity-provider-setup.md` and `internal/auth/README.md` (no refusal to start), `docs/temporal-authorization-setup.md` (an internal token URL), `docs/stripe.md` and `docs/discourse-setup.md` (`temporal-host` is required).
- **Schema:** none.
## Out of scope
Each is a candidate issue for the maintainer to decide on.
- **Graceful shutdown.** `start` installs no signal handling: a stop or redeploy drops in-flight requests, leaves the worker's tasks to time out and closes no pool. The background loops this change adds make that matter more. This change cancels nothing on a signal; its loops stop with the process.
- **Request deadlines that disagree** (#208). The server's write timeout (8 seconds) is below the request timeout middleware's 32 seconds and the Temporal SDK's 10-second call timeout, so a handler that waits on a Temporal that went away after connecting writes its answer into a closed connection. This change keeps the connection from being published before it works, which covers the boot window only.
- **A backstop for grant expiry.** The expiration workflow is the only thing that ends a bounded grant, and a start that fails after the grant is written leaves a grant that never expires. This change refuses the write while Temporal is not connected; a failed start while it is connected keeps today's warning.
@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: The first-boot section states the order start runs
The production deployment guide's first-boot section SHALL list the steps of `member-console start` in the order the code runs them, configuration validation first, and SHALL state which routes are served before Temporal and the identity provider answer (the certificate ask, the static files, the provider webhooks and signed-in pages), that sign-in answers 503 until a usable discovery document is published, that start keeps retrying Temporal instead of exiting and logs at Error after `temporal-connect-timeout`, and that a configured Stripe or Discourse needs `temporal-host`.
#### Scenario: The guide's order matches the code
- **WHEN** a reader follows the first-boot section
- **THEN** configuration validation SHALL be listed before the database steps, and the listener after the session store probe
#### Scenario: The guide says what serves early
- **WHEN** a reader looks for what a restart serves while the identity provider is unreachable
- **THEN** the first-boot section SHALL name the certificate ask as served and sign-in as answering 503 until a usable discovery document is published
@@ -0,0 +1,34 @@
## MODIFIED Requirements
### Requirement: Bootstrap configuration via ConfigSpec
The integration SHALL declare its bootstrap configuration through the contract's `ConfigSpec()`: `discourse-base-url` and `discourse-api-key` (Secret) bound all-or-nothing in one RequiredGroup, plus `discourse-api-username` (default `system`), `discourse-linkage-mode` (`email`|`oidc`|`discourseconnect`, default `oidc`), `discourse-auto-create-users` (default `false`), and `discourse-webhook-secret` (Secret, optional). The group SHALL declare that it needs Temporal (Requirement: A declared integration may need Temporal), because its webhook events and its group sync run only on Temporal. When the RequiredGroup is absent the integration SHALL remain dormant: registered, migrations applied, but `Startup` performs no provider calls and logs that it is unconfigured.
#### Scenario: Partial required config rejected
- **WHEN** the application starts with `discourse-base-url` set but `discourse-api-key` unset
- **THEN** startup validation fails identifying the incomplete Discourse config group
#### Scenario: A configured forum without Temporal is rejected
- **WHEN** the application starts with `discourse-base-url` and `discourse-api-key` set and `temporal-host` empty
- **THEN** startup validation fails naming the Discourse group and `temporal-host`, reported with any other configuration problem
#### Scenario: Unconfigured integration stays dormant
- **WHEN** the application starts with no Discourse config keys set
- **THEN** the application boots normally, the integration performs no Discourse API calls, and a log line records it as unconfigured
## ADDED Requirements
### Requirement: A webhook event that arrives before Temporal connects is recorded failed
The webhook handler SHALL read the Temporal connection when it processes an event, never a client captured when its route was registered. While the console is not connected to Temporal, an event whose processing would start a targeted reconcile SHALL be recorded with status `failed` and the error `start reconcile: Temporal is not connected`, and SHALL be answered HTTP 200 as every recorded event is; the periodic sweep converges the person once it runs.
#### Scenario: A group event before the connection
- **WHEN** a signed `user_added_to_group` event for a managed group and a linked forum user arrives while the console is not connected to Temporal
- **THEN** the endpoint SHALL answer HTTP 200
- **AND** the event's row SHALL read `failed` with the error `start reconcile: Temporal is not connected`
- **AND** the handler SHALL NOT panic
#### Scenario: The same event after the connection
- **WHEN** the same kind of event arrives after the console connected to Temporal
- **THEN** the person's targeted reconcile SHALL start and the row SHALL read `processed`
@@ -0,0 +1,16 @@
## ADDED Requirements
### Requirement: The ask answers as soon as the console serves
`GET /domains/ask` SHALL answer from the domains registry as soon as the console's listener is open, whatever the state of Temporal, the identity provider and the session store. It SHALL NOT read the session store, even for a request that carries a session cookie, and SHALL NOT wait for any background connection.
#### Scenario: The ask during a cold start of every other service
- **WHEN** the console serves while Temporal refuses connections, the identity provider answers 503 and the session store is unreachable
- **THEN** the ask for a name with a servable placement SHALL answer 200
- **AND** the ask for a name the registry does not hold SHALL answer 404
#### Scenario: A cookie on the ask changes nothing
- **WHEN** the ask arrives carrying a session cookie while the session store is unreachable
- **THEN** it SHALL answer from the registry as it does without the cookie
@@ -0,0 +1,42 @@
## ADDED Requirements
### Requirement: A request whose dependency is not ready answers 503
A request that needs a dependency the console has not reached yet SHALL be answered with status 503 and a `Retry-After` header of five seconds: sign-in and registration until a usable discovery document is published, and a request whose session the session store cannot load or save. A browser SHALL receive the styled error page with a heading that names what is unavailable (`Sign-in unavailable`, `Registration unavailable`) and one action, `Try again`, that repeats the request: `/login` for sign-in, `/register` for registration, and for the session store the request's own path when it was a GET or HEAD and is a local path, and the dashboard otherwise. An htmx request SHALL receive the heading as plain text with the same status, which the client's error toast reports without swapping it. The response to a request whose session cannot be saved SHALL be the 503 alone: nothing the handler set (a `Location` header, any `HX-` header such as `HX-Redirect` or `HX-Refresh`, a cookie) and nothing it wrote SHALL reach the client after it. The page SHALL NOT expose internal details.
#### Scenario: Sign-in before discovery renders the styled 503
- **WHEN** a browser requests `/login` before a usable discovery document is published
- **THEN** the response SHALL have status 503 and `Retry-After: 5`
- **AND** the body SHALL be the styled page headed `Sign-in unavailable` with one action, `Try again`, leading to `/login`
#### Scenario: An htmx request gets text
- **WHEN** an htmx request carrying a session cookie arrives while the session store does not answer
- **THEN** the response SHALL be status 503 with the plain-text body `Sign-in unavailable`
#### Scenario: A session that cannot be saved leaves the handler's answer out
- **WHEN** a request without a session cookie reaches `/login` while the session store does not answer
- **THEN** the response SHALL be status 503 with the styled page alone
- **AND** it SHALL carry no `Location` header and its body SHALL contain nothing the handler wrote
#### Scenario: A reload header the handler set does not follow the 503
- **WHEN** a handler puts a value in the session and sets `HX-Refresh: true` without writing, and the session store then fails to save the session
- **THEN** the response SHALL be status 503 and SHALL carry no `HX-Refresh` header
#### Scenario: An off-site request line does not become the action
- **WHEN** a GET whose request line is `//other.example/x` arrives with a session cookie while the session store does not answer
- **THEN** the page's action SHALL lead to the dashboard
#### Scenario: A stored session that cannot be decoded is not an outage
- **WHEN** the session store returns a value the session codec cannot decode
- **THEN** the response SHALL be the styled 500 page and not the 503
#### Scenario: The page's own stylesheet is served
- **WHEN** a browser that holds a session cookie loads the 503 page while the session store does not answer
- **THEN** the page's stylesheet SHALL be served with status 200
@@ -0,0 +1,32 @@
## RENAMED Requirements
- FROM: `### Requirement: The console refuses to start while a run of a removed FedWiki workflow type is open`
- TO: `### Requirement: The console starts no worker while a run of a removed FedWiki workflow type is open`
## MODIFIED Requirements
### Requirement: The console starts no worker while a run of a removed FedWiki workflow type is open
Where Temporal is configured, the console SHALL, after each successful connection to Temporal and before it starts its worker, list the running executions of the four FedWiki workflow types that predate lifecycle requests: `CreateFedWikiSiteWorkflow`, `DeleteFedWikiSiteWorkflow`, `SetSiteStatusWorkflow` and `SwapActiveSiteWorkflow`. When any is running, the console SHALL NOT start its worker and SHALL NOT publish the Temporal connection to its handlers, because a worker that does not register those types fails every task of an open run and leaves its farm call and row write unfinished. It SHALL log the types and the number of their open runs at Error, SHALL keep serving every route that does not need Temporal, SHALL NOT exit, and SHALL list the runs again at the connection retry's cap, starting its worker once none is open. When the list cannot be read, the console SHALL treat the attempt as a failed connection and retry it, because a list that cannot be read says nothing about the runs. Where Temporal is not configured, the console SHALL run no such check.
#### Scenario: An open run of a removed type starts no worker
- **WHEN** the console connects to Temporal with one `SetSiteStatusWorkflow` run still open
- **THEN** it SHALL NOT start its worker and SHALL NOT publish the connection
- **AND** the log SHALL name the type and the number of its open runs at Error
- **AND** the process SHALL keep serving, and a Temporal-backed action SHALL answer that it is not available
#### Scenario: The worker starts once the run is closed
- **WHEN** the open run is terminated while the console is serving
- **THEN** a later reading of the list SHALL find none open, and the console SHALL start its worker and publish the connection without a restart
#### Scenario: A visibility store that cannot be read is retried
- **WHEN** the console cannot list the running executions
- **THEN** it SHALL NOT start its worker, the log SHALL carry the error, and the connection SHALL be retried
#### Scenario: No open run lets the console start its worker
- **WHEN** no run of the four types is open
- **THEN** the console SHALL start its worker
@@ -0,0 +1,25 @@
## ADDED Requirements
### Requirement: A declared integration may need Temporal
A `ConfigKey` MAY declare `NeedsTemporal`, which says that the integration its RequiredGroup enables processes its events on Temporal. Only a key that belongs to a RequiredGroup SHALL carry it; `CheckSpecs` SHALL refuse it on any other key as a declaration error. When every key of a group that carries it is set and `temporal-host` is empty, `ValidateStart` SHALL refuse the configuration, reported with the other configuration problems, naming the group, `temporal-host` and the group's keys. A dormant group (none of its keys set) SHALL pass. Stripe SHALL declare it, and Discourse SHALL declare it: each receives events by webhook that only Temporal processes. An integration whose actions run on Temporal but that receives no events SHALL NOT declare it, because each of its actions answers that it is not available instead of storing work that nothing processes.
#### Scenario: Stripe without Temporal is refused
- **WHEN** start runs with `stripe-api-key` and `stripe-webhook-secret` set and `temporal-host` empty
- **THEN** startup validation SHALL fail with `Stripe needs Temporal, which processes its events: set temporal-host (or leave stripe-api-key and stripe-webhook-secret empty to disable it)`
#### Scenario: Discourse without Temporal is refused
- **WHEN** start runs with `discourse-base-url` and `discourse-api-key` set and `temporal-host` empty
- **THEN** startup validation SHALL fail naming the Discourse group and `temporal-host`
#### Scenario: A dormant group needs nothing
- **WHEN** start runs with no Stripe key set and `temporal-host` empty
- **THEN** startup validation SHALL NOT refuse on Stripe's account
#### Scenario: The need belongs to a group
- **WHEN** an integration declares `NeedsTemporal` on a key with no RequiredGroup
- **THEN** `CheckSpecs` SHALL report a declaration error naming the key
@@ -0,0 +1,280 @@
## MODIFIED Requirements
### Requirement: Sign-out ends the session before the identity provider is asked
`/logout` SHALL destroy the console session before redirecting the browser to the
identity provider's end-session endpoint. The sign-out SHALL NOT depend on the
provider redirecting back: a person who leaves the provider's page is signed out
of the console regardless.
The redirect SHALL carry `id_token_hint` whenever a valid ID token can be had:
the stored token while it is still valid, otherwise a fresh one minted from the
stored refresh token in one call bounded in time. A refresh that fails, a refresh
response without an ID token, or a session without a refresh token SHALL omit the
hint and SHALL NOT delay or prevent the sign-out.
The refresh token SHALL be held only in the server-side session store and SHALL
be deleted with the session.
The end-session endpoint SHALL be the `end_session_endpoint` of the discovery
document the console has published (Requirement: Sign-in waits for the identity
provider's discovery document, and nothing else does). A document that publishes
none SHALL leave sign-in unavailable until the provider publishes one.
While no end-session endpoint is published, `/logout` SHALL destroy the console
session, SHALL mint no hint, and SHALL send the browser to `/`: a 303 redirect, or
`HX-Redirect: /` with status 200 for an htmx request. The provider's own session
stays. Before any discovery document is published it SHALL call no endpoint of
the identity provider.
Once the session is destroyed, every refresh token that was live SHALL be
revoked at the provider's discovered `revocation_endpoint` when one is
published: the stored token, and the one a refresh handed back when it differs.
Each is one call bounded in time whose failure SHALL NOT prevent the sign-out.
A console whose document has an issuer match but no end-session endpoint revokes
the tokens as well; one that has published no document has no revocation
endpoint to call.
The redirect SHALL carry an unguessable `state`, which the provider echoes to
the post-logout redirect URI (OpenID Connect RP-Initiated Logout 1.0, section
2), and the same value SHALL be held in a cookie scoped to `/logout-callback`
that expires within five minutes. `/logout-callback` SHALL redirect
unconditionally and SHALL NOT refuse. It SHALL end a session only when the
echoed `state` equals the cookie's value, redirecting to `/login`; any other
callback SHALL leave whatever session the browser holds untouched and redirect
to `/`.
#### Scenario: Leaving the provider's page still signs out of the console
- **WHEN** a signed-in person requests `/logout` and never completes the provider's round trip
- **THEN** a request carrying their previous session cookie SHALL be treated as unauthenticated
- **AND** the response to `/logout` SHALL have expired the session cookie
#### Scenario: An aged session mints a fresh hint
- **WHEN** a person whose stored ID token has expired requests `/logout` and the session holds a refresh token
- **THEN** the console SHALL obtain a fresh ID token with a `refresh_token` grant
- **AND** the redirect SHALL carry it as `id_token_hint`
#### Scenario: A still-valid stored token is the hint
- **WHEN** the stored ID token is still valid at `/logout`
- **THEN** it SHALL be the hint and the token endpoint SHALL NOT be called
#### Scenario: The hint never gates the sign-out
- **WHEN** the refresh fails, or the session holds no refresh token
- **THEN** the redirect SHALL carry no `id_token_hint`
- **AND** the session SHALL still have ended
#### Scenario: The refresh token is revoked at the provider
- **WHEN** a person requests `/logout` and the session holds a refresh token
- **THEN** the console SHALL send that token, and the one a refresh rotated it to when there is one, to the provider's revocation endpoint, authenticated as the client
- **AND** a failed revocation SHALL NOT prevent the sign-out
#### Scenario: A provider without an end-session endpoint leaves sign-in unavailable
- **WHEN** the provider's discovery document publishes no `end_session_endpoint`
- **THEN** the console SHALL keep serving, SHALL log at Error naming the missing endpoint, and `/login` SHALL answer 503
- **AND** once the provider publishes the endpoint, sign-in SHALL become available without a restart
#### Scenario: Sign-out before discovery ends the console session
- **WHEN** a signed-in person requests `/logout` before a discovery document is published
- **THEN** the session SHALL be destroyed and the response SHALL send the browser to `/`
- **AND** no request SHALL be made to the identity provider
#### Scenario: Sign-out without an end-session endpoint still revokes
- **WHEN** a signed-in person whose session holds a refresh token requests `/logout` while the published document has a revocation endpoint and no end-session endpoint
- **THEN** the session SHALL be destroyed, the refresh token SHALL be sent to the revocation endpoint, and the response SHALL send the browser to `/`
#### Scenario: The callback never refuses
- **WHEN** `/logout-callback` is requested with no session, with a stale cookie, or with any query
- **THEN** the response SHALL be a redirect
#### Scenario: The provider's callback ends the session it was issued for
- **WHEN** `/logout-callback` arrives with a `state` equal to the value in the browser's logout cookie
- **THEN** any session the browser presents SHALL be destroyed, the cookie cleared, and the response SHALL redirect to `/login`
#### Scenario: A forged callback does not end a live session
- **WHEN** a signed-in browser is navigated to `/logout-callback` from another origin, with no logout cookie or a `state` that does not match it
- **THEN** the session SHALL remain authenticated and the response SHALL redirect to `/`
### Requirement: Sign-out is a POST
`/logout` SHALL end a session only on a POST. The console's sign-out control
SHALL be a button that posts, declared as an action trigger, so the
cross-origin protection covers it. A GET at `/logout` SHALL redirect to `/`
and SHALL NOT change the session. Once a discovery document with an
end-session endpoint is published, the handler SHALL answer an htmx request
with `HX-Redirect` naming the provider's end-session URL, because fetch
cannot follow a redirect to another origin; any other POST SHALL receive a
303 to that URL. While none is published, the answer SHALL be the one of
Requirement: Sign-out ends the session before the identity provider is asked:
`HX-Redirect: /` with status 200, or a 303 to `/`.
#### Scenario: A link cannot sign a person out
- **WHEN** a signed-in browser is sent to `/logout` by a GET, from any origin
- **THEN** the response SHALL be a redirect to `/` and the session SHALL remain valid
#### Scenario: The account menu signs out
- **WHEN** the account menu's Sign out posts to `/logout` with `HX-Request` once an end-session endpoint is published
- **THEN** the session SHALL be destroyed and the response SHALL be 200 with `HX-Redirect` naming the end-session URL, hint included where one can be had
#### Scenario: Any other POST is redirected
- **WHEN** `/logout` receives a POST without `HX-Request` once an end-session endpoint is published
- **THEN** the session SHALL be destroyed and the response SHALL be a 303 to the end-session URL
#### Scenario: A POST before an end-session endpoint is published lands on the root
- **WHEN** `/logout` receives a POST, with or without `HX-Request`, while no end-session endpoint is published
- **THEN** the session SHALL be destroyed and the answer SHALL be `HX-Redirect: /` with status 200 for an htmx request and a 303 to `/` otherwise
### Requirement: A session's identity is re-derived from the provider on an interval
The console SHALL re-derive a session's identity and roles from the identity
provider with a refresh-token grant once every five minutes, on the first
authenticated request after the interval. The returned ID token SHALL be verified as
a sign-in ID token is, its roles SHALL replace the session's, the returned ID
token and refresh token SHALL replace the stored ones, and the interval SHALL
restart.
A definitive refusal (`invalid_grant`) SHALL end the session, and the request
SHALL be treated as unauthenticated. A refreshed token naming a different
subject SHALL end the session. No answer (a timeout, a server error, a token
that fails verification) SHALL keep the session's last known state, and the
console SHALL NOT ask again for at least one minute.
While no verifier is published (Requirement: Sign-in waits for the identity
provider's discovery document, and nothing else does), because the console has
not yet fetched a document whose issuer is the configured one, no session SHALL
be re-derived: each keeps its last known identity and roles and no request is
made to the provider. The first request after the verifier is published and the
interval has passed SHALL re-derive its session. A document that lacks only the
end-session endpoint SHALL NOT delay re-derivation.
Requests that cross the interval together SHALL share one refresh. A session
without a refresh token SHALL keep its sign-in snapshot.
#### Scenario: A role removed at the provider is gone within the interval
- **WHEN** an operator's `operator-member` role is removed at the provider and the verifier is published
- **THEN** their next request more than five minutes after the session's last re-derivation SHALL carry no operator role
#### Scenario: A session ended at the provider ends here
- **WHEN** the provider answers the refresh with `invalid_grant`
- **THEN** the console session SHALL end and the request SHALL bounce to sign-in
#### Scenario: A provider outage signs nobody out
- **WHEN** the provider gives no usable answer
- **THEN** the request SHALL proceed with the session's last known roles
- **AND** no further refresh SHALL be attempted for that session within one minute
#### Scenario: Requests crossing the interval together
- **WHEN** several requests for one session arrive after the interval at once
- **THEN** exactly one refresh SHALL be made and every request SHALL use its answer
#### Scenario: No verifier means no re-derivation
- **WHEN** the verifier is not published and a signed-in session is past its interval
- **THEN** the request SHALL proceed with the session's last known roles and no request SHALL be made to the provider
#### Scenario: Re-derivation resumes when the verifier is published
- **WHEN** the verifier is published after a session passed its interval without one
- **THEN** that session's next request SHALL re-derive its identity and roles
## ADDED Requirements
### Requirement: Sign-in waits for the identity provider's discovery document, and nothing else does
Startup SHALL NOT fetch the identity provider's discovery document before the
console serves. A background loop SHALL fetch it from `oidc-idp-issuer-url`, each
attempt through an HTTP client of its own with a ten-second timeout, retrying
with the backoff and log decay of Requirement: Resilient Temporal startup
connection, with ninety seconds as the time after which its lines are Error.
The loop SHALL publish two values, each once, each as one value no handler can
change, and SHALL NOT replace either for the life of the process. The verifier
(the OAuth configuration, the ID token verifier, the provider and the revocation
endpoint) SHALL be published when a document's issuer is the configured issuer.
The end-session endpoint SHALL be published, together with the verifier it is
used with, when a document whose issuer is the configured one carries
`end_session_endpoint`; it MAY come from a later document than the verifier. A document that cannot be used (an issuer that is not the
configured one; no `end_session_endpoint`) SHALL be logged at Error naming why and
SHALL be fetched again at the backoff cap; a request that wakes the loop SHALL
NOT shorten that wait. A document whose issuer does not match publishes neither
value.
Until both values are published, `/login`, `/register` and `/callback` SHALL
answer 503 with a `Retry-After` header (Requirement: A request whose dependency
is not ready answers 503) and SHALL write nothing to the session. While they
answer 503, a request to one of them SHALL end the loop's wait, unless the wait
follows an unusable document, and no sooner than one second after the previous
attempt began, and SHALL NOT wait for the attempt. Every other
route SHALL answer as it does once both are published, except `/logout`
(Requirement: Sign-out ends the session before the identity provider is asked).
A signed-in session SHALL be re-derived (Requirement: A session's identity is
re-derived from the provider on an interval) whenever the verifier is published,
including while the document lacks the end-session endpoint, and SHALL keep its
last known identity and roles while it is not.
#### Scenario: Sign-in before discovery answers 503
- **WHEN** a browser requests `/login` while the identity provider has not answered
- **THEN** the response SHALL be 503 with `Retry-After` and the styled page headed `Sign-in unavailable`
- **AND** no session cookie SHALL be set and nothing SHALL be written to the session store
#### Scenario: Sign-in recovers without a restart
- **WHEN** the identity provider starts answering after the console began serving
- **THEN** a later `/login` SHALL redirect to the provider's authorization endpoint
- **AND** the process SHALL NOT have restarted
#### Scenario: A sign-in request wakes the loop
- **WHEN** the loop is waiting out a backoff and a browser requests `/login`
- **THEN** the next attempt SHALL begin without waiting for the backoff to end, and no sooner than one second after the previous attempt began
#### Scenario: An attempt that gets no answer ends
- **WHEN** the identity provider accepts the connection and never answers
- **THEN** the attempt SHALL end within its timeout and the loop SHALL retry
#### Scenario: A signed-in session passes before discovery
- **WHEN** a signed-in person whose identity is due for re-derivation requests a page before a document is published
- **THEN** the page SHALL be served with the session's last known roles
#### Scenario: Registration waits with sign-in
- **WHEN** a browser requests `/register` before a document is published
- **THEN** the response SHALL be 503 with the styled page headed `Registration unavailable`
#### Scenario: Roles are re-derived while the document is unusable for sign-in
- **WHEN** the provider's document has the configured issuer and no `end_session_endpoint`, and a role is removed at the provider
- **THEN** the next request of a signed-in session more than five minutes after its last re-derivation SHALL carry no such role
- **AND** `/login` SHALL answer 503
#### Scenario: A document with another issuer publishes nothing
- **WHEN** the provider's document names an issuer that is not the configured one, whether or not it carries `end_session_endpoint`
- **THEN** `/login` SHALL answer 503, the log SHALL carry an Error line naming the issuer mismatch, and no session SHALL be re-derived
- **AND** once the document names the configured issuer, the verifier SHALL be published without a restart
#### Scenario: A wake does not shorten the wait after an unusable document
- **WHEN** the last document could not be used and a browser requests `/login`
- **THEN** the next attempt SHALL begin at the backoff cap and not sooner
@@ -0,0 +1,26 @@
## ADDED Requirements
### Requirement: A grant with an end date needs Temporal before it is written
The expiration workflow is the only enforcement of a grant's `valid_until`. While the console has no Temporal connection, because Temporal is not configured or has not connected yet, issuing a grant with a `valid_until` bound, and saving a new or changed `valid_until` bound on a grant, SHALL be refused, after the end date has been parsed and checked so that a past or malformed date keeps its own field error, and before any grant row, decree or conferral is written, with the field error `An end date needs the expiration scheduler, which is not connected.` on the end date and the operator's other input kept. A grant without a bound SHALL be issued as before, and a save that clears a grant's bound SHALL NOT be refused, because it starts no workflow. A grant written while connected whose expiration workflow then fails to start SHALL keep the existing warning that it will not expire on its own.
#### Scenario: A bounded grant before the connection is refused
- **WHEN** an operator issues a grant with an end date while the console is not connected to Temporal
- **THEN** the form SHALL re-render with the field error on the end date
- **AND** no grant row SHALL be written
#### Scenario: An unbounded grant is issued without Temporal
- **WHEN** an operator issues a grant without an end date while the console is not connected to Temporal
- **THEN** the grant SHALL be issued
#### Scenario: A changed end date before the connection is refused
- **WHEN** an operator saves a new end date on an existing grant while the console is not connected to Temporal
- **THEN** the save SHALL be refused with the field error on the end date and the grant SHALL be unchanged
#### Scenario: Clearing an end date needs no Temporal
- **WHEN** an operator clears the end date of an existing grant while the console is not connected to Temporal
- **THEN** the save SHALL succeed
@@ -0,0 +1,138 @@
## MODIFIED Requirements
### Requirement: The session store's credentials and transport are configurable
The application SHALL require a password for the session store, supplied as a
direct value or a file path but never both, and startup SHALL fail when none is
configured. A session carries the person, organization and workspace ids that
authorization is decided from, so a store reachable without authentication lets
anyone who reaches its port read and forge them.
The application SHALL additionally accept optional configuration for a username,
for connecting over TLS, and for skipping certificate verification. TLS remains
optional because it requires certificates to be provisioned.
Dialling the store SHALL be bounded by connect, read and write timeouts. Startup
SHALL probe the store once before it serves. An error reply that refuses
authentication (a wrong or missing password: a reply beginning `WRONGPASS`,
`NOAUTH` or `NOPERM`, or an `ERR` reply to `AUTH`) SHALL fail startup with a
non-zero exit status. Any other probe error SHALL be logged at Error and SHALL
NOT hold the listener for longer than the dial timeouts; the store is dialled
again on each request. That covers a refused or timed-out connection, a failed
TLS handshake, and an error reply that says the store is busy, such as
`LOADING` while it reads its dataset.
A request whose session the store cannot load or save SHALL be answered 503 with
a `Retry-After` header (Requirement: A request whose dependency is not ready
answers 503) and one structured log line, whatever the type of the error the
store returned. A session the store returned but that cannot be decoded, an empty
or a truncated value included, SHALL be answered with the styled 500 page and one
structured log line. Routes that read no session (`/static/`, `/favicon.ico`,
`/domains/ask`, and the paths integrations declare as authenticated by their own
signature) SHALL NOT load the session.
When certificate verification is disabled, the application SHALL log a warning at
startup naming the deployment environment, because that setting removes the proof
of who is on the other end of the connection.
#### Scenario: A deployment without a store password does not start
- **WHEN** neither `valkey-password` nor `valkey-password-file` is configured
- **THEN** startup SHALL fail, naming the key and why it is required
- **AND** the failure SHALL be reported alongside any other configuration problem
#### Scenario: A password-protected store is reachable
- **WHEN** the session store requires authentication and the password is configured
- **THEN** the application SHALL connect
- **AND** sessions SHALL be written to and read from it normally
#### Scenario: A missing password fails loudly at startup
- **WHEN** the session store requires authentication and no password is configured
- **THEN** startup SHALL fail with an error naming the connection failure
- **AND** the application SHALL NOT begin serving
- **AND** the process SHALL exit with a non-zero status
#### Scenario: A store that does not answer does not hold the listener
- **WHEN** the session store refuses connections at startup
- **THEN** startup SHALL log the failure at Error and serve
- **AND** `GET /domains/ask` and `/static/` SHALL answer, with or without a session cookie
#### Scenario: A store that is loading its dataset does not stop the start
- **WHEN** the session store answers the startup probe with `LOADING`
- **THEN** startup SHALL log the failure at Error and serve
- **AND** the process SHALL NOT exit
#### Scenario: A request that needs the store during an outage answers 503
- **WHEN** a request carrying a session cookie arrives while the store does not answer
- **THEN** the response SHALL be 503 with `Retry-After` and the styled page headed `Sign-in unavailable`
- **AND** one structured log line SHALL name the method, the path and the error
#### Scenario: A store whose certificate fails is an outage
- **WHEN** the session store's TLS certificate is refused while a request carrying a session cookie arrives
- **THEN** the response SHALL be 503 with `Retry-After`, and not the styled 500 page
#### Scenario: A session that cannot be decoded is not an outage
- **WHEN** the session store returns an empty or a truncated value for a request's session cookie
- **THEN** the response SHALL be the styled 500 page and one structured log line saying the session could not be decoded
#### Scenario: Both a password value and a password file is rejected
- **WHEN** both the password and its file path are set
- **THEN** startup SHALL fail reporting the ambiguity, as it does for the other file-backed secrets
#### Scenario: Skipping certificate verification is announced
- **WHEN** TLS is enabled and certificate verification is disabled
- **THEN** startup SHALL log a warning naming the environment
## ADDED Requirements
### Requirement: The console serves before Temporal and the identity provider answer
`member-console start` SHALL open its listener once it has checked the
integrations' declarations, resolved the secret files, validated the
configuration, derived the Stripe mode, opened and checked the database, handed
the Stripe key to the Stripe SDK, applied the configuration overrides, ensured
the boot data state, bound the integrations' connections, probed the session
store and built the router. It SHALL NOT wait for Temporal or for the identity
provider's discovery document; both are fetched in the background (Requirement:
Resilient Temporal startup connection; Requirement: Sign-in waits for the
identity provider's discovery document, and nothing else does), and the
background work SHALL start only after the port is bound, so that a start that
cannot bind its port, or whose authentication setup fails, exits having started
neither. Every write to the configuration SHALL happen before the listener opens
and before any background work starts; background work SHALL only read it. When
serving stops, because the port cannot be bound or the server fails, start SHALL
log the error and exit with a non-zero status. No route SHALL be added for probes: an anonymous
`GET /` SHALL answer 302 to `/login` without contacting the identity provider,
Temporal or the session store.
#### Scenario: The ask answers while Temporal and the identity provider are unreachable
- **WHEN** start runs with `temporal-host` naming an address that refuses connections and an identity provider that answers 503
- **THEN** `GET /domains/ask` for a servable name SHALL answer 200 within one second of the listener opening
- **AND** for an unknown name it SHALL answer 404
#### Scenario: The ask answers while the session store is unreachable
- **WHEN** start runs with a session store address that refuses connections
- **THEN** `GET /domains/ask` SHALL answer 200 for a servable name and 404 for an unknown one, with and without a session cookie
- **AND** `/static/app.css` SHALL answer 200 and an anonymous `GET /` SHALL answer 302 to `/login`
#### Scenario: A port already in use stops the start
- **WHEN** start cannot bind its port
- **THEN** it SHALL log the error and exit with a non-zero status
- **AND** it SHALL NOT have started its Temporal connection or its identity provider fetch
#### Scenario: The root is a probe that needs nothing else
- **WHEN** an anonymous `GET /` arrives while Temporal, the identity provider and the session store are all unreachable
- **THEN** the response SHALL be 302 to `/login`
@@ -0,0 +1,134 @@
## MODIFIED Requirements
### Requirement: Stripe webhook endpoint ingests events asynchronously
The system SHALL expose a `/webhooks/stripe` HTTP endpoint that verifies Stripe webhook signatures, stores the event, starts the event's workflow, and returns a success response without processing the event in the request path. The endpoint SHALL acknowledge with 2xx only when the event has been durably recorded in `core.webhook_events` and its workflow started, when the event is a duplicate of one already finished, or when the event is recorded `refused` because its environment disagrees with the key's mode (Requirement: Webhook events are captured idempotently), which records the row and starts nothing; when the insert fails, or the workflow cannot be started while the console is connected, the endpoint SHALL return HTTP 500 so Stripe redelivers the event. While the console is not connected to Temporal, the endpoint SHALL record the event and SHALL answer HTTP 503 with a `Retry-After` header and the body `Temporal is not connected`, leaving the row `received`, so Stripe redelivers it. A redelivery of an unfinished event SHALL start its workflow again, which is a no-op when the execution is running; a redelivery of a refused event starts nothing, because `refused` is not an unfinished status.
#### Scenario: Valid Stripe webhook is accepted
- **WHEN** Stripe POSTs a webhook event with a valid signature to `/webhooks/stripe`
- **THEN** the handler persists the event to `core.webhook_events` with status `received`, starts its workflow, and returns HTTP 200
#### Scenario: Insert failure answers 5xx so Stripe redelivers
- **WHEN** the `core.webhook_events` insert fails (for example, a transient database error)
- **THEN** the handler logs the failure and returns HTTP 500 without acknowledging the event
- **AND** no event is silently dropped: Stripe's redelivery of the same event later succeeds and is recorded once
#### Scenario: Workflow start failure answers 5xx and keeps the row
- **WHEN** the event is recorded but its workflow cannot be started
- **THEN** the handler returns HTTP 500 with the row kept, and Stripe's redelivery starts the workflow
#### Scenario: An event before Temporal connects is recorded and answered 503
- **WHEN** a signed Stripe event arrives while the console is not connected to Temporal
- **THEN** the handler records the row `received`, starts nothing, and returns HTTP 503 with `Retry-After`
- **AND** once Temporal connects, the boot sweep or the redelivery starts the event's workflow, and a redelivery is then answered HTTP 200
#### Scenario: Duplicate delivery is acknowledged without a second record
- **WHEN** Stripe redelivers an event whose `(provider, provider_event_id)` is already recorded
- **THEN** the handler returns HTTP 200 and no second row is written; if the row is unfinished the workflow start is issued again, and if it is finished nothing starts
#### Scenario: Invalid Stripe signature is rejected
- **WHEN** a request to `/webhooks/stripe` has an invalid or missing signature
- **THEN** the handler returns HTTP 400 without persisting the event
#### Scenario: Webhook endpoint bypasses CSRF protection
- **WHEN** a request is made to `/webhooks/stripe`
- **THEN** cross-origin protection SHALL NOT refuse it, and the handler processes the request on its signature alone
#### Scenario: A refused event is acknowledged without a workflow
- **WHEN** a signed Stripe event arrives whose environment disagrees with the key's mode
- **THEN** the handler records the row `refused`, starts no workflow, and answers HTTP 200
### Requirement: Temporal workflow polls and executes integration outbox entries
The system SHALL register a relay workflow that polls `integration.integration_outbox` for `pending` entries and starts, for each, an entry workflow that dispatches to the action-type-specific executor. Once the console connects to Temporal, the system SHALL start the entry workflow for every Stripe entry left `failed` or `processing`.
#### Scenario: Pending outbox entry is executed
- **WHEN** an `integration_outbox` row exists with status `pending`
- **THEN** the relay starts the entry's workflow, which marks the row `processing`, dispatches to the appropriate action executor based on `action_type`, and on success marks it `completed` with the attempt count
#### Scenario: Recognized action types are dispatched to specific executors
- **WHEN** an outbox entry with a recognized `action_type` (e.g., `create_stripe_customer`) is processed
- **THEN** the workflow routes the entry to the corresponding executor which performs the external action
#### Scenario: Unrecognized action types succeed as no-ops
- **WHEN** an outbox entry with an unrecognized `action_type` is processed
- **THEN** the workflow logs the action and marks the entry `completed` without performing an external action
#### Scenario: Unfinished entries are swept once Temporal connects
- **WHEN** the console connects to Temporal with Stripe entries at `failed` or `processing`
- **THEN** it starts each entry's workflow; `pending` entries are the relay's, and finished entries are left alone
### Requirement: Each Stripe webhook event is processed by its own workflow
The system SHALL process each recorded Stripe webhook event in a Temporal workflow execution of its own, keyed by Stripe's event id, started by the webhook endpoint once the event is recorded. The workflow SHALL run the event's handler under Temporal's retry policy (from one second, doubling to ten minutes, within a budget of one day) and SHALL end the retries at once for an error the handler declares non-retryable. Each failed attempt SHALL be recorded on the event's row with Temporal's attempt number and the error. When the budget is spent or the failure is terminal, the workflow SHALL mark the row `dead_letter` and fail. A `dead_letter` row of a routed invoice event SHALL be moved to `completed` or `skipped` by the invoice sweep once its invoice converges; any other `dead_letter` row stays for an operator. Once the console connects to Temporal, after it has published the connection to the endpoint, it SHALL start a workflow for every Stripe row not yet finished, so a row recorded by a process that died before starting its workflow, or recorded while the console was not connected, is not stranded. The sweep overlaps live redeliveries, and a start for an event whose earlier execution has completed runs again, so the event's activity SHALL change a row to `processing` only from `received`, `processing` or `failed`, and SHALL return without dispatching the event when the row is at any other status.
#### Scenario: Recorded event is processed
- **WHEN** the endpoint records a new Stripe event
- **THEN** a workflow keyed by that event id starts, transitions the row to `processing`, runs the handler for the event type, and marks the row `completed` or `skipped`
#### Scenario: A failed attempt is retried and recorded
- **WHEN** the handler fails retryably
- **THEN** the row reads `failed` with the attempt number and the error, and Temporal runs the handler again after its backoff
#### Scenario: Exhausted or terminal failures are dead-lettered
- **WHEN** the handler fails through the whole budget, or fails with a non-retryable error
- **THEN** the row is set to `dead_letter` with the error and the workflow execution fails
#### Scenario: A dead-lettered invoice event is resolved by the sweep
- **WHEN** the invoice sweep converges or skips the invoice a dead-lettered `invoice.paid` names
- **THEN** the row becomes `completed` or `skipped` and keeps its error message
#### Scenario: A second start for the same event is a no-op
- **WHEN** a start is issued for an event whose workflow execution is already running
- **THEN** the running execution is used and no second one starts
#### Scenario: Unfinished rows are swept once Temporal connects
- **WHEN** the console connects to Temporal with Stripe rows at `received`, `processing` or `failed`, including rows recorded while it was not connected
- **THEN** it starts each row's workflow; finished rows and other providers' rows are left alone
#### Scenario: A finished event is not processed again
- **WHEN** a workflow for an event whose row is already `completed`, `skipped`, `refused` or `dead_letter` runs its processing activity, because a sweep that read the row earlier started it after a redelivery's run finished
- **THEN** the activity returns without dispatching the event and the row is unchanged
#### Scenario: A failed attempt's retry is processed
- **WHEN** Temporal retries the activity for a row at `failed`
- **THEN** the row is marked `processing` and the event is dispatched again
#### Scenario: Handled event types are dispatched to specific handlers
- **WHEN** a webhook event with a recognized event type is processed
- **THEN** the workflow routes the event to the corresponding handler which updates domain state; recognized prefixes include `customer.`, `product.`, `price.`, `checkout.session.`, `customer.subscription.`, `invoice.`, and `payment_method.`
#### Scenario: Unhandled event types are skipped
- **WHEN** a webhook event with an unrecognized event type is processed
- **THEN** the workflow marks the event `skipped` without error
### Requirement: Stripe mode is derived from the API key
The console SHALL derive its Stripe mode from the configured API key's prefix at boot: `sk_live_` or `rk_live_` is live, `sk_test_` or `rk_test_` is test, an empty key is unset, and any other prefix SHALL fail boot with a message naming the accepted prefixes. No setting SHALL declare the mode; the `stripe-mode` configuration key is retired and a stored override for it is removed by migration. Every surface that shows or acts on the mode (the test-mode banner, the mode label on the overview and the Stripe provider page, the readiness panel's Payment processing detail, dashboard links, Checkout, a member's plan moves, the reconcile on the checkout return and the billing sweep) SHALL read the derived value; the readiness detail reads it as the key's half of a comparison against the environment the mapping records (Requirement: Mappings record the environment that made their ids), never as the environment the mapped object lives in. Dashboard links SHALL carry the mode and no account: the console SHALL NOT read the account from the API (a restricted key would need Stripe's Connect "Accounts Read" permission) nor accept a declared account id (nothing could check it against the key).
Beside the derived mode, boot SHALL hand the configured key to the Stripe SDK, before the console serves and before any Temporal work starts, and nothing SHALL change the SDK's key afterwards.
Beside the derived mode, boot SHALL compute a SHA-256 fingerprint of the raw API key, the digest only and never the key, which the environment check records as the key it ran under (Requirement: The environment check verifies ids under the current key).
#### Scenario: A live key is live
- **WHEN** the console boots with an API key beginning `sk_live_`
- **THEN** the mode is live, no test banner renders, and the mode label reads "Live mode"
#### Scenario: A sandbox key is test
- **WHEN** the console boots with an API key beginning `sk_test_`
- **THEN** the mode is test and the test banner renders on billing views
#### Scenario: A malformed key refuses boot
- **WHEN** the console boots with an API key whose prefix is none of the accepted four
- **THEN** boot fails and the message names the accepted prefixes
#### Scenario: The key's fingerprint is derived beside its mode
- **WHEN** the console boots with a configured API key
- **THEN** it holds the mode and the key's SHA-256 digest, and no surface and no log line carries the key itself
#### Scenario: The SDK holds the key before the first request
- **WHEN** the console boots with a configured API key while Temporal is unreachable
- **THEN** the Stripe SDK SHALL hold that key before the first request is answered
- **AND** Checkout SHALL read the mode boot derived
@@ -0,0 +1,67 @@
## MODIFIED Requirements
### Requirement: Resilient Temporal startup connection
When `temporal-host` is configured, `member-console start` SHALL connect to Temporal in the background and SHALL NOT wait for the connection before it serves. It SHALL retry a failed connection for the life of the process and SHALL NOT exit because Temporal is unreachable, refuses its authorization, or cannot start the worker.
One connection attempt SHALL be the dial, bounded to twenty seconds and respecting context cancellation, followed by the worker preconditions, the worker's construction and its start. An attempt that fails at any of these steps SHALL close the client it dialled and SHALL be retried, except the refusal of Requirement: The console starts no worker while a run of a removed FedWiki workflow type is open, which keeps the client and reads its list again. Retries SHALL wait half a second after the first failure, doubling after each further failure up to a cap of fifteen seconds, each wait lengthened by a random fraction of up to half of itself, so that simultaneous consoles do not reconnect in lockstep.
The retries SHALL be logged with decay: the first failure at once; a failure whose error text differs from the last one logged at once, but at most one such line per thirty seconds; and otherwise one line per five minutes carrying the number of attempts since the last line. Each line SHALL carry the attempts, the time elapsed since start, the next wait and the error. The lines SHALL be Warn until `temporal-connect-timeout` has passed since start and Error afterwards, and the first line after that time SHALL be written when it passes. The default of 90 seconds exceeds the identity provider's key-refresh window, so that a cold-start authorization failure, where Temporal is reachable but has not yet loaded the provider's signing keys, resolves before the log turns to Error. A refusal of Requirement: The console starts no worker while a run of a removed FedWiki workflow type is open, and a failure to build or start the worker, SHALL be logged at Error from the first line, through the same decay. The connection, once made, SHALL be logged once with the attempts and the time it took.
After a successful attempt, start SHALL publish the connection to every handler (Requirement: A Temporal-backed action reads the connection when it runs) and only then run each integration's startup hook, the entitlement recompute and plan act pollers, the desired-state relay and read-back schedules, the Stripe environment check and public-details read, and the sweep schedules, so that every sweep those hooks run sees the rows recorded while Temporal was not connected.
#### Scenario: First boot after a cold start succeeds without a re-run
- **WHEN** `member-console start` connects to a Temporal server that is reachable but transiently rejects authentication because its signing-key cache has not yet loaded
- **THEN** start SHALL retry the connection with backoff
- **AND** once the server's key cache refreshes, the connection SHALL succeed and the worker SHALL start, without the operator re-running `start`
#### Scenario: An unreachable Temporal does not stop the console
- **WHEN** Temporal stays unreachable for longer than `temporal-connect-timeout`
- **THEN** start SHALL keep serving every route that does not need Temporal
- **AND** it SHALL keep retrying, and its retry lines SHALL be Error once that time has passed
- **AND** the process SHALL NOT exit
#### Scenario: Healthy Temporal connects immediately
- **WHEN** Temporal is reachable and authenticating normally
- **THEN** the first attempt SHALL succeed with no added delay
#### Scenario: The escalation time is configurable
- **WHEN** `temporal-connect-timeout` is set to a non-default value
- **THEN** start SHALL use that value as the time after which its retry lines are Error
#### Scenario: Rows recorded before the connection are swept
- **WHEN** a Stripe webhook event is recorded while Temporal is not connected, and Temporal then connects
- **THEN** the connection SHALL be published before the Stripe startup hook runs
- **AND** the hook's sweep SHALL start that event's workflow
#### Scenario: An hour of a steady failure writes a bounded log
- **WHEN** every attempt fails with the same error for one hour
- **THEN** start SHALL write at most fourteen retry lines in that hour
## ADDED Requirements
### Requirement: A Temporal-backed action reads the connection when it runs
Every handler that starts, signals, describes, lists or cancels a Temporal workflow or schedule SHALL read the Temporal connection when the request arrives, never a value captured when its route was registered. No connection, because Temporal is not configured or because it has not connected yet, SHALL be answered as Temporal not configured is answered: the action's existing "not available" answer, and nothing for a best-effort start (a drain the pollers back up, a schedule's health on an operator page). The published connection SHALL NOT change for the life of the process, and it SHALL be published only after the worker has started. A handler SHALL read the connection once per request and use that reading for the whole request.
#### Scenario: An action before the connection is not available
- **WHEN** a member starts a domain verification while Temporal is configured but not yet connected
- **THEN** the response SHALL be the existing "not available right now" answer
- **AND** no workflow SHALL be started
#### Scenario: The same action after the connection works without a restart
- **WHEN** Temporal connects after the console began serving, and the member starts the verification again
- **THEN** the verification workflow SHALL start
#### Scenario: No handler keeps a client of its own
- **WHEN** the console's handler configurations are inspected
- **THEN** no handler configuration in the server or in an integration's web package SHALL hold a Temporal client as a field; each SHALL hold the shared connection holder
@@ -0,0 +1,185 @@
Tests follow `docs/testing.md`: database tests open the database through `dbtest`, and tests meet the same complexity limit as production code, so a table runs its rows through a named function. A test of a loop injects its clock, its random source and its attempt function and never sleeps on the wall clock; a test that waits on a real server polls with a deadline of its own and fails naming what it waited for. A test that asserts a log line reads the records of a captured `slog` handler and compares the level, the message and the named attributes, never the formatted text. A test that changes a viper key, `stripe.Key` or a package variable restores it in its cleanup. A page's or a refusal's test compares the whole sentence with its entry in the design's Copy table. A test that needs the stack's session store reads `MC_VALKEY_ADDR` and `MC_VALKEY_PASSWORD`, which `make test` loads from `test/.env`, and skips when they are unset, as database tests skip without `TEST_DATABASE_URL`.
Sections 1 to 7 are one commit each, and their items are that commit's parts; section 8 is not a commit. The sections are ordered so that each passes alone: the Stripe key and the configuration refusal first, then the discovery, the session store and the Temporal holder, each while Temporal still connects before the listener, and only then the background connection. Every commit passes `make test` with the test stack up, which runs `make lint` first, including `filler-copy`, `em-dash-copy`, `TestEnvironmentReferenceDocumentsEveryConfigKey`, the notebook-citations check and `deadcode`. `internal/server/testdata/routes.golden` and `route_pages.golden` do not change in any section; `internal/server/testdata/routes-not-ready.golden` is new in section 3 and changes only in section 5, as item 5.9 says. No screen changes (`make screens` lists none), and no existing test's result changes except where a section's items name the test; any other change is a stop. The copy is the design's Copy table, approved by the maintainer before section 1 starts; a string that differs from its approved entry is a stop. Sections 3 to 6 also pass `go test -race -p 1 -count=1` on the packages their last item names, run from the repository root with `test/.env` loaded as `make test` loads it and after `test/reset-test-db.sh`. Paths under `web/` are in the integration the item names.
**The race baseline.** `make test` never runs `-race`, and the gates run whole packages (`internal/server`, the three integrations, `cmd`) that may never have run under it. Before section 1, run item 6.11's command without `./internal/retryloop/`, which does not exist before section 3 (go stops on a missing package path before it runs anything), on `bdd042bc`: `test/reset-test-db.sh` first, then the command with `-p 1`, because the packages share one test database and parallel packages make count-based tests claim each other's rows (`make test` runs `-p 1` for the same reason). Record its result in section 1's commit message. The run made on `bdd042bc` before this correction, with `./internal/retryloop/` in the list and without `-p 1`, found no data race; the only failures were `internal/retryloop` (does not exist yet) and two `internal/workflows/desiredstate` relay tests that failed on counts because packages ran in parallel, and that pass under `-race` alone on a reset database. If a later run shows races that these sections did not introduce, each gate in sections 3 to 6 is narrowed with `-run` to the new concurrency tests of its section (`TestDiscoveryPublishesUnderLoad`, `TestHolderUnderConcurrentReads`, `TestServeConsoleUnderLoad`, the retry loop's tests), and the old races are handed to the maintainer as candidate issues; none is filed without his go.
**Breaks.** Each section's list is written to a file outside the repository and run with `go run ./scripts/mutation-check -list <file> -side new` on the section's commit, after its green control run; the tests are new or rewritten in the section, so there is no old side. Every record's `want_new` is a text the failing test prints only when it fails, the message of the assertion that the break trips, never a test or subtest name, which `go test` prints on a pass as well. Every record must be CAUGHT. `scripts/mutation-check` runs its tests with `go test -count=1 -v -run` and no `-race`, so a break that only a `-race` run catches is written as a command record, `"cmd": "go test -race -p 1 -count=1 -run '^TestX$' ./pkg"` with `"exit": 1`, and the item below marks it as one. An `old` text below is the code as the section's tasks write it; when the committed code words it differently, the record is corrected to the committed text before the run, never dropped. A break that no test of its own section can see is named with the section whose test catches it, and runs with that section's list. The commit message lists every break with its result.
## 1. The Stripe key is set at boot, and every surface reads the boot mode
- [x] 1.1 `internal/integrations/stripe/stripe.go` (design D12): add `UseKey(apiKey string)`, which assigns the trimmed key to `stripego.Key` when it is not empty and does nothing otherwise, so the SDK sends the same value the mode and the fingerprint were derived from. Its doc comment says that `start` calls it once, after the mode and fingerprint are derived and before any goroutine starts, and that nothing assigns the key afterwards. `Startup` no longer assigns `stripego.Key`, and its doc comment says where the key is set; the invoice sweep trigger keeps reading `stripego.Key`. Delete `SDKKeyMode`.
- [x] 1.2 `cmd/start.go`: call `stripeintegration.UseKey(viper.GetString("stripe-api-key"))` directly after `FingerprintForKey`, with a comment that says why it is there. Remove `serverConfig.StripeKeyMode` and `workerCfg.StripeKeyMode`; set `workerCfg.StripeMode = stripeMode`.
- [x] 1.3 `internal/server`: delete `Config.StripeKeyMode` and its doc comment. `BillingCheckoutHandler.KeyMode` becomes `StripeMode string`, set from `cfg.StripeMode`, and `billing.go` reads it; `MemberProductsConfig.KeyMode` and the plans handler's `KeyMode` (`member_plans.go`) are deleted and the handlers read their `StripeMode`. `eagerReconcileOnReturn` passes `cfg.StripeMode`. `NewHandler`'s doc comment drops its sentence about `StripeKeyMode`, and the comment above the checkout registration says that `start` sets the SDK's key at boot.
- [x] 1.4 `internal/fulfillment`: `SwitchPlan`, `KeepCurrentPlan`, `CancelSubscription`, `ReconcileSubscription`, `ReconcileLatestSubscriptionForCustomer`, `SweepDueScheduledChanges` and the unexported functions they pass it to take `keyMode string` in place of `stripeKeyMode func() string`; each doc comment says it is the mode of the key the process runs under. `internal/workflows/worker.go`: `WorkerConfig.StripeKeyMode` becomes `StripeMode string`, and `wfBilling.NewActivities` takes the string. The Stripe workflows' activities pass `sdkKeyMode()` where they passed the function, and the comment of `key_mode.go` says the key it reads is set at boot, before the worker exists.
- [x] 1.5 Tests move to the boot mode on purpose (design F21): every call that passed `stripetest.KeyMode` passes `stripetest.KeyMode()`, and every `func() string { return "test" }` literal becomes `"test"`, in `internal/fulfillment` and `internal/server` (`billing_checkout_gate_test.go`, `billing_checkout_offer_db_test.go`, `billing_checkout_environment_db_test.go`, `new_handler_test.go`, `new_handler_db_test.go`, `plan_switch_timing_db_test.go`) and in the Stripe workflows' tests. The results of those tests do not change.
- [x] 1.6 `internal/integrations/stripe/stripe_test.go`: `TestSDKKeyMode` is deleted with its function. `TestUseKey`, one row per case through a named function: an empty key and a key of only whitespace leave `stripego.Key` as it was; `sk_test_usekey` is assigned; `" sk_test_usekey\n"` is assigned as `sk_test_usekey`. `TestCheckoutThroughTheRouter` (`internal/server/new_handler_db_test.go`) gains a live-price row, because the counter that used to show the router handing Checkout its mode is gone: a second product and price on a ladder of its own, mapped with `w.MapPriceIn(livePrice, true)`, posted through the same environment, which runs in `"test"` mode, answers 422 with a body containing `That product is no longer available`, and the test still sees exactly one `/v1/checkout/sessions` call; the test's doc comment says that Checkout, built from the server's configuration, checks the posted price against the boot mode and sends Stripe the configured base URL to return to. `TestStartupLeavesTheSDKKeyAlone` (a database test): with `stripego.Key` set to `sk_test_boot` and `stripe-api-key` set to `sk_test_hook`, `Startup` run with the smallest fake client that answers the calls it makes (the pattern of the existing fakes in `internal/server`'s tests) leaves `stripego.Key` at `sk_test_boot`.
- [x] 1.7 Breaks:
- `UseKey` assigns an empty key (its emptiness check removed): caught by `TestUseKey`'s empty row;
- `UseKey` assigns the untrimmed key: caught by `TestUseKey`'s `" sk_test_usekey\n"` row;
- `Startup` assigns `stripego.Key = viper.GetString("stripe-api-key")` again: caught by `TestStartupLeavesTheSDKKeyAlone`;
- the router hands Checkout an empty mode (`StripeMode: cfg.StripeMode` on `BillingCheckoutHandler` in `internal/server/server.go` replaced by `""`): caught by `TestCheckoutThroughTheRouter`'s live-price row;
- the eager reconcile's wiring (`eagerReconcileOnReturn` receiving `cfg.StripeMode`): covered by no break, because the reconcile also succeeds with an empty mode; the commit message says so;
- the checkout handler compares against `""` in place of its `StripeMode`: caught by `TestCheckoutRefusesPriceOutsideTheKeyEnvironment`;
- `SwitchPlan` reads `""` in place of `keyMode`: caught by `TestSwitchPlanRefusesUnreachableMapping`;
- `SweepDueScheduledChanges` passes `""` to the change it fires: caught by `TestSweepSkipsWithoutClaimingTheRow`;
- the `UseKey` call in `start`: no test of this section runs `start`; its break runs with section 6's list (item 6.10).
## 2. Integrations whose events are processed on Temporal need `temporal-host`
- [ ] 2.1 `internal/config/spec.go` (design D11): `ConfigKey` gains `NeedsTemporal bool`, with a doc comment that says what it declares, that it belongs on a key of a RequiredGroup, and why an integration that receives no events does not declare it. `CheckSpecs` refuses it on a key without a RequiredGroup with copy 8.
- [ ] 2.2 `internal/config/validate.go`: `validateTemporalNeeds(specs)`, appended after `validateRequiredGroups` in `ValidateStart`, reports copy 7 for each group, in first-seen order, that carries a `NeedsTemporal` key, has every key set, and meets an empty `temporal-host` (trimmed). A partly set group gets only the partial-group refusal, because it does not have every key set. Its doc comment names the two integrations that declare it today.
- [ ] 2.3 Stripe's `ConfigSpec` declares `NeedsTemporal` on `stripe-api-key`, and Discourse's on `discourse-base-url`; each `ConfigSpec` doc comment says why in one sentence. FedWiki declares nothing.
- [ ] 2.4 Tests. `TestValidateStartRefusesAGroupThatNeedsTemporal` (`internal/config/validate_test.go`), one row per case through a named function, with a spec list that mirrors Stripe's two keys and Discourse's two keys, each group carrying `NeedsTemporal` on one key: both Stripe keys set and `temporal-host` empty, copy 7 for Stripe in full; the same with `temporal-host` set, no error; only the Stripe key set, the partial-group refusal and not copy 7; no key set, no error; both Discourse keys set, copy 7 for Discourse in full; both Stripe keys set, `temporal-host` empty and `db-dsn` unset, both refusals in one joined error. `TestCheckSpecs` gains a row for copy 8. `TestIntegrationsThatReceiveEventsNeedTemporal` (a new `internal/integrations/registry_test.go`): over `integrations.All()`, Stripe and Discourse each declare `NeedsTemporal` on a key of a RequiredGroup, FedWiki on none.
- [ ] 2.5 Breaks:
- `validateTemporalNeeds` not called from `ValidateStart`: caught by the Stripe row;
- the check reads `temporal-namespace` in place of `temporal-host`: caught by the row with `temporal-host` set;
- the check fires on a partly set group: caught by the partial row (two errors where it wants one);
- `CheckSpecs` without the group check: caught by the copy 8 row;
- Discourse's `ConfigSpec` without `NeedsTemporal`: caught by `TestIntegrationsThatReceiveEventsNeedTemporal`;
- Stripe's `ConfigSpec` without `NeedsTemporal`: caught by the same test;
- FedWiki's `ConfigSpec` declaring `NeedsTemporal`: caught by the same test.
## 3. A retry loop, and sign-in that waits for the discovery document
- [ ] 3.1 `internal/retryloop` (new package, design D17): `Run(ctx, policy, attempt)` calls `attempt(ctx)` until it returns nil or `ctx` ends, and returns nil or `ctx.Err()`. `Policy` carries the first wait, the cap, the escalation time, an optional wake channel, the minimum gap after an attempt before a wake counts, the default message for a failed attempt that returns a plain error, the message for the success, the base attributes every line carries, a logger, and an injectable clock and random source (the real ones when unset). An attempt returns a plain error, or a `retryloop.Failure{Message, Level, Err}` carrying its own message and a level floor (Error for copy rows 10, 15 and 16); `AtCap(err)` marks an error after which the next wait is the cap, and a wake does not end that wait. Every line goes through the decay: no caller logs from inside an attempt (design D17). The decay and escalation are D17's table. The package doc comment states the numbers and the bound on lines per hour.
- [ ] 3.2 `internal/auth/auth.go` (design D3, D4): two unexported immutable values behind two `atomic.Pointer`s in `Config`, with a wake channel of buffer one: the verifier (the `*oauth2.Config`, the verifier, the provider, the revocation endpoint) and the sign-in value, `signIn{verifier *discovered; endSession string}`, which holds the end-session endpoint and a pointer to the verifier value it was published with, so that a request that loads the sign-in value never meets a verifier from another document. `NewAwaitingDiscovery(sessionManager)` returns a `Config` with empty holders; `Setup` builds its `Config` with it, reads from viper once every key the loop needs, and no longer calls `oidc.NewProvider`. `providerEndpoints(provider, issuer)` compares the document's `issuer` claim with the configured one and returns a typed `issuerMismatchError`; with a matching issuer it returns both endpoints, and a missing end-session endpoint is its own error, returned together with the revocation endpoint the verifier needs (today's error path returns it as `""`, `auth.go:149-150`); the provider is built with `oidc.InsecureIssuerURLContext(ctx, issuer)` so that go-oidc's own untyped issuer error cannot occur, and ID tokens are still checked against the configured issuer. `(*Config).Discover(ctx)` runs `retryloop.Run` with D17's numbers and a ninety-second escalation: each attempt builds the provider under a context carrying a dedicated `*http.Client` (`Timeout` from the package variable `discoveryAttemptTimeout`, ten seconds, which a test shortens) and a deadline of the same length, and stores only inside the matching-issuer branch: the verifier once (if it is not stored), logging copy 12 when the document has no revocation endpoint, and then, when the same document carries `end_session_endpoint`, the sign-in value once, after the verifier and pointing at the stored verifier. A document whose issuer does not match stores neither, whatever it carries. With both stored the loop logs copy 11 and returns. An `issuerMismatchError` or a missing end-session endpoint is returned as a `retryloop.Failure` marked `AtCap` with copy 10's message; any other failure is copy 9's message. `verifier()` and `signIn()` return the holders' values for a `Config` built by `NewAwaitingDiscovery`. For a hand-built `Config` both return a value whenever `OAuthConfig` is set, copying the exported fields as they are, an empty `EndSessionEndpoint` and a nil `Verifier` included, and nil only when `OAuthConfig` is nil; their doc comments say why both exist.
- [ ] 3.3 The handlers read each pointer they need once per request. `/login`, `/register`, `/callback` and `POST /logout` load the sign-in pointer and fall back to the verifier pointer only when it is nil, so the pair they use always comes from one document; re-derivation loads the verifier pointer. The gates and the logout branch test the pointer and not the endpoint's string, so a hand-built `Config` with an empty `EndSessionEndpoint` keeps today's answers. `LoginHandler`, `CallbackHandler` and `RegistrationHandler` begin with a gate: with the sign-in value missing, wake the loop with a non-blocking send, set `Retry-After: 5`, and answer 503 through `showFailure` with copy 1 (`/login` and `/callback`) or copy 2 (`/register`), before anything touches the session. `LogoutHandler` with no sign-in value destroys the session, revokes the refresh tokens when the verifier is published, logs copy 20, and answers `HX-Redirect: /` with 200 for an htmx request and 303 to `/` otherwise (design D5); with the sign-in value it runs as today, passing the value's verifier to `idTokenHint` and `revokeRefreshTokens`, which read nothing from `c`'s exported discovery fields and dereference nothing the value does not hold. `refreshIdentity` and `rederive` read the verifier value and keep today's nil-`Verifier` check (`auth.go:1106`), now applied to that value; with none, the session keeps its state and nothing is asked of the provider. The comment on `EndSessionEndpoint` and the "Setup guarantees one" log line of `LogoutHandler` are reworded to say where the endpoint comes from.
- [ ] 3.4 `internal/server/server.go`: `Start` runs `go authConfig.Discover(ctx)` after `NewHandler` and before `ListenAndServe`; the doc comment of `Start` says so. Nothing else in the boot order changes in this section.
- [ ] 3.5 `internal/retryloop` tests, each with a fake clock and a seeded source: `TestRunWaitsFollowThePolicy` (the exact waits of eight failures that the seeded source yields, each the base wait plus the seeded fraction of it, the cap reached and kept; a range check would accept zero jitter); `TestRunWakeEndsTheWait` (a wake during a fifteen-second wait starts the next attempt one second after the last began, and a wake within that second waits for the second); `TestRunAtCapWaitsTheCap` (the wait after an `AtCap` result is the cap, and a wake during it does not end it); `TestRunStopsWhenTheContextEnds` (returns `context.Canceled`, no further attempt); `TestRunLogDecay`, one row per pattern through a named function: an hour of one error (at most fourteen lines; the first is Warn, every line from ninety seconds on is Error, and the first Error line is written when the escalation time passes), an hour of an error whose text changes on every attempt (no two changed-text lines within thirty seconds), a `Failure` with level Error (Error from the first line, and the hour's count stays within the bound), and three failures then a success (three lines, then one Info line with `attempts` 4 and the elapsed time). Every line carries `attempts`, `elapsed`, `retry_in` and `error`.
- [ ] 3.6 `internal/auth` tests, with an `httptest` identity provider whose answer the test switches (`discoverytest` helpers in the test file, building on the fake discovery document of `logout_test.go`): `TestSignInWaitsForDiscovery`: while the fake answers 503, `/login` answers 503, `Retry-After: 5`, copy 1's heading as plain text without a renderer, no `Set-Cookie`, and the session store holds no new entry; after the switch to a usable document, a `/login` within the loop's wait answers 302 to the fake's authorization endpoint; `/callback` and `/register` give copy 1 and copy 2 before the switch. `TestDiscoveryKeepsAnUnusableDocumentOut`, one row per document: no `end_session_endpoint` (the verifier is published, the sign-in value is not, and `/login` still answers 503), and an issuer that is not the configured one, in a document that carries `end_session_endpoint` (neither value is published, `/login` still answers 503, and the error is the typed `issuerMismatchError`); each logs copy 10 at Error from the first line, and the same fake switched to a usable document is published without a new `Config`. `TestDiscoveryAttemptEnds`, one row per fake, with `discoveryAttemptTimeout` shortened to 200 milliseconds: a fake that accepts the document request and never answers (the attempt's line is logged within a second), and a usable document whose `jwks_uri` accepts and never answers (a `Verify` of an ID token, called with `context.Background()` in a goroutine, returns within a second, and the test fails with "Verify outlasted the attempt timeout" otherwise; a `Verify` under a deadline of its own returns at that deadline whatever client the key set uses, so only a `Verify` with no deadline shows that the dedicated client bounds the fetch). `TestSignInRequestWakesDiscovery`: the loop with an injected clock (an unexported package variable beside `discoveryAttemptTimeout` holds the clock `Discover` hands to the loop) waiting out a fifteen-second backoff; a `/login` starts the next attempt without advancing the clock past one second. `TestSignOutWithoutAnEndSessionEndpoint`, one row per state: before publication (the session is gone from the store, the answer is 303 to `/` and 200 with `HX-Redirect: /` for an htmx request, copy 20 is logged, and the fake received no request after the test began counting), and with the verifier published and no end-session endpoint (the same answers, and the fake received a revocation request carrying the session's refresh token). `TestSignedInSessionPassesBeforeDiscovery`: a session whose re-derivation is due, served through `Middleware` before publication, reaches the handler with its roles, and the fake received no request. `TestRolesAreRederivedWhileTheDocumentIsUnusableForSignIn`: the fake serves a document with the configured issuer and no `end_session_endpoint`, the role of a signed-in session is removed at the fake's token endpoint, and the session's next request after the interval carries no such role while `/login` answers 503. `TestDiscoveryPublishesUnderLoad`: twenty goroutines request `/login` and a signed-in page through `Middleware` while the fake switches; every answer is 503 or 302 for `/login` and 200 for the page, and the test runs under `-race` (item 3.10). Three existing tests change with the accessors, and each keeps its result because a hand-built `Config` returns values whenever `OAuthConfig` is set: `auth_login_test.go` (an `OAuthConfig` with no `EndSessionEndpoint` still gets 302 at `/login`), `logout_test.go` (an empty `EndSessionEndpoint` still answers 500, `:530`) and `new_handler_test.go`'s `routeAuth` (no `Verifier`, so re-derivation keeps today's nil check). `TestProviderEndpointsComeFromDiscovery` (`logout_test.go:486`) is rewritten for `providerEndpoints(provider, issuer)`: its "no end-session endpoint" subtest wants the missing-endpoint error together with the revocation endpoint, and a new subtest wants `issuerMismatchError`.
- [ ] 3.7 `internal/server`: `answer()` (the route table's recorder) records a `Retry-After` header when a response carries one, so `routes.golden` does not change. `TestNewHandlerRouteTableNotReady` builds the route environment with `auth.NewAwaitingDiscovery` over the test's session manager and writes `testdata/routes-not-ready.golden` with the rows of `TestNewHandlerRouteTable`. Its diff from `routes.golden` is the sign-in rows only: `/login`, `/register` and `/callback` answer 503 with `Retry-After: 5` and copy 1 or 2, and `POST /logout` lands on `/`. `TestNewHandlerUnavailableSignInIsStyled`: a browser `/login` before publication renders the styled page with copy 1's heading and one action, `Try again`, to `/login`.
- [ ] 3.8 Breaks:
- `LoginHandler`'s gate after `RenewToken`: caught by `TestSignInWaitsForDiscovery` (a `Set-Cookie` is written);
- the gate without the wake: caught by `TestSignInRequestWakesDiscovery`;
- the gate answers through `http.Error` instead of `showFailure`: caught by `TestNewHandlerUnavailableSignInIsStyled`;
- the gate answers 500 in place of 503: caught by `TestSignInWaitsForDiscovery` (`want 503`);
- the gate without `Retry-After`: caught by the same test;
- `/register` without the gate: caught by its row (a redirect to the registration endpoint);
- the sign-in value published from a document without an end-session endpoint (the check dropped): caught by `TestDiscoveryKeepsAnUnusableDocumentOut` (`/login` redirects);
- the sign-in value stored outside the issuer check: caught by the issuer row of the same test (`/login` redirects);
- the verifier published only together with the sign-in value: caught by `TestRolesAreRederivedWhileTheDocumentIsUnusableForSignIn` (the removed role is still carried);
- the verifier published from a document with another issuer (the `issuerMismatchError` check dropped): caught by `TestDiscoveryKeepsAnUnusableDocumentOut`'s issuer row;
- an unusable document returned as a plain error in place of `AtCap`: caught by `TestDiscoveryKeepsAnUnusableDocumentOut` (the copy 9 message in place of copy 10);
- the attempt on `http.DefaultClient` (the dedicated client dropped): caught by `TestDiscoveryAttemptEnds`'s `jwks_uri` row ("Verify outlasted the attempt timeout");
- `LogoutHandler` before publication answers 500 as today: caught by `TestSignOutWithoutAnEndSessionEndpoint`;
- `LogoutHandler` before publication redirects without destroying the session: caught by the same test (the store still holds it);
- `LogoutHandler` with the verifier and no end-session endpoint revokes nothing: caught by the same test's second row;
- `refreshIdentity` ends the session when no verifier is published: caught by `TestSignedInSessionPassesBeforeDiscovery`;
- `Run` without the cap (`backoff *= 2` unbounded): caught by `TestRunWaitsFollowThePolicy`;
- `Run` without the jitter: caught by the same test (the exact waits differ from the seeded source's);
- a wake that ends an `AtCap` wait: caught by `TestRunAtCapWaitsTheCap`;
- the wait ignores `ctx.Done()`: caught by `TestRunStopsWhenTheContextEnds`;
- the discovery holder is a plain field in place of an `atomic.Pointer` (command record, `-race`): caught by `TestDiscoveryPublishesUnderLoad`;
- a `Failure`'s level ignored (Warn before the escalation): caught by `TestRunLogDecay`'s Error-level row;
- the decay without its five-minute rule (a line per attempt): caught by `TestRunLogDecay`'s steady row;
- the escalation never reached (Warn throughout): caught by the same row;
- the changed-text rule without its thirty-second floor: caught by the changing row;
- a wake that ignores the one-second gap: caught by `TestRunWakeEndsTheWait` (the gap is asserted there; `TestSignInRequestWakesDiscovery` has one wake);
- `Start` without `go authConfig.Discover(ctx)`: no test of this section runs `Start`; its break runs with section 6's list (item 6.10).
- [ ] 3.9 `internal/embeds/static/error-handler.js`: the 5xx toast reads `Server error. Try again later.` (copy row 24). The check `filler-copy` cannot make, because it does not scan JavaScript, is `git grep "Please try again later" -- internal/embeds test` finding nothing after the edit; no test, golden or screen baseline carries the old string, so nothing else changes.
- [ ] 3.10 `go test -race -p 1 -count=1 ./internal/retryloop/ ./internal/auth/ ./internal/server/` passes.
## 4. The session store: a refusal stops the start, an outage answers 503
- [ ] 4.1 `internal/auth/auth.go` and `valkey.go` (design D14): `Setup`'s probe returns its error only when the store's reply refuses authentication: a `redis.Error` beginning `WRONGPASS`, `NOAUTH` or `NOPERM`, or an `ERR` reply to `AUTH` during the dial. Every other probe error logs copy 17 and continues, an error reply such as `LOADING` (the store answers `PING` with it while it loads its dataset, which is the state of a restart of everything together), `BUSY` or `MASTERDOWN` included. The session pool's dial takes `valkey-addr` once, when `Setup` builds the pool, in place of reading it on every dial (design D13). `NewAwaitingDiscovery` wraps the session manager's store in `taggedStore{scs.Store}` (`Setup` builds its `Config` through it, and so does the server's test of item 4.4), implementing `Find`, `Commit` and `Delete` and the `Ctx` variants scs prefers, so that every error the store returns is wrapped in `storeError`; there is no list of error types, and the doc comment says why (the codec returns `io.EOF` and `io.ErrUnexpectedEOF`, and redigo returns TLS and protocol errors of types no list names). `FailurePage` gains `RenderErrorPage(w, r, status, msg)`, which `SafeTemplates` already has. `NewAwaitingDiscovery` also sets the session manager's `ErrorFunc`, which writes through the latch writer of item 4.2: for `errors.As(err, &storeError)` it deletes the headers a handler may have set, which are every header whose name begins `HX-` (`HX-Redirect`, `HX-Refresh` and `HX-Trigger` among them), `Location`, `Set-Cookie` and `Content-Type`, answers `Retry-After: 5` and 503 through `showFailure` with copy 3 (action: `validReturnTo(r.URL.RequestURI())` for GET and HEAD, `/` when it refuses and for any other method) and logs copy 19's first message; any other error does the same with the styled 500 page, the recovery page's existing message (`An unexpected error occurred. Try again.`) and copy 19's second message. Either answer marks the latch.
- [ ] 4.2 `internal/auth` and `internal/server/server.go` (design D14): the latch writer lives in `internal/auth`, because the `ErrorFunc` there cannot import `internal/server`, which imports `internal/auth`. `(*Config).LoadAndSave(next)` wraps `w` in the latch writer and calls the session manager's `LoadAndSave`: once the `ErrorFunc` marks the latch, the writer drops every later `WriteHeader` and `Write`, so a handler's headers and body cannot follow the 503. `preAuthStack`'s session layer (`server.go:443`) becomes `skipSession(exempt, authConfig.LoadAndSave)`, which calls the next handler without loading the session for a request under `/static/`, for `/favicon.ico`, or whose path is in `exempt` (`/domains/ask` and the paths the mounts declare, the list `registerIntegrationRoutes` returns), and calls `LoadAndSave` for every other request. Its doc comment says which routes and why. `routes.golden` does not change, because `routeAuth` builds a hand-made session manager that keeps scs's default `ErrorFunc`, which never marks the latch.
- [ ] 4.3 `cmd/start.go` (design D18): when `server.Start` returns, `Run` logs copy 21 and calls `os.Exit(1)`.
- [ ] 4.4 Tests. `TestSetupRefusesAStoreThatRefuses` (`internal/auth`): against the stack's session store with a password the store does not accept, `Setup` returns an error that contains `failed to connect to Valkey`. `TestSetupServesWithoutAStore`, one row per store through a named function: `valkey-addr` naming a port that refuses (listened on, then closed), and a fake store that answers every command with `-LOADING Redis is loading the dataset in memory`; each gives a `Config` and copy 17 at Error with `addr`. `TestSessionStoreFailureAnswers503` (`internal/auth`, over a handler wrapped by `(*Config).LoadAndSave`, where the latch lives), with an `scs.Store` fake whose `Find` and `Commit` return a `*net.OpError`, one row per request through a named function: a browser GET with a session cookie to a page (503, `Retry-After: 5`, the styled page with copy 3's heading and `Try again` to the requested path), an htmx request with the cookie (503, the plain text `Sign-in unavailable`), a POST with the cookie (the action leads to `/`), a GET to `//evil.example/x` with the cookie (the action leads to `/`), a store whose `Find` returns a `tls.RecordHeaderError` (503, which no type list names), and `/login` without a cookie once discovery is published (503 from the commit, no `Location` header, and a body equal to the styled page alone, with no `Found` link after it); a second request without a cookie to a route whose handler sets `HX-Redirect` before its `WriteHeader` (the `bounceToLogin` shape) answers 503 with no `HX-Redirect` header; a handler that puts a value in the session and sets `HX-Refresh: true` without writing (the shape of `workspace_partials.go:211-214`) answers 503 with no `HX-Refresh` header when the commit fails; each logs copy 19's first message with `method`, `path` and `error`. `TestUndecodableSessionAnswers500`, one row per store answer: bytes the codec cannot decode, an empty value (`io.EOF`) and a truncated one (`io.ErrUnexpectedEOF`) answer the styled 500 page and log copy 19's second message, with no `Retry-After`. `TestSessionFreeRoutesSkipTheStore` (`internal/server`, the route environment over the failing store, its `Config` built through `NewAwaitingDiscovery` so that it carries the tagged store and the `ErrorFunc`): `/static/app.css` answers 200 with a session cookie, `/favicon.ico` answers 404 and not 503 with a session cookie (no route serves that path, so the styled 404 answers without loading the session), `/domains/ask` without its `domain` parameter answers 400 with a cookie, as the route table's anonymous probe does, the fake mount's declared exempt path answers as without a store, and `/` with the cookie answers 503.
- [ ] 4.5 Breaks:
- the probe fatal for every error, as today: caught by `TestSetupServesWithoutAStore`;
- the probe fatal for every error reply, `LOADING` included: caught by `TestSetupServesWithoutAStore`'s `-LOADING` row;
- the probe never fatal: caught by `TestSetupRefusesAStoreThatRefuses`;
- `ErrorFunc` not set (scs's default): caught by `TestSessionStoreFailureAnswers503` (`want 503`);
- the store not wrapped (`taggedStore` returns the store's errors as they are): caught by the same test (the `tls.RecordHeaderError` row answers 500);
- every error classified as a store error: caught by `TestUndecodableSessionAnswers500` (the empty-value row answers 503);
- the latch not marked: caught by `TestSessionStoreFailureAnswers503`'s `/login` row (`Location` present) and its `HX-Redirect` row;
- the `ErrorFunc` deletes a fixed list of headers without `HX-Refresh`: caught by `TestSessionStoreFailureAnswers503`'s `HX-Refresh` row;
- the action always `/`: caught by the GET row;
- the action built from `RequestURI` without `validReturnTo`: caught by the `//evil.example/x` row;
- `skipSession` without `/static/`: caught by `TestSessionFreeRoutesSkipTheStore`'s stylesheet row;
- `skipSession` without `/favicon.ico`: caught by its favicon row;
- `skipSession` without the exempt list: caught by its ask row;
- the `os.Exit(1)` after `server.Start`: `Run` is not run by a test; the boot test of section 6 proves that the serving half returns the error, and the line after it is read in review.
- [ ] 4.6 `go test -race -p 1 -count=1 ./internal/auth/ ./internal/server/` passes.
## 5. Temporal-backed actions read the connection per request
- [ ] 5.1 `internal/workflows/clientholder` (new package, design D7): `Holder` with `New()`, `Publish(client.Client)` (stores once; a second call panics with a message naming the holder), and `Client()` (nil before `Publish`, nil on a nil `*Holder`); `ErrNotConnected` with the text `Temporal is not connected`. The package doc comment states the contract of the design's holder table. There is no test-kit helper for a published holder: tests write `h := clientholder.New(); h.Publish(fake)`, because a package reached only by tests needs an entry in `scripts/deadcode-testonly.txt` and the prefix line `internal/testkit.*` does not cover `internal/testkit/temporaltest`.
- [ ] 5.2 `internal/server`: `Config.TemporalClient` and `Deps.TemporalClient` become `Temporal *clientholder.Holder`, and every handler configuration that held a `client.Client` holds the holder under the name `Temporal` (`operator_partials.go`, `member_domains.go`, and the handlers they build: `operator_domains.go`, `operator_enrollment.go`, `operator_entitlement_recompute.go`, `operator_integrations_stripe.go`, `operator_plan_acts.go`). Each handler reads `Client()` once at the start of the request and keeps its nil branch and its copy. `test/e2e/plan-management/e2e_test.go` sets `Temporal` in the same step, because its `TemporalClient: nil` stops compiling (item 5.8).
- [ ] 5.3 FedWiki (`fedwiki.go`, `web/api.go`, `web/partials.go`, `web/site_actions.go`, `web/customdomain.go`, `web/operator.go`) and Discourse (`discourse.go`, `web/operator.go`, its schedule health read): the same change. The functions the adapters build at registration (Discourse's `StartReconcile`, Stripe's `StartProcessing`) read the holder when they are called; with no client they return `clientholder.ErrNotConnected` (design D8, D9).
- [ ] 5.4 `internal/integrations/stripe/web/webhook.go` (design D8): when `StartProcessing` returns an error that `errors.Is` `clientholder.ErrNotConnected`, the handler sets `Retry-After: 5`, answers 503 with copy 4 as its body, logs copy 22 at Warn, and leaves the row `received`; every other start error keeps its 500. The `StartProcessing` field's comment and `ServeHTTP`'s doc comment say so.
- [ ] 5.5 `internal/server/operator_enrollment.go` (design D15): the issuance handler refuses a submission with `valid_until` while `Temporal.Client()` is nil, with copy 6 as the field error on `valid_until`, through the same refusal path as the form's other field errors, after the end date is parsed and checked (so a past or malformed date keeps its own field error, such as `Choose a time in the future.`, as `grant_resumption_operator_test.go:1412-1413` asserts for a save) and before `ConferGrant`. The management handler refuses, before its write and inside the `ValidUntil` callback (`operator_enrollment.go`, where `scheduleGrantExpiration` is chosen from `result.ValidUntil.Valid`), a submission whose end date differs from the grant's current one, whose new bound is set (`bound.Valid`) and while the client is nil, with the same field error, in the same place relative to the parse. A save that clears the bound is not refused. The two "no scheduler" branches and their messages are deleted; `scheduleGrantExpiration` keeps its branch for a start that fails.
- [ ] 5.6 `cmd/start.go`: build `clientholder.New()` and pass it as `serverConfig.Temporal`. When `temporal-host` is set, publish the connected client after `worker.Start` returns and before the first `Startup` hook (design D7); the hooks, pollers and schedules keep receiving the client. Temporal still connects before the listener in this section.
- [ ] 5.7 `TestNoHandlerHoldsATemporalClient` (`internal/server`, a structural test with `go/parser`): in every non-test file of `internal/server`, of `internal/integrations/*/web` and of the three adapters' own packages, no struct field's type is `client.Client` from `go.temporal.io/sdk/client`, under any import name; a failure names the file, the line and the field. The scan is a function over source text, and the test runs it on an in-memory source that imports the package as `temporalsdk` and one that imports it unaliased, each with a field of the package's `Client` type, and expects both found. The workflows packages' schedule managers, which receive a client per call from the hooks, are outside the scan, and the test's comment says why.
- [ ] 5.8 Tests. `internal/workflows/clientholder`: `TestHolder`, one row per case (a nil holder, before and after `Publish`, a second `Publish` panics) and `TestHolderUnderConcurrentReads` (readers loop on `Client()` while one goroutine publishes; every reading is nil or the client). The nine test files that pass a fake client as `TemporalClient` or `Temporal` publish the fake to a holder (`clientholder.New()`, then `Publish`), and their results do not change: `internal/integrations/fedwiki/web/oracle_test.go` and `requests_db_test.go`; in `internal/server`, `grant_resumption_operator_test.go`, `member_domains_rollback_db_test.go`, `new_handler_test.go`, `operator_integrations_stripe_test.go`, `operator_org_type_change_test.go` and `operator_stripe_public_details_test.go`; and `test/e2e/plan-management/e2e_test.go`, which `go test ./...` builds and whose `TemporalClient: nil` (`:220`) no longer compiles after item 5.2. The operator harnesses build their configuration without a client, so under D15 every save that sets an end date would be refused with copy 6 and change the result of tests that do not name the warning. The shared harnesses therefore take a published holder over a fake client whose `ExecuteWorkflow` succeeds, and every bounded save keeps its result: `newGLHarness` (`operator_grants_list_test.go:184-196`, used by `operator_grants_delivery_state_test.go:105` `TestGrantDeliveryState_EndToEnd` and `operator_grants_list_test.go:294` `issueAgreementGrants`), `newOtcHarness`, `newGRWorld` (`grant_resumption_operator_test.go:836`, `:1226` and `:1509` save with no `w.temporal()` call before them), and the plan-management `httpHarness`, whose `issueTrial` (`:345-365`) and `extendGrant` (`:389-430`) save a `valid_until` and want the trial conferred and a `transfer` transition. `operator_enrollment_manage_guard_test.go` (`TestManageGrant_SucceedsForSinglePoolOrg` at `:256`, `TestManageGrant_ExtendsExactlyTheNamedGrant` at `:292` and `:392`) takes its holder from the same harness. The e2e harness comment (`:202-205`) is rewritten to say that the harness publishes a fake client. The commit message lists these files. `TestDomainClaimReadsTheConnectionPerRequest` (`internal/server`, a database test): a member domains handler built once with an empty holder answers `Domain verification is not available right now.` and starts nothing; after `Publish` of a fake that records starts, the same handler, not rebuilt, starts the verification workflow. The same pattern for one FedWiki site action (`web/site_actions_test` or its database test) and for the Stripe environment check (`Temporal is unavailable.`, then a start). `TestWebhookBeforeTemporalAnswers503` (Stripe `web`, a database test with the committed-rows fixture of the existing receiver tests): a signed event with an empty holder answers 503, `Retry-After: 5`, the body of copy 4, a row `received`, and copy 22 logged; after `Publish`, the redelivery answers 200 and the fake recorded one start keyed by the event id. `TestWebhookBeforeTemporalRecordsFailed` (Discourse `web`, a database test): a `user_added_to_group` event for a managed group and a linked user with an empty holder answers 200, the row reads `failed` with `error_message` copy 5, and nothing panics; after `Publish`, the same kind of event reads `processed` and the reconcile started. `TestBoundedGrantNeedsTemporal` (`internal/server`, a database test), one row per case: issuance with an end date and an empty holder re-renders with copy 6 on the end date and writes no grant row; issuance without an end date issues; a management save that sets a new end date is refused with copy 6 and the grant is unchanged; a management save that clears the end date is saved with an empty holder; with a published fake, issuance with an end date issues and the fake recorded the expiration start. No test asserts the deleted "no scheduler" warnings (`git grep "no scheduler"` over the tests finds nothing), so the files above are the tests whose results the refusal would change without a published fake, and the commit message names them.
- [ ] 5.9 `TestNewHandlerRouteTableNotReady` builds its environment with an empty Temporal holder as well; `routes-not-ready.golden` is regenerated, and every row that changes from section 3's version is listed in the commit message with the reason it changed (a route table probe that reaches a Temporal-backed answer).
- [ ] 5.10 Breaks:
- the member domains handler reads the client at construction (a field filled in `NewMemberDomainsHandler` from `cfg.Temporal.Client()`): caught by `TestDomainClaimReadsTheConnectionPerRequest` (no start after `Publish`);
- the FedWiki site action handler reads the client at construction: caught by the FedWiki site action row of item 5.8 (no start after `Publish`);
- the Stripe environment-check handler reads the client at construction: caught by the Stripe environment-check row of item 5.8 (no start after `Publish`);
- the holder is a plain field in place of an `atomic.Pointer` (command record, `-race`): caught by `TestHolderUnderConcurrentReads`;
- a `client.Client` field added back to `MemberDomainsConfig`: caught by `TestNoHandlerHoldsATemporalClient`;
- a `temporalsdk.Client` field (the import name `fedwiki/web/operator.go` and `discourse/web/operator.go` use) added back to a handler configuration: caught by the same test's alias row;
- Stripe's `StartProcessing` reads the holder at registration: caught by `TestWebhookBeforeTemporalAnswers503` (no start after `Publish`);
- the webhook answers 500 for `ErrNotConnected`: caught by the same test (`want 503`);
- the webhook answers 503 before the insert (nothing recorded): caught by the same test's row assertion;
- Discourse's `StartReconcile` calls the nil client: caught by `TestWebhookBeforeTemporalRecordsFailed` (a panic);
- the issuance refusal after `ConferGrant`: caught by `TestBoundedGrantNeedsTemporal` (a grant row is written);
- the management refusal removed: caught by its row;
- the refusal applied to a grant without an end date: caught by the unbounded row;
- the management refusal applied to a save that clears the end date: caught by the clearing row;
- `Publish` stores a second client without panicking: caught by `TestHolder`;
- `Publish` in `start` after the hooks: no test of this section runs `start`; the order is the connector's from section 6, whose test catches it (item 6.10).
- [ ] 5.11 `go test -race -p 1 -count=1 ./internal/workflows/clientholder/ ./internal/server/ ./internal/integrations/stripe/... ./internal/integrations/discourse/... ./internal/integrations/fedwiki/...` passes.
## 6. Temporal connects in the background, and start serves before it
- [ ] 6.1 `internal/workflows/client.go` (design D17): delete `dialWithRetry`, its constants and `ClientConfig.ConnectTimeout`. `NewClient(ctx, cfg)` becomes one `client.DialContext` under a 20-second deadline, with the OAuth headers provider and the logger as today. `client_test.go`'s four `TestDialWithRetry_*` tests are deleted with the function; a test of the single attempt's deadline replaces them (a listener that accepts and never answers; the dial returns within the shortened deadline the test sets through a package variable).
- [ ] 6.2 `internal/workflows`: `ErrCannotCheck`, a sentinel a `WorkerPrecondition` wraps when it could not read what it checks. FedWiki's `listRunsError` reports `errors.Is(err, workflows.ErrCannotCheck)`; `openRunsError` does not. The doc comment of `WorkerPrecondition` says what the connector does with each (design D10). `openRunsError.Error()` (`internal/integrations/fedwiki/fedwiki.go`) takes copy 15's text in both forms: `... is still open: <types>. Let it finish on the previous release, or terminate it; the worker starts once none is open.` and the plural; its existing test, if it compares the sentence, compares the new one.
- [ ] 6.3 `cmd/temporal_connect.go` (new, design D6, D7, D10): `connectTemporal(ctx, t temporalConnect)`, where `temporalConnect` carries the client configuration, the worker configuration, the holder, the logger, the escalation time (`temporal-connect-timeout`, 90 seconds when zero), the policy's first wait and cap (two package variables, 0.5 and 15 seconds, which the boot test of item 6.8 shortens to 50 milliseconds), and the injected `dial`, `newWorker` and `afterConnect` functions. It runs `retryloop.Run` with D17's numbers and copies 13, 14, 15 and 16, and the attempt logs nothing itself: every failure is returned as a `retryloop.Failure`. One attempt: dial (a plain error, copy 13's message); `newCheckedWorker`; on a refusal that is not `ErrCannotCheck`, keep the client and return a `Failure` with copy 15's message and level Error, marked `AtCap`, so that the next attempt reads the list again on the same client; on `ErrCannotCheck`, a build error or a `worker.Start` error, close the client and return the error (a `Failure` with copy 16's message and level Error for the worker's two failures). On success, `Publish`, then `afterConnect(ctx, c, worker)`. `afterConnect` in production is today's block of `cmd/start.go` from the `Startup` hooks to the webhook partition schedule, moved unchanged into `startTemporalWork`.
- [ ] 6.4 `cmd/start.go` (design D1, D13, D18, D19): `Run` keeps steps 1 to 5 of the design's boot sequence (the key derivations without `UseKey`), then calls `serveConsole(ctx, logger, boot, database, integs, listenTCP(":"+port))`, where `boot` carries the Stripe mode, the key's fingerprint and the integrations' specs `Run` derived, logs copy 21 when it returns and exits 1. `serveConsole` calls `stripeintegration.UseKey` first, then runs the overrides, the boot data state, the connection binding, the mounts and the holder, then `server.Bind(serverConfig, listen)`, then `go connectTemporal(...)` when `temporal-host` is set (the Warn and the backlog otherwise, as today), then the bound server's `Serve(ctx)`. When `ctx` ends it waits for `connectTemporal` and `Discover` to return (a `sync.WaitGroup`) before it returns. A comment above the first `go` statement says that every configuration write is above it and that the goroutines take their keys once (design D13). The block comments of the old Temporal section and `startStripeEnvironmentCheck`'s "boots at the same speed" paragraph are rewritten to the new order.
- [ ] 6.5 `internal/server/server.go` (design D19): `Start(ctx, cfg)` becomes two steps. `Bind(cfg, listen func() (net.Listener, error))` runs `auth.Setup`, `NewHandler` and `listen` and returns the bound server, or the first error having started nothing. `(*Bound).Serve(ctx)` starts the discovery loop (`go authConfig.Discover(ctx)`) and serves on the listener; when `ctx` ends it calls `Shutdown` with a bounded context (five seconds), waits for `Discover` to return, and returns. Their doc comments say that nothing before `listen` waits on Temporal or the identity provider and that a failure of `Bind` starts no background work.
- [ ] 6.6 `--temporal-connect-timeout`'s help is copy 23; the viper default stays 90 seconds.
- [ ] 6.7 `internal/integrations/stripe/workflows/webhook.go` (design F34): `ProcessWebhookEvent`'s first `UPDATE` adds `AND status IN ('received','processing','failed')` and the activity checks the rows affected; when none is, it returns nil without dispatching, with a comment that says the sweep and a redelivery can start a completed event's id again. The guard is in the activity because the sweep's status read and the workflow start are two steps, and the workflow id's reuse policy lets a completed id run again. Test (`TestProcessWebhookEventSkipsAFinishedEvent`, a database test, one row per status through a named function): a row at `completed`, `skipped`, `refused` and `dead_letter`, with an event type whose handler fails on the empty payload, is returned nil, unchanged and not dispatched; a row at `received`, one at `processing` (a worker that died mid-attempt, which Temporal then retries) and one at `failed` are each dispatched: the activity returns the dispatch error, and the row reads `failed` with `retry_count` 1 and the error, because the handler fails on the payload and the row does not rest at `processing`.
- [ ] 6.8 Tests. `TestConnectTemporal` (`cmd/temporal_connect_test.go`), injected fakes and a fake clock, one row per case through a named function: three refused dials, then a dial that succeeds: the holder is published, `afterConnect` runs once after `Publish` (the fakes record the order of `Start`, `Publish` and `afterConnect`), and copy 14 carries `attempts` 4; a precondition that refuses twice and then passes: one dial, no worker until it passes, the holder empty while it refuses, copy 15 at Error, the second reading a cap's wait after the first; a precondition that returns `ErrCannotCheck`: the client is closed and the next attempt dials again after the backoff's first wait; `worker.Start` that fails once: the client is closed, the next attempt dials again, copy 16; `worker.Start` that fails on every attempt for an hour of fake time: at most fourteen lines carry copy 16, all at Error, because the attempt logs nothing outside the decay; a context that ends during a wait: `connectTemporal` returns and publishes nothing; failures past the escalation time: Error lines. `TestServeConsoleBeforeDependencies` (`cmd/serve_db_test.go`, a database test that also needs the stack's session store), with `temporal-host` naming a listener that accepts, counts and closes each connection (a closed port records nothing, so a count that stays zero could not fail), the connector's first wait and cap shortened to 50 milliseconds through the package variables of item 6.3, `oidc-idp-issuer-url` naming an `httptest` server that answers 503 until the test switches it to a discovery document whose issuer is its own URL, `stripe-api-key` `sk_test_serve<suffix>` with a webhook secret, the SDK key empty beforehand, and a servable placement seeded the way the domains registry's own database tests seed one, under the test's suffix. The test clears and reads the SDK key through `stripetest.ClearSDKKey(t)` (which restores it in `Cleanup`) and `stripetest.SDKKey()`, both new in `internal/stripetest`, because `.golangci.yml` lints tests and forbids `stripe.Key` outside `internal/(integrations/stripe|stripetest)/`. `serveConsole` runs in a goroutine with a `listen` that opens `127.0.0.1:0` and sends the address on a channel, which is the happens-before edge the key read needs; within one second of the address being received: the ask for the seeded name answers 200 and for an unknown name 404; `/static/app.css` 200; `/login` 503 with `Retry-After: 5`; a signed Stripe event (`webhook.GenerateTestSignedPayload`) 503 with its row `received`; and `stripetest.SDKKey()`, read after the first answer, is the configured key. After the switch, `/login` answers 302 to the fake's authorization endpoint within five seconds. Cancelling the context makes `serveConsole` return, and it returns only after `connectTemporal` and `Discover` have: the listener's count and the identity provider fake's count are equal when `serveConsole` returns and 500 milliseconds later, so a loop that outlived it and dialled again within its shortened wait is seen. A second row runs `serveConsole` with a `listen` that returns the error of binding an address already in use, and asserts that `serveConsole` returns that error and that, 500 milliseconds after it returned, the counting listener recorded no Temporal dial and the identity provider fake no discovery request. A third row sets `valkey-addr` to a refused port and asserts, with and without a session cookie, that the ask answers 200 and 404, `/static/app.css` 200, and an anonymous `/` 302 to `/login`. `TestServeConsoleUnderLoad` runs the first row with twenty goroutines requesting `/login`, the ask and a signed-in page across the switch, under `-race` (item 6.11).
- [ ] 6.9 Start this worktree's application on the stack (`go run . start` with `test/.env` loaded) and check that it serves; stop the stack's Temporal container and start the application again: the ask and `/login` answer while the boot log shows copy 13 at Warn, then Error after ninety seconds; start the container again: copy 14 appears and the process has not restarted. Point `oidc-idp-issuer-url` at an address that refuses and check that `/login` answers the styled 503 page and that the ask answers.
- [ ] 6.10 Breaks:
- `serveConsole` without `UseKey` (the call removed; section 1's record): caught by `TestServeConsoleBeforeDependencies` (the SDK key is empty);
- `UseKey` after the bound server's `Serve` is called: caught by the same assertion;
- `Serve` without `go authConfig.Discover(ctx)` (section 3's record): caught by the switch assertion (`/login` stays 503);
- `Serve` fetching the discovery document before `listen` (a synchronous first attempt that waits for success): caught by the one-second assertions;
- `serveConsole` waiting for `connectTemporal` before the bound server's `Serve`: caught by the one-second assertions;
- `connectTemporal` started before `listen` (before `server.Bind` returns): caught by the in-use row (a dial recorded);
- `serveConsole` returning without waiting for its goroutines: caught by the first row (a dial recorded after `serveConsole` returned) and, as a command record (`-race`), by `TestServeConsoleUnderLoad`;
- `skipSession` without the ask's path, over the real handler: caught by the third row (the ask answers 503 with a cookie);
- `connectTemporal` publishes after `afterConnect`: caught by `TestConnectTemporal`'s order row;
- `connectTemporal` publishes before `worker.Start`: caught by the same row;
- `connectTemporal` returns after the escalation time (the old budget): caught by the escalation row;
- the open-runs refusal closes the client: caught by the refusal row (a second dial);
- `ErrCannotCheck` treated as a refusal: caught by its row (no second dial);
- `worker.Start`'s failure left without closing the client: caught by its row;
- `listRunsError` without its `Is` method: caught by `TestConnectTemporal`'s `ErrCannotCheck` row run against FedWiki's real error type through the fake list;
- `worker.Start`'s failure logged from inside the attempt, outside the decay: caught by `TestConnectTemporal`'s hour-of-failures row (more than fourteen lines);
- `ProcessWebhookEvent` without its status guard: caught by `TestProcessWebhookEventSkipsAFinishedEvent`'s finished rows (a row read `processing` and the event was dispatched);
- the guard without `failed`: caught by the same test's `failed` row (the retry is not dispatched);
- the guard without `processing`: caught by the same test's `processing` row (nil returned, not dispatched);
- `NewClient` without the deadline: caught by the single attempt's deadline test.
- [ ] 6.11 After `test/reset-test-db.sh`, `go test -race -p 1 -count=1 ./cmd/ ./internal/auth/ ./internal/server/ ./internal/retryloop/ ./internal/workflows/... ./internal/integrations/stripe/... ./internal/integrations/discourse/... ./internal/integrations/fedwiki/...` passes.
## 7. Docs
- [ ] 7.1 `docs/production-deployment.md` section 7, as design D20 writes it: the steps in the order `start` runs them, configuration validation first; what serves before Temporal and the identity provider answer; sign-in at 503 until a usable discovery document is published; Temporal retried with an Error log after `temporal-connect-timeout`; a configured Stripe or Discourse needs `temporal-host`. Placeholders only, and no deployment's names.
- [ ] 7.2 `docs/environment-reference.md`: the `temporal-connect-timeout` row says what the key now sets; the `temporal-host` row says that a configured Stripe or Discourse needs it.
- [ ] 7.3 `docs/fedwiki-setup.md`, "Serving authorization": the paragraph about an unreachable console says that the console answers the ask as soon as it serves, whatever Temporal, the identity provider and the session store do, and keeps the bound for a console that is down.
- [ ] 7.4 `docs/identity-provider-setup.md` and `internal/auth/README.md`: "refuses to start" and "fails startup" become what the code does: the console serves and keeps sign-in unavailable, logging at Error, until the provider publishes the endpoint. The logout section says what sign-out does without an end-session endpoint, and the provider setup says that a document with the configured issuer and no `end_session_endpoint` leaves sign-in unavailable but does not stop session re-derivation.
- [ ] 7.5 `docs/temporal-authorization-setup.md`: one paragraph on the token URL: an internal address keeps the Temporal connection off the TLS proxy; the console no longer needs it before it serves.
- [ ] 7.6 `docs/stripe.md` and `docs/discourse-setup.md`: one sentence each that `temporal-host` is required, and why.
- [ ] 7.7 No code changes; `make test` passes.
## 8. Landing
- [ ] 8.1 With the test stack up, `make test` passes (it runs `make lint` first), item 6.11's race run passes (after `test/reset-test-db.sh`, with `-p 1`; narrowed as the race baseline says, if it came to that), and `openspec validate serve-before-dependencies --strict` passes.
- [ ] 8.2 Post a comment on #141 under CONTRIBUTING.md's rules for forge text, and close it. The comment states that the console now opens its port once the database steps have run and answers the certificate ask whatever Temporal, the identity provider and the session store do; that sign-in answers 503 until a usable discovery document is published, which a background loop fetches from the identity provider, and recovers without a restart; that Temporal is retried for the life of the process and `temporal-connect-timeout` now sets when the log turns to Error; that the Stripe webhook answers 503 until Temporal connects, so Stripe redelivers; that the Stripe library's key is set at boot and Checkout and the plan moves read the mode boot derives; that a configured Stripe or Discourse without `temporal-host` no longer starts, which a deployment changes its configuration for; that a bounded grant, or a new end date on a grant, is refused while Temporal is not connected; and that `start` exits non-zero when it cannot serve. It names the commits of sections 1 to 7.
- [ ] 8.3 At archive, two edits a delta cannot make are made in the same commit: the Purpose of `openspec/specs/temporal-connection/spec.md` stops saying that start retries within `temporal-connect-timeout` and exits non-zero, and says that start retries for the life of the process; the Purpose of `openspec/specs/error-pages/spec.md` names the 503 as well as the 404 and the 500.
- [ ] 8.4 Nothing in this change touches a production host. The candidate issues in the proposal's "Out of scope" (graceful shutdown, a backstop for grant expiry) and the session that cannot be decoded (design F27) are handed to the maintainer; none is filed without his go.