Files
member-console/status/archive/issues-resolved.md
T
cgalo5758 494f25860c Stabilize demo operator walkthroughs
Configure the personal org-type default ladder and floor vacant pools so
the seeded dataset survives grant revocation. Let the modal mechanics
test
fall back to plan-ladder action triggers in full-suite order.
2026-08-01 18:53:28 -05:00

95 KiB
Raw Blame History

Resolved & archived issues

Closed/superseded items moved out of ../issues.md on 2026-06-24 to keep active tracking lean. History also lives in git and openspec/changes/archive/.

Recently resolved (2026-08)

TestModalMechanicsWalkthrough always skipped in a full-suite run

RESOLVED (2026-08-01, same day as filed). The walkthrough triggered off a revoke button on the first org composite, but alphabetical package order runs it after grant_revoke, whose post-revoke restoration leaves the pool delivering a system-managed default grant — no revoke affordance, so it skipped every full-suite run. Since the walkthrough never fires the action (step 2 dismisses), any data-action-* trigger exercises the projection contract: the scan is now generic (button[data-action-url]) with a fallback to the plan-ladders listing, whose delete triggers exist on any seeded stack. Verified in full-suite order: both steps run and pass immediately after grant_revoke.

Labels: testing

Walkthrough demo preconditions + schema-consolidation-gate e2e failures

RESOLVED (2026-08-01), three parts. (1) TestMVPPlanManagement_ViaHTTP "step 8 expects success where the CreateNonPlanGrant form-split guard 422s": dissolved by the Doc 41 rework — the form split (and that step) no longer exist in the rewritten test; both twins verified green against the e2e database. (2) Must-defeats-skip: the extend walkthrough was fixed 2026-07-23 (subject scan + clean skip); the recurrences in grant_nonplan_test.go and products_test.go were converted to non-Must calls and verified live on the exact previously-panicking path (committed as a43a789). (3) Demo preconditions: durable fix landed in the demo seeder. seed-demo already carried the set/products/ladder/grant baseline; the genuinely missing piece was the personal org type's default_plan_ladder_id, without which the dataset drains — grant_revoke ends the delivering grant, the pool stays vacant, and the next run's grant_extend finds no subject. seedOrgTypeDefault now points the personal org type at demo-ladder (only when no default is configured) and floors vacant personal pools via ReapplyDefaultsIfVacant + MaterializePoolEntitlements — the same primitives the org-type commit endpoint and the post-revoke restoration use. Verified: the seeder is idempotent (re-run creates 0 rows), and two consecutive full runs of extend/nonplan/revoke/products all pass — the revoke walkthrough's vacancy-guarded restoration re-floors the pool at rank 0, so the dataset self-sustains indefinitely. Also corrected from an interim note: a bare (pre-ladder) delivery does NOT need revoke + re-grant to become a plan position — adding the product as a ladder tier self-aligns every live provision via align_conferral_shape inside the tier-add transaction (the attachment's created_at equals the tier row's). The walkthroughs read the app database by design; ./test/seed-demo.sh after alice's first login remains the documented precondition.

Labels: testing, flaky

Original entry (2026-07-05): the clean-stack suite gate proved these failures were NOT caused by the schema consolidation (unchanged code, queries verified, server paths proven at HTTP level under core.*): (1) TestMVPPlanManagement_ViaHTTP step 8 asserted success where the CreateNonPlanGrant form-split guard fired 422 as designed. (2) TestGrantExtendWalkthrough panicked — go-rod MustElementX defeated the test's own t.Skip; demo orgs had 0 active attachments. (3) TestGrantPlanWalkthrough (since retired with the per-pool form) stalled client-side against the demo dataset; TestGrantRevokeWalkthrough was unrunnable for the same demo-precondition reason. Prescribed fixes: use non-Must go-rod calls so skips work, and give the walkthrough demo dataset an org with an active plan grant. Both landed.

Test isolation: shared test DB polluted across parallel packages

RESOLVED (2026-08-01) by test-db-isolation: all three vectors closed. (1) Cross-run accumulation and (3) live-app interference: tests moved off the application database onto member_console_test, which test/reset-test-db.sh drops, recreates, and migrates at the start of every run (make test); the app keeps member_console to itself. (2) Same-run interference: the in-process e2e suite moved to its own member_console_e2e, and make test runs go test -count=1 -p 1 ./... so packages that commit rows others read cannot interleave. The recorded blocker — cluster-global roles created unguarded, so a second database in the cluster failed with role core_owner already exists — was a bug against the existing per-schema-roles requirement rather than a design limit: core, fedwiki, stripe, and discourse now guard CREATE ROLE with the pg_roles check the domains stream already used, and their Down sections tolerate a role another database still depends on. Verified: two fresh databases migrated back to back in one cluster (identical object counts), three consecutive green full-suite runs, and both named tests green with a live app booting its reconcilers against the app database.

Labels: testing, tech-debt, flaky

go test ./... runs packages in parallel against the same TEST_DATABASE_URL, so committed writes from one package leak into another's reads. Confirmed 2026-05-24: internal/provisioning TestAutoProvisionWithoutDefaultProduct asserts the personal org type has no default_plan_ladder_id, but fails when the test/e2e/operator-walkthroughs suite (which boots a full app and seeds/commits org-type defaults) runs concurrently. The test passes in isolation and across go test ./internal/... (no e2e). Not a product bug — a test-harness isolation gap. Options: give DB-touching suites their own schema/database (or a unique-per-run namespace), wrap committing suites so they clean up, or serialize the e2e suites (-p 1 for the e2e tag). Surfaced during member-ladder-aware-catalog apply; that change's own tests roll back via tx and do not pollute. Second instance confirmed 2026-07-05 (per-stream-ledgers verification): rerunning the suite against the same DB fails internal/provisioning TestAutoProvisionWithDefaultProduct/TestAutoProvisionTransactionIntegrity with 23505 on uq_entitlement_sets_name — their fixed-name sets ("Public Tier Default", "Empty Set (no rules)") are committed by the first run and the tests' t.Cleanup (provisioning_test.go:201-211, 342-352) only resets the org-type default ladder, never deletes the sets. First run against a fresh DB passes.

Update 2026-07-23: a visible symptom of this pollution — committed fixture ladders with non-0-based/non-contiguous tier ranks ("Recon Ladder …" 1-based, "Plan Webhook" rank-5) appearing in the operator UI — is remediated at both ends: the fulfillment/stripe-workflow fixtures now write 0-based contiguous ranks (pure append; the rank-0 default seeder swaps instead of gapping), and the plan-ladder structural-validation page + topology health strip gained a "malformed rank sequences" check that names any legacy offenders (166 on the long-lived stack DB at time of fix). Also isolation-relevant: per-schema DB roles are cluster-global, so a second database in the same Postgres cluster cannot run migrations ("role core_owner already exists") — per-run isolation needs a separate cluster, not just a separate database.

Third instance class confirmed 2026-08-01 (integration-config-parity verification): app boots against the polluted stack DB now interfere with DB-backed tests via the domains boot reconciliation — the orphaned-placement sweep and domain backfill adopted 18 leftover test-fixture sites into member claims, after which internal/integrations/fedwiki/web TestCreateFormAffordanceSurvivesRevocation/entitled,_no_claim and (per its own documented residue signature above) internal/integrations/discourse/workflows TestReconcilePersonTargeted fail — both reproduce on clean HEAD in a fresh worktree, so they are environmental, not change regressions. A stack-fresh clears them.

Inter-package test race: provisioning precondition vs org-type-change fixtures

RESOLVED (2026-08-01) by test-db-isolation: make test runs packages serially (-p 1), so the window where internal/server's org-type-change tests hold personal.default_plan_ladder_id set can no longer overlap internal/provisioning's precondition read. The e2e walkthroughs, the other half of the interleaving, moved off the package-test database entirely. Reproduced during verification (one failure in three parallel runs) and gone across three serialized runs.

Labels: bug, testing, flaky

go test ./... runs packages in parallel, and internal/provisioning TestAutoProvisionWithoutDefaultProduct asserts as a precondition that the personal org type has no default_plan_ladder_id — while the server_test org-type-change tests (and the e2e operator walkthroughs) temporarily set and restore that same committed row. When the scheduler interleaves them, the provisioning test catches the window and fails ("expected personal org type to have no default plan for this test"); it passes solo. Observed 2026-07-21 during the entitlement-rule-authoring gate (two of three full-suite runs). Fix ideas: the provisioning test could create its own throwaway org type instead of asserting on personal, or the suites touching personal could serialize via a shared lock/package. Until then, a lone internal/provisioning failure in a full run should be retried solo before being treated as real.

Discourse reconcile tests still exposed to committed group_mappings

RESOLVED (2026-08-01) by test-db-isolation: two mechanisms, matching the two ways a foreign mapping could appear. Residue from an earlier run is gone because the package-test database is dropped and recreated per run; a mapping committed by internal/integrations/discourse/web during the run can no longer be concurrent, because make test serializes packages. A third approach was tried and rejected: deleting foreign mappings inside the harness transaction does not hold under READ COMMITTED, which still sees mappings committed after the delete.

Labels: bug, testing

The reconcile suite (internal/integrations/discourse/workflows/reconcile_test.go) is tx-rollback against a fake Discourse server, but the shared stack DB's committed rows are visible inside the transaction. The 2026-07-21 harness fix (tx-private testflag_* resource key per harness) removed the biggest leak — desired-set members from live-session user_links — but TestReconcilePersonTargeted iterates all group mappings, so any committed mapping (e.g. an operator configuring one on the live stack, or a failed TestDiscourseMappingWalkthrough run leaving its walkthrough-members row) makes the fake 404 and the test fail. Currently the stack has no committed mappings; if this test starts failing, check discourse.group_mappings for residue first. Real fix: scope person-targeted reconciliation testing to harness-created mappings, or run reconcile tests against a dedicated database.

Recently resolved (2026-07)

Operator panel: heading hierarchy is broken (H1 → H6, no H2/H3/H4/H5)

RESOLVED (2026-07-31) by hardening-polish (milestone phase 10e): re-leveled across 29 templates: every operator page now carries an <h1 class="h2"> title (converging older pages with the overview/topology pattern), sections are H2, nested panels H3; zero <h5>/<h6> tags remain, visual sizes pinned with Bootstrap .hN utility classes.

Labels: bug, a11y, frontend

Operator pages jump straight from H1 ("Operator") to H6 for section titles and table headers, with one stray H5 ("Confirm" modal). Violates WCAG 1.3.1 / 2.4.6. Discovered in docs/operator-ux-walkthrough-evidence/landing/; cross-cutting across the operator surface. Candidate for M7-7c design-system foundation.

Operator forms: inputs missing autocomplete attribute

RESOLVED (2026-07-31) by hardening-polish (milestone phase 10e): all template forms carry form-level autocomplete="off" (admin-data and lookup fields; no member-facing form collects the member's own identity, which lives in Keycloak).

Labels: bug, a11y, frontend

Chrome flagged 3 inputs at the operator landing without autocomplete. Violates WCAG 1.3.5. Cross-cutting across operator forms. Candidate for M7-7c.

Operator template has at least one inline event-handler attribute

RESOLVED (2026-07-31) by hardening-polish (milestone phase 10e): re-verified 2026-07-31 with a loose pattern grep: zero inline on*= handlers remain; evidently removed during earlier CSP work. Closed as already-resolved.

Labels: bug, security, frontend

Observed during Phase A v1 walkthrough — strict CSP would reject it unless an exception is configured. Worth grepping templates for on*= attributes and lifting handlers out. Candidate for M7-7c.

Operator revoke-and-transition: empty product name in confirm body

RESOLVED (2026-07-31) by hardening-polish (milestone phase 10e): the confirm body and the grant-name table cell are conditionally templated; an unnamed (set-targeted/legacy) grant reads "Revoke this grant for this organization?" and the cell shows the unavailable-value marker. Render-tested.

Labels: bug, frontend, copy

The per-org Enrollment "Revoke" button's confirm modal interpolates {{ .ProductName }} into the body text. When the grant target is an entitlement set (not a product), ProductName is empty and the modal reads "Revoke for this organization? The pool returns to its org-type default and a downgrade or end transition is recorded." (two spaces, dangling preposition). Fix: either fall back to the set/grant name, or template the entire phrase conditionally. Discovered in Phase A v2. Candidate for M7-7e action-confirm pattern.

Operator plan-ladder Tiers: "no products to add" alert is misleading when ladder is full

RESOLVED (2026-07-31) by hardening-polish (milestone phase 10e): copy replaced with "No plan products are available to add. Every published product is already a tier on this ladder, or no products are published yet." (the old second sentence was stale pre-Doc-41 product-type language). Render-tested.

Labels: bug, frontend, copy

With all plan-typed products already in the ladder, the alert reads "No available plan products to add. Products must be published with no product type assigned." The first sentence is true (no candidates remain) but the second sentence advises a remedy that does not apply (existing products are correctly typed; they're already on the ladder). Copy bug. Candidate for M7-7e.

Catalog feature summaries render raw resource keys

RESOLVED (2026-07-31) by hardening-polish (milestone phase 10e): buildPlanFeatures now resolves labels through the same resolveResourceLabels/labelFor path as the entitlement view; DB-backed catalog test asserts "16 Wiki Sites" renders and fedwiki_sites never leaks.

Labels: ux

The member catalog's tier feature lines render raw resource keys ("· 1 fedwiki_site") while the entitlement view now leads with core.resource_keys.display_name + provider attribution (9f, dashboard-display-genericity). The feature-summary path (TierViewModel.EntitlementFeatures, member-product-discovery surface) should reuse the same label resolution (resolveResourceLabels/labelFor in internal/server/member_products.go) so the two member surfaces agree. Small, self-contained. Noticed during the 9f live walkthrough (2026-07-21).

Container image should not run as root

RESOLVED (2026-07-31) by hardening-polish (milestone phase 10e): runtime stage adds adduser -D -H -u 65532 app + USER app; verified UID 65532 in the built image; the app makes no runtime filesystem writes.

Labels: security, infrastructure

/login state-overwrite race on parallel requests

RESOLVED (2026-07-31) by hardening-polish (milestone phase 10e): the long-term fix from this entry is implemented: /login reuses an unconsumed state/nonce/verifier triple younger than 5 minutes (new state_issued_at session key), recomputing the PKCE challenge; callback success clears the triple. Unit-tested (parallel reuse, stale replacement, post-consumption regeneration).

Labels: auth, bug

The OIDC login handler (internal/auth/auth.go LoginHandler) generates a fresh state/nonce/code-verifier on every call and unconditionally writes them to the session. When two requests hit /login concurrently in the same session — e.g. an unauthenticated page load that fans out into a /favicon.ico fetch, both bouncing through the auth middleware's redirect-to-/login — the second call overwrites the first's state before the user finishes authenticating at the IdP. The IdP then returns the first request's state, but the session holds the second request's, producing "State mismatch" 400s on /callback. Symptom recurs whenever a new asset path slips out from under the public-paths allowlist (this is at least the second time we've hit it). Long-term fix: make /login idempotent — if an unconsumed state exists and is recent (e.g. < 5 min old), reuse it instead of clobbering; clear it on /callback success or expiration. Defends against the favicon case AND multi-tab login attempts. Short-term mitigation already shipped: added /favicon.ico to the public-paths allowlist.

Serve HTMX assets locally instead of from CDN

RESOLVED (2026-07-31) by hardening-polish (milestone phase 10e): closed as already-resolved: HTMX, Bootstrap, and SortableJS are all self-hosted in internal/embeds/static/ and no template references an external asset URL (verified 2026-07-31). SRI is moot for same-origin embedded assets.

Labels: security, frontend

Include SRI hashes.

Role extraction doesn't check resource_access

RESOLVED (2026-07-31) by hardening-polish (milestone phase 10e): extractRoles now returns the deduplicated union of top-level roles, groups, realm_access.roles, and resource_access.<client-id>.roles for the configured client; no location masks another. The broader configurable role-mapping design is tracked as a new follow-up issue.

Labels: bug, auth

extractRoles in auth.go doesn't check resource_access.<client-id>.roles. Investigate best pattern for IDP-agnostic role mapping.

Better configuration handling

RESOLVED (2026-07-31) by hardening-polish (milestone phase 10e): closed as superseded: boot-time configuration validation with aggregated, meaningful error messages shipped with 10b (startup-configuration spec).

Labels: dx

Validate config at boot with meaningful error messages.

Custom error pages

RESOLVED (2026-07-31) by hardening-polish (milestone phase 10e): styled full-page 404/500 rendering shipped: error.html in the page chrome, wired at the router catch-all (which previously served the dashboard for ANY unknown path), and the panic-recovery middleware; HTMX partial requests keep plain-text errors for the toast contract; template failure falls back to plain text. Also fixed en route: logging.FromContext could return a nil logger before initialization (SIGSEGV in the recovery path).

Labels: frontend

Make test-stack secrets unmistakably test-only

RESOLVED (2026-07-31) by hardening-polish (milestone phase 10e): the three mirrored Keycloak client secrets are re-valued to self-labeling TEST-ONLY-... strings across mc-config.yaml/seed-keycloak.sh/compose.yaml/fedwiki config.json.tpl; banners already existed in the remaining files and generated test/.env; the tracked realm export was already deleted and gitignored upstream; the fedwiki admin token is not safely re-valuable from the repo (its consumer holds a hash) and stays covered by the file banner per the spec's carve-out; Stripe test files under test/secrets/ were confirmed never git-tracked.

Labels: security, test-stack

The test/ stack seeds fixed, committed credentials (Keycloak admin/admin; client secrets mirrored across mc-config.yaml / seed-keycloak.sh / compose.yaml / fedwiki config.json.tpl; csrf-secret; fedwiki token; cookieSecret). Only mc-config.yaml and compose.yaml carry a "not for production" banner; the other secret-bearing files and the generated test/.env do not, and the opaque random-looking values carry no in-value marker — so a single value copied out of context could silently become a production secret. Separately, test/testdata/keycloak/master-realm.json is git-tracked and holds real RSA private keys, AES/HMAC secrets, argon2 password hashes, cleartext client secrets, and real-looking PII emails, yet compose never imports it (pure liability). Make every test secret obviously test-only (self-labeling values where the format allows, banner comments elsewhere) and untrack the unused realm JSON. Part of M12b (first-run friction); distinct from the production-focused "Session/CSRF secret generation and rotation strategy" above.

provider_configs should not be per-organization

RESOLVED (2026-07-22) by dynamic-provider-config (milestone phase 9g): the scope question is settled — config is app-level, and the stillborn stripe.provider_configs table (never read, written, or seeded in the repo's history; mode duplicated the stripe-mode ConfigKey) is dropped (stripe stream 00002). Runtime-managed non-secret config is delivered generically: operator-set overrides in core.integration_config_overrides layered over env by a boot-time overlay (internal/config.ApplyOverlay, resolution override → environment → default, apply-on-restart), surfaced per integration at /operator/integrations/{slug}/settings from ConfigSpec() declarations. Secrets stay env/file-based. The upstream design prescription (per-org provider_configs per provider schema, design/integration/architecture.md) is flagged in issues.md § Design feedback for upstream sync.

Labels: design-feedback, integration

The integration architecture models provider_configs as per-organization configuration. Member-console runs a single Stripe account for the cooperative — there's no multi-org Stripe Connect topology. provider_configs should be a single-row app-level config (or env-based), not org-scoped. Discovered during Stripe integration planning (2026-04-02). Broadened 2026-07-05 to cover the delivery mechanism (integration-declared config); delivery landed via integration-extraction (ConfigSpec(), Decision 5); the residual scope + runtime-managed config became phase 9g, resolved above.

Tier removal with active attachments: spec says permitted + reconciled, code hard-blocks

RESOLVED (2026-07-12) by tier-removal-preview-commit: reconciled removal is implemented as a preview → commit flow (holders classified by position source; other-source positions align-shrink with delivery continuing; default-sourced holders require an explicit keep — with honest floor-union copy — or migrate — end + floor-restore onto the current default, resolved after renumbering so rank-0 removal migrates onto the promoted tier). The commit is atomic (tier delete + renumber + all reconciliations in one transaction). The stealth-default-change concern this entry flagged is handled by the same consequence-preview idiom as reorder. The spec's confirmation-modal wording was amended to the preview shape; the modal remains for holder-less removals.

Labels: spec-code-divergence, plan-transitions, backend

The synced plan-ladder-management spec ("Operator tier management within a ladder") requires that removing a tier with one or more active pool_provision_ladders rows be permitted, not blocked — the confirmation states the affected-pool count and each pool's live provision of that product is reconciled via align_conferral_shape, ending its junction row on the removed ladder without fabricating a new commercial event. The code does the opposite: DeletePlanLadderTier (internal/server/operator_plan_ladders.go) counts CountActiveAttachmentsByTier and hard-rejects with "Cannot remove tier: N active attachment(s) exist". The permissive behavior was specced in the Doc 41 conferral-uniformity change (its "Tier membership changes reconcile existing live holders via align_conferral_shape" requirement covers the induction direction, which is implemented) but the removal direction was never built. One of the two is wrong: either implement reconciled removal per spec, or amend the spec to bless the guard. Note the guard currently does load-bearing work for the tier-reorder flow's design assumption that a deletable rank-0 tier has no incumbent holders — if reconciled removal is implemented, deleting a default ladder's rank-0 tier with holders becomes another stealth default change and should route through the same consequence preview as reorder. Discovered while authoring tier-reorder-preview-commit, 2026-07-12.

Member-console reads products.product_type directly instead of billing.product_kinds view

RESOLVED (2026-07-11) by dissolution (doc41-conferral-uniformity, upstream Doc 41 Decisions 134136): product_type is renamed display_category and demoted to presentation-only metadata, billing.product_kinds is deleted, and every behavioral read goes through structure (core.product_conferral_shapes for conferral, ladder membership for plan-ness, per-price columns for billing/consumption). The read-discipline cleanup this issue tracked is superseded — no label with structural claims remains to mis-read.

Labels: tech-debt, correctness, backend

Per upstream membcons-db Doc 35 (Product Kind Taxonomy), billing.product_kinds is the single authoritative read path for "what kind is this product?" — it derives 'plan' structurally from plan_ladder_tiers membership and delegates the three labeled kinds (addon, usage, one_time) to products.product_type. The view's WHEN plan branch is evaluated first; reading product_type directly misses the structural plan derivation because plans now carry product_type = NULL. The member-console code reads product_type directly in many places (e.g. operator_plan_ladders.go lines 223/431/469/487 use product_type IS NULL / IS NOT NULL as plan-vs-non-plan discriminator). This works today because the application enforces the structural invariant (a non-plan product never has tier rows), but it duplicates the view's discrimination logic and is structurally fragile: any future kind that develops its own structural derivation (Doc 35 §5 sketches one_time via prices.recurring_interval, usage via prices.usage_type, addon via a future relational construct) will silently break direct product_type readers while the view stays correct. Fix: audit reads of products.product_type in Go and SQL; route product-kind discrimination through billing.product_kinds everywhere the application asks "what kind is this?". Writes that declare a non-plan kind continue to set product_type (per Doc 35 §7 reading guide). Related: this is the underlying schema-side reason the two grant-issuance code paths (IssueGrant for plans via ladder; CreateGrant for non-plan via labeled kind) exist — the M7-7b IA decision keeps both paths and labels the issuance forms by intent; this issue tracks the read-discipline cleanup independently.

Directory structure for integrations

RESOLVED (2026-07-05) by integration-extraction: FedWiki and Stripe both now live under internal/integrations/{fedwiki,stripe}/, each with its own module, store, and workflows subpackages.

Labels: design-feedback

If more integrations arrive beyond FedWiki, consider internal/integrations/fedwiki/. Not urgent — database schema namespace provides separation.

Purchasability checklist: no operator affordance to fulfill "Stripe-mapped pricing"

RESOLVED (2026-07-03) by purchasability-sync-completion + multi-price-support (M10c, archived). The operator product readiness card now surfaces mapping state per product with an explicit Sync to Stripe action, a live-polling pending indicator (HTTP 286 stop-polling), a failed/dead-letter state with Retry, and a clean empty-state when Stripe isn't configured (stripeQ == nil). The row label reads "Payment processing" rather than the operator-opaque "Stripe-mapped price". Multi-price shipped alongside (default-price model + partial unique index on billing.prices(product_id) WHERE is_default), so the readiness card tracks the default price and the price-aware sync is product-idempotent (no duplicate Stripe product on re-sync).

Labels: bug, operator-ux, billing, stripe

Noticed 2026-06-23 while walking the operator catalog. A product's purchasability required HasActivePrice && StripeMapped; an operator could create an active price but then hit a dead end on "Stripe-mapped pricing" with no button to enqueue the sync and no pending/failed surfacing. Live verification of the multi-price make-default → sync-without-duplicate path and the ladder-append flows is left for maintainer confirmation against the running stack.

Org-type default select silently NULLs when the defaulted ladder loses its rank-0 tier

RESOLVED (2026-07-03)ListPlanLaddersWithRankZeroProduct now LEFT JOINs the rank-0 tier/product, so a rank-0-less ladder still renders as an option (labeled "no rank-0 tier — can't provision new orgs") and an org type's saved default keeps its selection instead of falling back to "None" and NULLing on save; the Current hint flags the broken state in red; and UpdateOrgTypeDefaultPlan refuses a rank-0-less ladder as a new default (re-saving the already-configured value stays allowed so a form submit can't destroy state while the ladder is rebuilt). Live-verified on a fresh stack: broken option renders selected + red hint, re-save preserves the DB value, new selection gets the friendly refusal banner. The verification also demonstrated the blast radius: with a broken default configured, new-member signup fails at "Failed to create user record" — exactly what the save-guard now prevents.

Labels: bug, operator-ux, data-loss

Surfaced by 12h's verify pass on audit finding #26 (2026-07-03). DeletePlanLadderTier renumbers survivors to 0..N-1 (so the common case always leaves a rank-0), but it has no last-tier guard: deleting the sole remaining tier of a ladder that is an org-type default leaves the ladder with zero tiers. The org-types form then sourced its dropdown from ListPlanLaddersWithRankZeroProduct, whose INNER JOIN on t.rank = 0 dropped the rank-0-less ladder — its option vanished, the select fell back to "None (no default plan)", the "Current:" hint disappeared, and Save wrote NULL, silently destroying the real default. (A blanket last-tier deletion guard was rejected — emptying a ladder to rebuild it is a legitimate workflow, and the invariant is org-type-default-specific.)

Temporal UI can't complete SSO login inside the Docker network

RESOLVED (2026-07-01) by keycloak-dedicated-test-realmtemporal-ui now gets extra_hosts: ${KC_HOSTNAME}:host-gateway (the same pattern fedwiki uses), so it reaches Keycloak at the host-facing URL the browser also uses and the token issuer matches; and it gains a keycloak-seed: service_completed_successfully dependency so it doesn't do OIDC discovery before the wikicafe realm exists. Live-verified: temporal-ui stays Up, the SSO flow redirects to /realms/wikicafe/.../auth?client_id=temporal-ui, and logging in as alice round-trips back to an authenticated Temporal UI with no console errors.

Labels: bug, test-stack, low-urgency

temporal-ui's TEMPORAL_AUTH_PROVIDER_URL/TEMPORAL_AUTH_ISSUER_URL are built from ${KC_HOSTNAME}:${KEYCLOAK_PORT} — the host-mapped Keycloak port. From inside the Docker network the compose network alias resolved that hostname to Keycloak's container (whose published port isn't reachable in-network), so OIDC discovery failed with connect: connection refused and the container exited. Surfaced live while verifying keycloak-dedicated-test-realm; pre-existing (identical failure against /realms/master). member-console's own Temporal client was unaffected (it dials Temporal directly).

Embedded example mc-config.yaml is unused dead weight

RESOLVED (2026-07-01) by member-console-init-scaffoldcmd/init.go now writes the embedded embeds.Config template as the scaffolded mc-config.yaml (dropping the one-line stub), and the template was rewritten as a neutral, well-commented starter (required keys with placeholders, optional Temporal/Stripe/FedWiki sections, secret-generation guidance, a dedicated-realm issuer example — not master). Verified: member-console init <dir> writes a config that parses with all required keys present.

Labels: dx, tech-debt

internal/embeds/embeds.go declared //go:embed mc-config.yaml (var Config embed.FS) and internal/embeds/mc-config.yaml was a fully-commented example — but nothing read embeds.Config; member-console init wrote an unrelated one-line stub instead. Noticed while auditing config examples for stale guidance (its issuer pointed at Keycloak's master admin realm).

Keycloak account console errors ("Something went wrong") — test stack ran on master realm

RESOLVED (2026-07-01) by keycloak-dedicated-test-realm — the test stack now creates and runs entirely on a dedicated wikicafe app realm (seed-keycloak.sh splits KC_ADMIN_REALM=master, used only to authenticate the bootstrap admin, from KC_REALM=wikicafe, which it creates and seeds). Live-verified: logging in as alice and opening Keycloak's account console renders "Account Management" with her profile populated and zero console errors; the two endpoints that previously 401'd (GET /realms/.../account/?userProfileMetadata=true and /account/supportedLocales) both return 200 on wikicafe. member-console's Temporal client, OIDC login, and FedWiki's OAuth2 discovery were all re-verified against the new realm end to end.

Labels: bug, auth, keycloak, test-stack, low-urgency

The member-facing "Identity and Access" sidebar link ({{ .KeycloakAccountURL }} = oidc-idp-issuer-url + "/account", present on index.html, products.html, billing.html) opened Keycloak's account console in a new tab. The link itself was correct, but the account console rendered "Danger alert: Something went wrong — Sorry, an unexpected error has occurred."

Diagnosed live (2026-05-22, logged in as bob):

  • The account-console SPA completed its OIDC round-trip and got a token (token POST → 200), then called the Account REST API (GET /realms/master/account/?userProfileMetadata=true and /account/supportedLocales) — both returned 401 with body {"error":"HTTP 401 Unauthorized"}.
  • The bearer token was sent and looked valid (azp: account-console, iss matches the host), but its decoded payload carried no aud (audience) claim, so the Account REST API rejected it. Keycloak logged no auth event for this (it's the bearer resource filter, not a login event).

Root cause: the test stack ran everything on Keycloak's master realm (KC_REALM="master", issuer …/realms/master). master is Keycloak's administration realm; its end-user account console is not a supported surface and the token minted for account-console there didn't carry aud: account. The seed only configured the member-console/fedwiki clients and never touched the built-in account clients/audience mappers.

Severity was low — cosmetic, local-test-stack-only. Login, sessions, the OIDC flow, and all member-console features worked regardless; only the external Keycloak account-management page was broken. Discovered during M8a (member-invoice-history) live verification (2026-05-22).

Recently resolved (2026-06)

FedWiki active-site swap cooldown is circumventable (archive+restore) and hardcoded

RESOLVED (2026-06-24) by fedwiki-swap-cooldown — the identity-based rotation gate closes the archive+restore bypass, and the window is now a configurable flag (default 30 days).

Labels: bug, fedwiki, entitlements, enforcement

Noticed 2026-06-23 reviewing the fedwiki-lifecycle-states swap/cooldown that just landed.

The per-workspace 30-day active-site swap cooldown (fedwiki.site_swap_policy, checkSwapCooldown + UpsertSiteSwapPolicy) is meant to stop a member on a reduced limit from rotating which site is active to edit each in turn. Two gaps:

  1. Circumventable via archive + restore of different sites. The cooldown is enforced only in the KeepSiteActive swap branch (the at-limit "Keep active" path, fedwiki_partials.go:558). ArchiveSite (→ set_status archived, frees a slot) and RestoreSite (→ quota-gated set_status active) touch neither the cooldown check nor the policy stamp. So a member at the limit can Archive site A (frees the slot) then Restore site B (reclaims it) — a net active-slot swap that never consults the cooldown. The explicit swap button is rate-limited; the archive→restore equivalent is not. (The below-limit KeepSiteActive reactivation is also un-gated, but that is correct — with headroom no slot is displaced.)

  2. Not configurable. const SwapCooldown = 30 * 24 * time.Hour (internal/workflows/fedwiki/reconcile.go:22) is a compile-time constant referenced directly by both the reconcile logic and the HTTP handler; the user-facing copy hardcodes "once every 30 days" too. No config flag, no per-tier / per-plan override — changing the window means a recompile, and it is globally uniform.

Fix direction: (1) route every active-slot transition through one chokepoint — gate any move into active that would swap the slot at the limit (restore, keep-active, reactivate) on the same policy, and stamp the policy on each — so archive→restore can't bypass it; (2) make the window configurable (config key, optionally overridable per entitlement tier) and derive the user-facing copy from it. Anti-rotation-abuse was an explicit fedwiki-lifecycle-states design knob; this tracks closing the loophole and the hardcoded duration.

Orphaned farm sites are invisible in the operator panel but block creation

RESOLVED (2026-06-24) — config/visibility fixed by system-tenant (ownerless sites land in the app-ensured System tenant and appear in the operator panel). The residual "no operator UI to act on ownerless sites" is tracked by the open issue "Member-console has no UI to manage sites it doesn't own."

Labels: bug, fedwiki, operator-ux, sync, partially-resolved

Config + visibility resolved (2026-06-23; refined 2026-06-24 by change system-tenant). The sync's landing workspace is now an app-ensured singleton System tenant, resolved by natural key (org_type='system') at boot — not a config UUID. This removes the original root cause (config-key drift between fedwiki-sync-default-workspace-id and the legacy fedwiki-sync-default-user-id): the fedwiki-sync-default-workspace-id key/flag and the interim test/seed/holding-workspace.sql are deleted; internal/systemtenant.Ensure find-or-creates the tenant idempotently on every boot. The sync projects every farm site via the owner-safe UpsertSiteFromFarm (ON CONFLICT(domain) never rewrites workspace_id), so member-created sites keep their owner while ownerless farm sites land in the System tenant and surface in the read-only operator panel via ListAllSites — no longer invisible (root cause 2). The System org has no pool, so force_reduce / auto-provisioning skip it and members' lists/quota are untouched. Residual (still open): the operator panel is read-only and ownerless sites have no member, so there is still no in-UI way to purge an orphan or free its farm name — tracked by "Member-console has no UI to manage sites it doesn't own" above. Freeing a name today still needs a direct FarmManager hard-purge. Original analysis retained below.

Noticed 2026-06-23. Creating a FedWiki site whose domain already exists on the farm but not in fedwiki.sites fails with "already taken", yet the site never appears in the operator FedWiki Sites page — a farm↔DB divergence with two causes:

  1. Sync default-workspace is never configured (config-key drift). The sync imports orphaned farm sites into fedwiki-sync-default-workspace-id (cmd/start.go, default ""), but test/mc-config.yaml still sets the legacy key fedwiki-sync-default-user-id. With the real key empty the sync logs "no default workspace ID configured, skipping site sync" every tick and imports nothing, so farm sites the DB doesn't already track stay orphaned. Fix the test config to set fedwiki-sync-default-workspace-id to a real (ideally dedicated system/import) workspace — and reconcile the two key names so the legacy one isn't silently ignored.

  2. The operator panel only shows DB rows, not farm truth. fedwiki-sites spec says the operator FedWiki Sites tab shows "all sites across all workspaces", but the farm is the source of truth for site existence. A farm site with no fedwiki.sites row is invisible there while still 409-ing on create. The operator surface should reflect farm/DB divergence — either by importing every farm site (needs the default workspace above) or by reading the farm and flagging untracked/unowned sites the operator can claim or purge.

Operationally (e.g. after a dev DB reset) this leaves orphaned farm sites that block name reuse with no in-app way to see or remove them; today the only recourse is the FarmManager API directly.

Transition.End reapplies default on every end, not only on "downgrade that leaves the ladder empty"

RESOLVED (2026-06-24) by transition-end-reapply-narrowing (with sibling transition-downgrade-honors-target for the downgrade arm) — ending a pool already at its default no longer re-mints the default, and downgrades land on their explicit target.

Labels: bug, entitlements, spec-deviation, low-urgency

openspec/specs/plan-transitions/spec.md:54 is precise about when Transition should delegate to auto-provisioning.ReapplyDefaultsForPool:

When the primitive records a downgrade or end, the existing active provision and its ladder attachment SHALL transition to status = 'ended'. On downgrade that leaves the ladder empty, the primitive SHALL invoke auto-provisioning.ReapplyDefaultsForPool(ctx, tx, pool_id) to re-issue the org's default grant if a default plan ladder is configured.

The conditional is "downgrade that leaves the ladder empty" — only downgrades, and only the ones that leave the pool with no ladder attachment. The code at internal/entitlements/transitions.go:185 is broader:

if transitionType == "downgrade" || transitionType == "end" {
    return reapplyDefaultsForPool(ctx, tx, poolID, actor, prior)
}

It reapplies on every downgrade and every end. Combined with ReapplyDefaultsForPool's idempotency check (which checks for an active ladder attachment but fails when called from a Transition that just pre-ended the attachment — internal/entitlements/reapply_defaults.go:152), this means an operator-revoke on a default-tier pool mints a new default-reason grant every time. That's the proximate cause of the duplicate-default-grants accumulation seen in 2026-05-17 testing — 4 zombie defaults built up in one test org through repeated revoke+transition cycles.

Severity: low-urgency now that the operator and member UIs are honest about delivery state (per docs/operator-ux-conventions.md §9a and the ListGrantsWithDeliveryByOrgID join). The duplicate grants stop visibly accumulating because the UI hides superseded rows; the underlying minting still happens silently per revoke cycle.

Fix sketch: narrow the conditional to match the spec. Concretely: pass the post-end ladder state into the branch decision, and only call ReapplyDefaultsForPool when the operation is downgrade and the result is a ladder-empty pool. end-target invocations on a pool already at the default tier should be a no-op (the operator asked to end, ending is the action; reapply was never the spec's intent there). Pure end on a non-default tier with default configured is the genuinely ambiguous case — design call required there.

Scope of change: delta-spec against plan-transitions (refine §"reapply trigger"), narrow the conditional in transitions.go, update the unit tests in internal/entitlements/transitions_test.go. Net change is small (~15-30 LOC + spec).

Why not now: the felt-bug is gone now that UI is honest. This is correctness-on-paper and a spec-deviation cleanup, both worth doing but neither blocking. Natural home is M7f integrity-guards or whenever the next entitlements milestone opens.

Related: docs/operator-ux-conventions.md §9a (the lifecycle/delivery split this bug interacted with), status/issues.md "Design gap: grants.status lifecycle vs operational delivery is undocumented" (the documentation gap that made it harder to notice).

RESOLVED (2026-05-23) — checkout.session.completed fulfillment provisions zero items — paid upgrade grants no entitlement

Labels: bug, billing, stripe, blocks-m8b, resolved

Resolved by change stripe-fulfillment-reconcile (workstream E), combining fix sketches (a) and (b): all subscription webhook events and the post-checkout return now route through a single API-backed, idempotent ReconcileSubscription (internal/fulfillment) that refetches the subscription's items from the Stripe API and converges core records + entitlements via the transition primitive; the skip-if-mapping-exists guards are removed and stripe.Key is set process-wide before the worker starts. Verified live — resending the original empty-line_items event for sub_1Ta7fm… created the subscription item, a subscription-linked provision, and the active HTTP Standard ladder attachment; the member UI now shows HTTP Standard as the current plan. Mock-backend regression tests (scenarios 5.15.7) landed in internal/fulfillment/reconcile_integration_test.go (custom in-process stripe.Backend + TEST_DATABASE_URL-gated fixtures).

Discovered 2026-05-23 during the first true end-to-end test of the upgrade flow (Stripe CLI stripe listen forwarding — never exercised before because no forwarding was configured).

In internal/workflows/stripe/webhook_subscription.go:

  • handleCheckoutSessionEvent (checkout.session.completed) builds the fulfillment items from session.LineItems (~line 108), then calls fulfillSubscription, which also writes the stripe.subscription_mappings row.
  • Stripe does not inline line_items in the checkout.session.completed event, so session.LineItems is empty → fulfillSubscription creates the billing.subscriptions row + mapping with zero subscription_items → no pool_provisions row → no pool_provision_ladders attachment.
  • handleSubscriptionCreated (customer.subscription.created) does receive items and would provision correctly — but it is idempotency-guarded (mapping exists, skipping, ~line 159). Because the checkout handler already wrote the mapping, the items-bearing event no-ops.

Net effect is order-dependent: the two events fire ~simultaneously; if checkout.session.completed wins, the subscription is recorded but nothing is provisioned — the member is charged and receives no entitlement.

Evidence: replaying bob's checkout.session.completed created billing.subscriptions (1 row) + mapping for sub_1Ta7fm…, but billing.subscription_items = 0 and no HTTP Standard attachment. The member-plans Upgrade button correctly stayed visible (there is genuinely no attachment — the attachment-based IsCurrent from member-upgrade-flow §5.1 is working).

Severity: blocks the M8b MVP checkpoint ("complete Checkout and immediately have quotas increased to Standard"). Paid upgrades do not activate.

Fix sketch (pick one): (a) make customer.subscription.created the sole provisioning trigger (it carries items) and have checkout.session.completed not pre-create the mapping that blocks it — cleaner, since the subscription-created path already provisions correctly; or (b) in the checkout path, when session.LineItems is empty, fetch the subscription's items from Stripe before fulfilling.

Scope: its own change — workstream E (Stripe fulfillment best-practices fix), separate from member-upgrade-flow. Apply correct patterns via the stripe-best-practices skill (read references/billing.md + references/security.md first). Needs a delta-spec against stripe-subscription-creation + the handler change + an end-to-end test (runnable now via webhook forwarding / workstream C). Gates the 8b MVP.

RESOLVED (2026-06-23, change fedwiki-lifecycle-states) — Downgrade read-only enforcement (force_reduce)

Labels: enhancement, fedwiki, entitlements, resolved

Resolution: wiki-plugin-farmmanager v0.4.1 shipped the reversible read-only/archived states, unblocking the member-console side. The fedwiki-lifecycle-states change implemented it: fedwiki.sites.status (observed projection), the set_status contract verb + FarmManager PATCH client, a SetSiteStatusWorkflow saga (quota = active sites), and the ReconcileFedWikiQuotaActivity force_reduce engine (parks excess read-only keeping the most-recently-modified active via force_reduced_at, reactivates on re-upgrade) running idempotently each sync tick. The member swap picker ("Keep active" on a read-only site) + first-free-then-30-day per-workspace cooldown (fedwiki.site_swap_policy) also landed. Remaining refinement: enforcement latency is currently the sync interval (hourly), not instant — an immediate per-trigger start would close that. Original analysis retained below for history.

When a member downgrades Standard (N sites) → Public (1 site) they are over the new quota. The M8c milestone originally described a site-selection UX: the member picks one site to keep active and the others go read-only. The 8c scoping pass (2026-05-31) split this out — it is a force_reduce-flavored enforcement (apply the lower cap immediately, below current usage), and the live default is not force_reduce.

What the design actually chooses: design/companion.md Decision 125 sets entitlement_set_rules.tier_reduction_policy default to defer (grandfather the prior allocation to period/term end) and offers clamp (lower the ceiling, preserve existing usage until it drains). clamp is the natural fit for sites and is already emergent — the FedWiki site-create workflow guards on usage < limit (internal/workflows/fedwiki/activities.go), so a downgraded member keeps their sites and simply cannot create new ones until back under cap. So 8c's MVP needs no selection/read-only step at all.

Blocked on infrastructure that does not exist yet:

  • No site-state model. fedwiki.sites (code: internal/fedwiki/migrations/00001_init.sql) has no status/access column; "read-only" is undefined. The design data model names active/suspended/deleted but not read-only, and the code has none of these.
  • No provider read-only operation. A per-provider read-only lifecycle operation is exactly what the M9 integration / extension contract is slated to define. Update 2026-06-07 (change provider-extension-contract): the contract now defines set_readonly as a lifecycle verb, so the contract is no longer the blocker. But the FarmManager client exposes create / list / describe (GetSite) / soft-delete (deactivate) / hard-delete — no true read-only mode and no per-site usage metrics. Per the contract's orphan-operation conformance check, FedWiki declares create/delete/list/describe and does NOT declare set_readonly. So this blocker has shifted: read-only downgrade now waits on FarmManager gaining a read-only API upstream, after which FedWiki adds a set_readonly activity and declares the verb. The member-console side (intent, outbox event, projection) is unblocked by the contract. A capability wishlist (read-only, reversible suspend, storage metrics, change-notification webhooks) was drafted for the FarmManager maintainers at docs/fedwiki-farmmanager-capability-wishlist.md.

Scope when unblocked (against M9): a force_reduce/selection variant of the downgrade — member picks the retained site, the rest get the provider read-only operation. Until then, 8c ships clamp (keep-but-cannot-create) + a member advisory. Split out 2026-05-31 during the 8c scoping pass.

Operator UX: no clear path to making a product "available for purchase" (price + Stripe mapping unguided)

Labels: enhancement, frontend, ux, billing, operator

Resolved (2026-05-31) by change operator-purchasability-readiness. The operator product edit page now shows a Purchasability panel evaluating each precondition — published, public+active, structural kind, active price, Stripe-mapped (synced vs sync-pending) — and renders a single ✓ Purchasable / ⚠ Incomplete verdict naming the missing steps, each with inline remediation. The verdict is computed from computePriceReadiness, the same gate the member catalog's resolvePurchasable uses, so operator and member can never disagree. The create→sell path is documented in docs/plan-management.md. Covered by a view-model unit test over all five precondition states (internal/server/product_readiness_test.go) and a template render test (internal/server/operator_product_readiness_render_test.go). Verified live 2026-05-31 through the operator UI on FedWiki Standard (unpriced): edit page shows ⚠ Incomplete with Active price + Stripe-mapped price as the missing preconditions, and the member catalog shows the matching disabled "Upgrade — Not available for purchase yet" (operator/member agreement via the shared gate). Adding a $16/mo price live moved the Active-price precondition to Met (DB-confirmed) and narrowed the missing-list to the Stripe mapping; the Stripe-mapped row stayed Missing because the product itself is not synced to Stripe (outbox create_stripe_price → "lookup product mapping … no rows"), so the fully-synced ✓ Purchasable flip is test-covered (product_readiness_test.go, operator_product_readiness_render_test.go) rather than eyeballed live.

An operator can create a plan product, attach an entitlement set, put it on a ladder, and publish it — and it still renders in the member catalog disabled ("Not available for purchase yet") with no in-panel explanation of what is missing. Discovered 2026-05-31 while setting up FedWiki Public/Standard for the 8c downgrade verification: both products were is_public + is_active + published and on a ladder (so product_kind='plan'), yet not purchasable.

Two things gate purchasability, neither surfaced in the operator UI:

  1. an active billing.prices row on the product (resolvePurchasable, internal/server/member_products.go:677); and
  2. a Stripe price mapping (stripe.price_mappings → a real price_…), checked via GetPriceMappingByPriceID (internal/server/billing.go:66). Without it the buy control is disabled and POST /billing/checkout returns 400 "price not yet available in Stripe."

There is no operator-facing readiness indicator showing where a product sits on the path: draft → entitlement rules added → on a ladder → priced → Stripe-synced → purchasable. The Stripe-mapping step is especially invisible (the "Product retirement and Stripe-mapping visibility" issue asks to surface the mapping for retire; this is the create/sell-side counterpart). Proposed: a per-product "purchasability" status panel listing each precondition and its state, with inline guidance ("add a price", "sync to Stripe"), plus a short operator-docs section on the create→sell flow. Discovered 2026-05-31.

Older resolved / superseded

Domain ambiguity: must a plan be on a ladder to be shown/purchasable? (NULL-type off-ladder products are an invisible limbo)

Labels: design-feedback, frontend, billing, ux

Resolved (2026-05-31) by change operator-purchasability-readiness. The rule is now documented (docs/plan-management.md: a plan is a ladder tier; a single-tier ladder is the idiom for a standalone plan; off-ladder NULL-type products are not plans by construction, per Doc 31 Amendment #3 / Decision 121), and the readiness panel surfaces the invisible-limbo case on the product edit page itself (published + NULL product_type + on no ladder) with remediation. Ask (3) — whether off-ladder plans should ever be sellable — is settled by the data model: a product's plan-status is its ladder membership, so an "off-ladder plan" cannot exist; single-tier ladders are the idiom.

It is neither documented nor surfaced that a plan must be a tier on a ladder to appear in the member catalog or be purchasable — and that a product_type IS NULL product not on any ladder is an invisible limbo. Grounded in code (2026-05-31):

  • buildPlansData only iterates ladders and renders products found as tiers (internal/server/member_products.go:362-402); ListPublicPlanProducts requires EXISTS (… plan_ladder_tiers …) (internal/billing/queries/products.sql:19-28). A plan not on any ladder is never returned → shows nowhere, cannot be bought.
  • billing.product_kinds derives kind='plan' only from tier membership; a product_type=NULL product with no tier row resolves to kind = NULL (internal/billing/migrations/00013_products_kind_lifecycle.sql). The add-on path (GetAddons, member_products.go:657) filters to product_type=='addon', so a NULL-type off-ladder product is skipped there too — invisible in both catalog paths, unpurchasable.
  • By contrast, non-plan products with product_type ∈ {addon, usage, one_time} ARE purchasable off-ladder via the separate add-on path.

So the effective rule is: plans MUST be on a ladder to be visible/sellable; a NULL-type product off all ladders is dead weight. This is load-bearing but implicit, and operators get no warning when they create a NULL-type product and leave it off-ladder (it simply never appears). Asks: (1) document the rule (a plan is a ladder tier; to sell a plan, put it on a ladder — single-tier ladders are the idiom for a standalone plan); (2) operator-side validation/warning when a NULL-type product is on no ladder — the structural-validation health strip already flags "orphan plan products," so confirm it catches this case and surface it on the product edit page; (3) decide explicitly whether an off-ladder plan should ever be sellable, or whether single-tier ladders are mandatory. Discovered 2026-05-31 during 8c verification setup. Related: "Operator plans surfaces — consolidate…" and the member-ladder-aware-catalog model.

Keycloak seed ID pinning assumption is fragile (RESOLVED — see OpenSpec change 2026-05-11-keycloak-id-pinning-fix)

Labels: bug, fedwiki, testing, resolved

Resolved: Keycloak 26.x silently drops the id field on POST /admin/realms/{realm}/users. The fix swaps user creation in seed-keycloak.sh to use POST /admin/realms/{realm}/partialImport, which preserves the pinned id. Verified empirically: a partialImport with id=f0000099-... round-trips through GET /users?username=... with an exact id match. See OpenSpec change 2026-05-11-keycloak-id-pinning-fix for the rewrite and rationale.

Historical context (kept for reference): test/seed/keycloak/seed-keycloak.sh pinned user IDs (e.g. a0000001-... for Alice) so that FedWiki seed data (owner.json) could reference them deterministically. Keycloak did not honor the requested IDs on POST /users — proof at the time: gnu.localtest.me/status/owner.json had "id": "e0249a3c-..." instead of the pinned a0000001-....

Downstream consumers that benefit from the fix:

  • FedWiki owner.json references (original report).
  • Member-console demo seeder (OpenSpec change 2026-05-10-member-console-demo-seeder) — its current "do not seed person rows" workaround can be revisited in a follow-up change to re-add seedPersons with deterministic UUIDs.

Operator panel tabs are not HTMX-idiomatic: stale state and lost position on reload ✓ Resolved

Labels: bug, frontend, ux Each tab pane in the operator panel uses hx-trigger="revealed" to load its partial once on first reveal. After that, content is cached in the DOM and never refreshed — so a change made in one tab (e.g. creating a new entitlement set) is invisible in another tab (e.g. the Products form's entitlement-set dropdown) until the whole page is reloaded. Worse, a page reload always resets to the first tab (Bootstrap JS default), losing the operator's position.

Root causes:

  1. hx-trigger="revealed" fires only on first reveal — no cross-tab invalidation mechanism exists.
  2. Tab state lives entirely in Bootstrap JS memory, not in the URL, so it cannot survive a reload.

HTMX-idiomatic fixes to consider:

  • URL-based tab state: add hx-push-url (or hx-replace-url) on each tab button so the active tab is reflected in the URL fragment or a query param; on load, scroll/activate the matching tab.
  • Cross-tab refresh via HTMX events: change trigger to revealed, <custom-htmx-event> so that a mutation in one tab can fire a named event (htmx:trigger) that causes dependent tabs to re-fetch their partial.
  • Polling or out-of-band swap alternative: for low-frequency mutations, an OOB swap (hx-swap-oob) from the mutating partial can push updated data into sibling containers without a full tab reload.

Plan concept needs depth evaluation before M6 ✓ Resolved

Labels: design-feedback, billing product_type = 'plan' was a label with no behavioral depth — the system treated plans identically to other product types. M5 phase 5c intentionally deferred first-class plan treatment (one-active-plan constraint, upgrade/downgrade rules, plan comparison logic). Discovered during M5 phase 5c exploration (2026-04-11).

Resolved by design commit 732197a (latest design import): product_type='plan' is now structurally enforced via plan_ladders, plan_ladder_tiers, and the product_type='plan' ↔ plan_ladder_id IS NOT NULL CHECK; mutual exclusion per (pool, ladder) is enforced by the GiST exclusion constraint on entitlements.pool_provision_ladders; upgrade/downgrade semantics are expressed through rank. Remaining work (transition primitive, dormant status, transition audit, operator UI) is scheduled as the new Milestone 6 "Plan Management Foundation" — see status/milestones.md. Follow-up design feedback items: "Dormant provision status for supersession" and "provision_transitions table for plan audit history" below.

pool_provisions.status needs a dormant value for superseded provisions ✓ Superseded

Labels: design-feedback, entitlements, billing The ladder-based mutual-exclusion model in the latest design commit (732197a) enforces at most one active provision per (pool, ladder) via a GiST exclusion constraint, but does not specify what happens to a pre-existing provision when a higher-ranked provision activates. Original proposal was to add a dormant status on pool_provisions so a superseded provision could reversibly sleep and wake up when the superseder ends.

The design team's review (2026-04-18) agreed with the placement argument but refined in two ways: (1) dormancy is not universal — a superseded trial subscription should end rather than sleep, while a superseded baseline grant should dormant; (2) the GiST exclusion predicate must be audited to confirm dormant is treated as vacant. Accommodating (1) required a new pool_provisions.supersession_behavior column plus per-source policy encoded in the transition primitive.

On reconsideration (2026-04-18), this approach was abandoned because the accreting complexity — new status value, new column, per-source policy, GiST predicate audit, canonicalization rules against subscription_changes — exceeded the value over a simpler alternative. Superseded by the "end-and-re-apply on reversal" approach below.

Supersession via end-and-re-apply (no dormant status needed)

Labels: design-feedback, entitlements, billing After reviewing the per-source-dormancy complexity that the design team's feedback surfaced, the member-console team concluded that a simpler mechanism without a new provision status is preferable.

Approach:

  • On supersession (upgrade), the existing provision and its ladder row transition to status='ended'. No new statuses. A new subscription-backed (or higher-ranked) provision and ladder row are created alongside.
  • On reversal (downgrade), the top provision and its ladder row end. The transition primitive then invokes a new pool-scoped ReapplyDefaultsForPool(ctx, tx, pool_id) operation that reuses the M5a-era auto-provisioning logic: look up the owning org's org_types.default_product_id, call entitlements.CreateGrantInTx(...) with grant_reason='default' to issue a fresh grant + provision + ladder attachment.

Why this is simpler than the dormant approach:

  • No new pool_provisions.status value and no new columns.
  • No per-source policy table (trial-vs-baseline distinction disappears because everything ends on supersession; reversal re-applies the policy from org_types rather than reversing a stored state).
  • No GiST predicate audit burden — ended is already excluded from the constraint whatever the predicate shape.
  • No plan-stacking semantics pressure — materialization sees at most one active provision per ladder per pool, which is exactly what the GiST constraint already enforces.
  • Reuses existing entitlements.CreateGrantInTx (already extracted from M5a for in-transaction use).

Required member-console work (scheduled as M6b):

  • A new ReapplyDefaultsForPool(ctx, tx, pool_id) primitive wrapping CreateGrantInTx. Looks up org via pool → org_id, reads org_types.default_product_id, issues the grant. ~20 lines around existing logic.
  • The transition primitive delegates to this on any downgrade that leaves the ladder empty.
  • Audit row in pool_provision_transitions records the transition; see enumeration below for the transition_type selection rules.

ReapplyDefaultsForPool contract (explicit):

  • If org_types.default_product_id IS NULL: the primitive records a single pool_provision_transitions row with transition_type='end', to_rank=NULL, and returns. No grant, provision, or ladder row is created. The pool is left legitimately off-ladder. The downgrade is still observable in audit; the re-application is a no-op by policy, not a silent swallow.
  • If default_product_id is set: call entitlements.CreateGrantInTx(...) with that product, grant_reason='default'. Record a pool_provision_transitions row with transition_type='downgrade' (or 'initiate' if the pool had no prior rank) from the superseding rank to the re-applied rank.
  • Caller (the transition primitive) records the end-of-superseding-provision transition separately; ReapplyDefaultsForPool only records the re-application event itself.

Audit semantics:

  • A pool that cycles Public → Standard → Public over time accumulates both ended provisions and fresh grant rows. Each grant row carries its own valid_from, granted_by_person_id (NULL for system), grant_reason, and quantity — they are the per-issuance audit record and intentionally distinct from their antecedents.
  • "What tier was this pool on at time T?" remains reconstructible by joining pool_provisions with pool_provision_ladders on the active window containing T; "who and why at time T?" by joining the chronological pool_provision_transitions record.

Trade-offs — accepted deliberately:

  • Grant-row accumulation. CreateGrantInTx unconditionally creates a new grant row (see internal/entitlements/grants.go:91). An org cycling Public ↔ Standard five times produces five Public-tier default grant rows in addition to the subscription-derived grants. We considered reusing an earlier matching grant (looking up (org_id, product_id, grant_reason='default') and creating only a new provision + ladder row against it) and rejected it for three reasons: (1) CreateGrantInTx is the single call-site for all grant creation — subscription renewals flow through the same path and also create fresh rows, so special-casing defaults would fragment the code path; (2) each grant is a discrete issuance event with its own metadata, which is the audit shape we want, not a bug to optimize away; (3) operators observe accumulation naturally via the M6e enrollment/transition audit UI and can reason about cycle counts from it. Accepting accumulation is the deliberate choice, not a fallout of call-site convenience.
  • Provision-row accumulation. Likewise, a new provision row per downgrade cycle (vs. the single reversible row under dormant). For an org that upgrades/downgrades five times, five extra ended provisions. Same audit-value argument as for grants.
  • Re-applying reads current org_types.default_product_id, not the original grant's product, which means an org that held a custom default grant would be re-applied from the org-type default on downgrade rather than from the original custom grant. Noted explicitly; custom per-org defaults are not currently a supported concept.

Conceptual model reinforced:

  • Grants are durable catalog entries ("this org is entitled to this product under this policy"); provisions are current materializations of those catalog entries. When conditions change, re-materialize from the catalog.
  • No new conceptual primitive is introduced; the system stays within the language it already speaks.

Reconsidered on 2026-04-18 after design-team review surfaced the accreting complexity of per-source dormancy.

New entitlements.pool_provision_transitions table for plan transition audit history

Labels: design-feedback, entitlements The GLOSSARY update in design commit 732197a frames billing.subscription_changes as "the general audit log for all subscription lifecycle events; not specific to plan transitions," leaving plan-level transitions (grant lifecycle events, grant-to-subscription supersession, repeated tier changes over an org's lifetime) without a dedicated audit record.

Scenarios the existing tables cannot fully reconstruct:

  • An org cycles Public → Standard → Public → Standard over time. pool_provisions.{activated_at, ended_at} and pool_provision_ladders.{activated_at, ended_at} record per-row activation windows, but reconstructing the chronological enrollment trajectory (with actor attribution and reasons) requires an event log.
  • A trial grant at rank 1 issued by an operator, later superseded by a paid subscription, is a pure entitlements-lifecycle event and leaves no subscription_changes row.
  • Operator audit UI ("why is Alice's org on Standard right now?") needs a chronological, actor-attributed record that spans grants, subscriptions, and purchases uniformly.

Proposed: add entitlements.pool_provision_transitions (renamed from provision_transitions per the design team's prefix-convention note to match pool_provision_ladders):

CREATE TABLE entitlements.pool_provision_transitions (
  transition_id    UUID PRIMARY KEY,
  pool_id          UUID NOT NULL REFERENCES entitlements.resource_pools(pool_id),
  provision_id     UUID NOT NULL REFERENCES entitlements.pool_provisions(provision_id),
  plan_ladder_id   UUID REFERENCES billing.plan_ladders(plan_ladder_id),
  from_rank        INTEGER,
  to_rank          INTEGER,
  transition_type  VARCHAR(50) NOT NULL,
  actor_type       VARCHAR(50) NOT NULL,
  actor_id         UUID,
  reason           TEXT,
  effective_at     TIMESTAMPTZ NOT NULL,
  created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
  CHECK (actor_type != 'operator' OR actor_id IS NOT NULL)
);

Field semantics:

  • plan_ladder_id NULL for off-ladder transitions (e.g., add-on grant lifecycle).
  • from_rank NULL for initial attach; to_rank NULL for detach/end.
  • transition_type is strictly scoped to ladder-position changes: {initiate, upgrade, downgrade, end}. Suspension/resumption lifecycle stays with subscription_changes plus pool_provisions.status; it is intentionally not recorded here.
  • actor_type ∈ {operator, system, webhook}.
  • actor_id is required when actor_type = 'operator' (enforced by CHECK); NULL-allowed for system and webhook actors today, with room to populate it if webhook authentication adds actor attribution later.

Canonicalization rule (to be added to GLOSSARY alongside the existing subscription_changes entry):

  • pool_provision_transitions is canonical for plan-position history of a pool: what tier a pool held, when, who changed it, why.
  • subscription_changes is canonical for commercial mutations of a subscription: status transitions (trialing, past_due, canceled), period boundaries, amount changes.
  • Subscription-driven ladder attachments are recorded in both tables — intentionally — because they answer different questions. An operator audit UI should query pool_provision_transitions for enrollment history and subscription_changes for billing lifecycle; neither is a substitute for the other.

Audit module framing:

  • This table is designed as a view-shaped projection of a future generic audit.log. Its columns map directly to a standard audit shape: {resource_type='pool_provision', resource_id=provision_id, actor_type, actor_id, action=transition_type, occurred_at=effective_at, recorded_at=created_at, payload={pool_id, plan_ladder_id, from_rank, to_rank, reason}}.
  • Consequence: when the generic audit module graduates from the backlog, absorption is mechanical — a UNION ALL view across pool_provision_transitions (and any peer specialized logs) yields the generic log without semantic rewriting. This table can also be deprecated into a view over audit.log at that point if desired.

Scope and boundaries:

  • Lives in entitlements because transitions are a pool/provision-level concern; the cross-module reference to billing.plan_ladders follows the same direction already established by products.entitlement_set_id and pool_provisions.subscription_id.
  • Complements, does not replace, billing.subscription_changes.

Alternatives considered and rejected:

  • Deriving transition history from timestamps on pool_provisions and pool_provision_ladders: works when provisions are long-lived single rows, but does not capture actor attribution or reason, and quickly loses readability when a pool accumulates multiple ended provisions across upgrade/downgrade cycles.
  • Extending billing.subscription_changes to be a generic provision_changes table: overloads an existing stable table, conflicts with its scope-statement in the GLOSSARY, and pulls subscription-scoped schema into the entitlements module's primary-key graph.

Discovered during M6 planning exploration (2026-04-18); refined after design-team review (2026-04-18) to add the actor_type='operator' ⇒ actor_id IS NOT NULL CHECK, the prefix-convention rename, the canonicalization rule for GLOSSARY, the explicit ladder-position-only scope for transition_type, and the view-shaped contract for the future audit module. The simplified transition_type enum (initiate | upgrade | downgrade | end) also reflects the supersession approach change above (end-and-re-apply instead of dormant), which removed the need for supersede and reactivate as distinct types.

Auto-provisioning does not backfill existing orgs when default_product_id changes

Labels: design-feedback, entitlements, organization Auto-provisioning today runs only on first login during org creation (see internal/provisioning/provisioning.go:163-183). When an operator configures or changes org_types.default_product_id after deployment, pre-existing orgs of that type are unaffected — their pools retain whatever grants (or lack thereof) they had at creation time.

This is acceptable in the narrow M5a scope (new deployments configure default product once before users arrive) but becomes a concrete gap in M6:

  • Supersession via end-and-re-apply (see entry above) assumes org_types.default_product_id is always populated when a pool downgrades off the ladder. If the operator changes the default mid-deployment, existing orgs that downgrade later get the new default, but orgs that never upgraded never receive any grant adjustment.
  • Operator UI in M6 needs to communicate this reality and offer an explicit remediation path.

Proposed member-console-level additions (M6d, operator auto-provisioning config UI):

  • When an operator changes org_types.default_product_id, show a warning: "This applies to newly created orgs only. Existing orgs of this type are unchanged. Click 'Backfill existing orgs' to apply the new default retroactively."
  • A "Backfill existing orgs" operator action: for each existing org of the type whose default pool does not currently have an active grant-sourced provision on the ladder, invoke the ReapplyDefaultsForPool(ctx, tx, pool_id) primitive. The operation is idempotent with respect to pools already holding an active baseline provision.
  • Backfill is recorded in pool_provision_transitions with actor_type='operator', transition_type='initiate', and a reason indicating the retroactive application.

Design-layer implication: no schema change is required to support backfill — the operation is a loop over existing pools invoking a primitive that M6b adds. However, the design should explicitly acknowledge that auto-provisioning policy changes are not automatically retroactive, and that the retroactive pathway is an operator-driven action rather than a system trigger.

Discovered during M6 planning exploration (2026-04-18).

core.webhook_events ON CONFLICT dedupe only collapses same-instant duplicates — Stripe handler affected

Labels: bug, billing, webhooks

The table's unique constraint is (provider, provider_event_id, received_at) (received_at in the target because the table is partitioned by it), so the ON CONFLICT ... DO NOTHING insert in internal/integrations/stripe/web/webhook.go did not dedupe a provider redelivery arriving at a different instant: it inserted a second row and reprocessed. Found 2026-07-17 by the Discourse duplicate-delivery test (discourse-integration findings #6).

Resolved 2026-07-20: Stripe handler switched to the Discourse handler's INSERT ... WHERE NOT EXISTS on (provider, provider_event_id); guarded by a DB-backed cross-time duplicate-delivery test (internal/integrations/stripe/web/webhook_db_test.go, red-verified against the old code — it recorded 2 rows).

/operator/fedwiki-sites 500s — operator-shell template set missing the lookup partial

Labels: bug, fedwiki, operator-ux

Verified live 2026-07-17: every render of the FedWiki operator page failed with no such template "operator_lookup_result.html" (HTTP 500). The operator shell gained a literal include of the lookup partial in audit remediation 8514df5 (07-03); html/template's escape analysis requires referenced templates even on untaken branches; the FedWiki operator handler created by integration-extraction (07-06) parsed only operator.html + its own body partial. Found during discourse-integration (findings #8).

Resolved 2026-07-20: internal/integrations/fedwiki/web/operator.go parses partials/operator_lookup_result.html alongside the shell (the Discourse handler's pattern); render regression guarded by internal/integrations/fedwiki/web/operator_render_test.go (red-verified — reproduces the exact no such template 500 when the partial is dropped). The building-an-integration guide §4 already documents the shell-partial dependency rule.

FedWiki-only integration assumption pervades UI and data patterns

Labels: design-feedback, architecture

Many UI templates, handler names, and entitlement display logic assumed FedWiki was the only integration; resource keys were rendered without integration context; the member dashboard hardcoded a single "FedWiki Sites" card. Discovered during M5 phase 5c exploration (2026-04-11). The handler/template extraction half landed 2026-07-05 with integration-extraction; the remaining scope was promoted 2026-07-06 to milestone phase 9f.

Resolved 2026-07-21 (dashboard-display-genericity, M9 9f): the member dashboard is registry-driven — integrations declare cards via server.DashboardCardProvider and core's index.html renders generic shells only (guard test asserts the template names no integration); FedWiki's card, create/delete modals (hx-preserve inside the sites partial), and script moved into its tree; Discourse gained a member forum card as the second, shape-different proof; entitlement rows lead with core.resource_keys.display_name + provider attribution with raw-key fallback, and granted boolean entitlements (previously invisible to members) render as "Included" rows. Verified by render/DB tests plus a live browser walkthrough (create-site flow through the partial-delivered modal, both cards, labels). Residual: catalog feature summaries still render raw keys — filed as its own small issue in issues.md.

Operator entitlement-set rules: no UI to add boolean rules

Labels: bug, frontend partials/operator/entitlement-sets/{id}/rules exposes only the "Add Limit Rule" form (resource key + value + stacking + per-unit). The schema and seeded data include boolean rules (demo.feature-x), but there's no form path to create one. Discovered in Phase A v2. Candidate for M7-7b/7c.

Resolved 2026-07-21 (entitlement-rule-authoring): the form is now key-first and kind-adaptive — core.resource_keys gained kind ∈ {boolean, numeric} (core migration 00008, discourse stream 00002 corrects discourse_posting), and CreateEntitlementSetRule derives rule_type from the selected key's kind server-side, so boolean rules are authorable from the UI and wrong-shape rules (a limit rule on a boolean-consumed key — previously a silent misconfiguration) are unreachable. Boolean creation writes the explicit NULLs chk_entitlement_set_rules_type requires. The fields region swaps via HTMX on key selection (no rule-type selector, per maintainer direction); "Per Unit" was relabeled "Multiply by purchased quantity" with help text. Guarded by render + DB handler tests (internal/server/operator_entitlement_set_rules_test.go). The companion design-feedback entry ("Resource keys carry no shape discriminator") stays open in issues.md for the upstream model ask.

Discourse group converge: one dead username poisons the whole batch add

Labels: bug, integration, discourse

convergeGroup (internal/integrations/discourse/workflows/activities.go) sent every missing desired member in a single AddGroupMembers call. A discourse.user_links row whose forum user no longer resolves (deleted account, or test residue) starved delivery: live 3.5.3 silently drops unresolvable names from a partly-valid batch (the member looks delivered but isn't), and rejects an all-unresolvable batch with 400 "You supplied invalid parameters to the request: usernames" — failing the whole converge on every sweep, so "staleness bounded by one interval" silently became "never". Hit live 2026-07-22: a leftover rule-authoring live-test link (carlos_forum, fake forum id 424242) entered the desired set when its org's discourse_posting re-materialized; every sweep from then on failed all 5 attempts, blocking a genuinely-entitled member (alice) from group delivery.

Resolved 2026-07-22: members still absent after the batch attempt (verified by re-list — the batch response names nothing per-member) are diagnosed individually against the forum's id-keyed admin record: renamed users heal the stored discourse_username and retry; users the forum no longer knows are quarantined as status = 'orphaned' (discourse migration 00003 widens the CHECK; operator-visible like conflict, excluded from the desired set), guarded by a VerifyKey re-probe so a dead admin key's 404s can't orphan healthy links. The observed projection now records only actually-delivered members. The fake's 400-on-any-unknown add was itself a fixture-vs-live divergence and now mirrors 3.5.3 (silent drop; 400 only when nothing resolves). Red-green: TestConvergeQuarantinesDeadLinkAndDeliversRest (old code: ghost recorded as delivered, then permanent converge failure) and TestConvergeHealsRenamedUsername in internal/integrations/discourse/workflows/reconcile_test.go; live-replayed by re-inserting the carlos link and watching the 1-minute sweep quarantine it.

Grant-extend walkthrough fails on a long-lived stack DB (toast never shows; server returns 200)

Labels: test-infra TestGrantExtendWalkthrough phase 2 times out waiting for #successToast.show on a stack whose DB has accumulated many prior walkthrough runs, even though the server log shows the extend POST returning 200 (phase 1's 422 also behaves). Bisected 2026-07-23: fails identically against a pre-change binary, so it is stack-state drift, not a code regression.

Resolved 2026-07-23: root cause found by reading, not looping — the extend card rendered on HasAttachment alone, but ExtendGrant requires an active grant-backed provision (GetActivePoolProvisionsByPoolIDGrantID.Valid); a pool whose delivery is subscription-backed or ended (e.g. orgs left by purchase-loop testing sorting first in the listing) showed a form whose submit could only ever re-render with the "no active grant-backed delivery" banner — a 200 with no toast, exactly the observed symptom. Two-sided fix: (1) honesty — PoolEnrollmentViewModel.HasGrantDelivery gates the extend form, with a precondition note in its place, so form presence is a truthful signal the action can succeed (guarded by TestOrgEnrollmentExtendForm_RequiresGrantDelivery); (2) robustness — the walkthrough's new gotoExtendableOrgComposite helper scans the org listing for a composite that renders the form instead of trusting listing order, and skips (naming seed-demo.sh) when no org qualifies. Verified on the live stack: extend and revoke walkthroughs pass, render tests green. Fresh-stack hygiene codified as make stack-fresh + a "Browser walkthroughs (e2e) and stack age" section in test/AGENTS.md (true per-run DB isolation stays blocked by cluster-global Postgres roles).

Discourse operator walkthrough lacks a forum-reachability skip guard

Labels: test-infra TestDiscourseMappingWalkthrough (test/e2e/operator-walkthroughs) hard-fails when no Discourse backend is reachable (default stack runs neither fake-discourse nor the opt-in discourse profile): the unknown-group validation needs the forum API, gets connection-refused, and the 422 assertion times out on an empty swap. Confirmed pre-existing 2026-07-22 (fails identically on pre-remediation code).

Resolved 2026-07-23: skipUnlessForumReachable (discourse_test.go) reads the forum base URL the app is actually configured with — the effective discourse-base-url on the integration settings page (dogfooding the 9g surface) — and probes it over HTTP, skipping on connection failure or a ≥500 answer (a proxy fronting a down backend, or Discourse still booting). Scraping the settings page rather than hardcoding test/.env's DISCOURSE_PORT keeps the guard truthful for every bootstrap: compose profile, fakeserver, or a runtime override. Validated live in all three modes: unconfigured app → pre-existing "not configured" skip; configured against the down compose port → new skip naming the URL and remedy; configured against the fakeserver → probe passes through and all four phases pass.

Unconfiguring Discourse leaves its durable sweep schedule firing forever

Labels: operations, integration, discourse Temporal schedules are durable, but only a Discourse-configured boot ensured/updated discourse-group-sync. Boot the app once with Discourse configured, then restart it unconfigured, and the leftover schedule kept firing DiscourseGroupSyncWorkflow every interval into a worker whose client has no base URL — VerifyAPIKeyActivity failed 5 attempts with unsupported protocol scheme "", every minute at the test cadence, indefinitely. Observed live 2026-07-23 while validating the walkthrough forum probe. Generic shape: any integration that ensures a schedule only when configured (FedWiki's fedwiki-site-sync included).

Resolved 2026-07-23: internal/workflows/schedule_dormancy.goPauseLeftoverSchedule (called by an unconfigured/disabled Startup) pauses an existing running schedule with a frozen dormancy note; ResumeDormantSchedule (called after EnsureSyncSchedule on a configured boot) unpauses only a pause carrying that exact note, so an operator's deliberate pause — any other note — survives restarts and is logged for diagnosability. Wired into both Discourse and FedWiki Startup hooks; NotFound-only absence classification matches the hardened schedule managers. Decision logic is pinned as pure functions (schedule_dormancy_test.go) since the Schedule API has no testsuite fake; the client path was validated live through four boot cycles: configured→created/running, unconfigured→paused+note, configured→resumed, operator-paused+configured→left paused with a log line.

Custom-domains entitlement gate lives in fedwiki's web layer, not core

Labels: debt, architecture The external_domain_claims plan gate — a core rule over a core resource, seeded by the core domains migration stream — was enforced only in per-surface web code (fedwiki's web/customdomain.go and member_domains.go, with duplicated helpers and resource-key constants), so any second consumer of domains — or extraction of the domains system — would have shipped ungated. Filed 2026-07-26 during the domains IA de-centering work.

Resolved 2026-07-26 (centralize-external-claim-gate, same day): enforcement moved into Registry.ClaimExternal via an injected domains.ExternalClaimGate (pre-lock, so neither of the original objections — entitlement-table reads in the domains package, a second lock order — applies). One constructor (internal/server.NewExternalClaimGate) feeds both member-facing registries and both surfaces' affordance checks; the duplicated helpers and constants are gone.

Operator domains: force-release renders for placed claims the server will refuse

Labels: bug, frontend, testing The force-release button rendered for every live non-root claim, but the registry's placement guard refused placed ones server-side with no success toast — a dead-end affordance — and TestOperatorDomainsWalkthrough timed out on the first placed claim the stack ever held. Not caused by, only exposed during, the gate-centralization work (2026-07-26).

Resolved 2026-07-26 (same day): Releasable now also requires zero placements, so the button renders exactly where the registry's guards would admit it; placed non-root claims read "in use" (freed by removing their sites, not by moderation) while operator roots keep their label even when placed (they always are). DB-free render coverage in operator_domains_render_test.go; the walkthrough pins the no-button-on-in-use invariant against live state before moderating, and its skip message names the real precondition. Live-verified end-to-end (force-release → toast → claim leaves the list).

FedWiki demotion residue — remaining parity gaps vs Discourse (post-M9)

Labels: integration, tech-debt, tracking

M9 already did the registry-level demotion (9c/9d: FedWiki was the first integration migrated onto the 9a contract; operator surface at /operator/integrations/fedwiki, registry-declared dashboard card, per-stream migrations — the archives record it as complete, and the dispatch-transport asymmetry is a decided coexistence, "capability, not transport"). A 6-agent parity audit (2026-07-31) confirmed the render/mount/registry seams are symmetric with Discourse and found the remaining privilege concentrated in two places, neither previously tracked: an app-layer half (config keys hand-declared in core, a fedwiki-named custom-domain key) and a test-stack half (the 4-service fedwiki+caddy chain running unconditionally while Discourse was profile-gated, MC_FEDWIKI_FARM_API_URL always-on, mc-config.yaml shipping the farm enabled, seed-stack.sh hard-failing without the container, teardown/isolation scripts hardcoding paths, and the integration-settings walkthrough assuming a reachable farm).

Decided (2026-08-01): BOTH integrations profile-gated symmetrically, with demo-ness decoupled from presence-assumptions — whether bootstrap's default COMPOSE_PROFILES includes either integration is a separate composition knob, and no script, seed, teardown path, or walkthrough may assume a service exists.

Resolved 2026-08-01 in two slices.

Slice A — app layer (integration-config-parity): the core domains-connect-target key replaced fedwiki-custom-domain-target outright (resolved once in the composition root and threaded via server.Config/WorkerConfig/server.Deps; no fallback alias — pre-production clean break, exactly the supersession domains-registry D4 pre-designed); ConfigSpec now carries bool/duration keys (type rides the Default value) and registerIntegrationConfigFlags registers them typed — the four fedwiki sync knobs moved into fedwiki's ConfigSpec, discourse's two previously undeclared knobs are declared with behavior-preserving defaults, and no integration key remains hand-declared in core; the init scaffold generates one commented section per declaring integration from its ConfigSpec; the Temporal-not-configured warn is integration-agnostic. Disposition for the fedwiki_sites seed in core migration 00002: accepted historical artifact — shipped migrations are immutable and the row backs live entitlement rules; integrations seed their keys in their own streams going forward (Discourse pattern).

Slice B — test stack (test-stack-integration-profiles): the fedwiki chain and Caddy carry profiles: [fedwiki], so the default docker compose up -d composes core services only and nothing binds host 443. COMPOSE_PROFILES in the generated test/.env is the composition knob (Compose reads it natively, so up/ps/down and the seed/teardown scripts all resolve one composition); bootstrap honours a generation-time override, reports the active profiles, and now probes FedWiki's 8090 base port alongside Discourse's 9292. MC_FEDWIKI_* joined MC_DISCOURSE_* as a commented-out opt-in block and mc-config.yaml keeps only the inert sync-cadence knobs, so the app boots farm-less by default (farmAPIURL="" hasToken=false, zero errors). Presence guards: seed-stack.sh skips its FedWiki steps via docker compose ps -q, and skipUnlessIntegrationEndpointReachable (helpers_test.go) now backs both skipUnlessForumReachable and a new skipUnlessFarmReachable on the integration-settings walkthrough — verified live in both branches (unconfigured farm → phases run; configured-but-absent farm → skip naming the URL and the remedy). Two inverse gaps closed at the same time: teardown's root-owned-testdata fallback stopped enumerating per-service paths in favour of a generic chown -R + git clean -fdX (the old list missed testdata/caddy and testdata/discourse/postgres), and verify-stack-isolation.sh gained Discourse client assertions. Also repaired in passing: seed-stack.sh never sourced test/.env, so it had been unusable against any slot-allocated stack, and it invoked the container-native render.sh directly instead of through docker compose run --rm fedwiki-render. make test is green with no integration profiles composed. The caddy-443 cross-stack singleton stays open on its own entry, with a note that the default composition no longer contends for the port.