Files
member-console/status/archive/issues-resolved.yaml
T
cgalo5758 0c78d5f425 Move issue tracking to the forge tracker
Replace status/issues.md and status/milestones.md with the Gitea forge
tracker, add issue templates, and archive pre-forge issues as YAML.
2026-09-21 02:35:27 -05:00

1497 lines
190 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Issues resolved before the tracker moved to the forge on 2026-09-21.
# Frozen history: nothing is appended here. From that date on, the closed
# issues on the forge are the archive. One record per entry of the former
# status/archive/issues-resolved.md, in the order the entries appeared there.
#
# Fields, every record, in this order:
# title the entry's heading, without strike-through, RESOLVED suffixes or resolution notes
# kind what closed it, one of the kind/ names in status/labels.yaml: bug, enhancement, debt, design, docs
# areas where the fix landed, one or more of the area/ names in status/labels.yaml
# outcome fixed, superseded, wontfix, duplicate or moved-upstream
# resolved YYYY-MM-DD, or YYYY-MM when only the month is known
# by what resolved it: a change name, a commit, a decision, a milestone phase or "upstream"; empty when unknown
# summary one or two sentences stating the resolution
# body the original entry text verbatim, minus the heading, the Labels line and the resolution marker the summary carries
#
# The schema is enforced by TestResolvedIssuesArchiveSchema in internal/lint/archive_test.go.
- title: "`SECURITY.md` publishes a placeholder disclosure address"
kind: docs
areas: [meta]
outcome: fixed
resolved: "2026-09-21"
by: ""
summary: "The reporting section names the deployment's real mailbox and the placeholder note and its TODO are gone."
body: |
`SECURITY.md` named a preferred private channel and marked it a placeholder in the same line, under an open TODO comment. Nobody had confirmed that the address existed or who read it, so the repository shipped no working private channel and a reporter's first message could go nowhere. Found in the README front-door review, where three outside reviews flagged it independently.
- title: "Session/CSRF secret generation and rotation strategy"
kind: enhancement
areas: [identity, ops]
outcome: superseded
resolved: "2026-09-08"
by: "csrf-standard-library"
summary: "CSRF protection moved to the standard library's origin check, which removed the token and its secret entirely; sessions never had a signing secret of their own, so there is nothing left to generate or rotate."
body: |
**Resolved 2026-09-08 by the `csrf-standard-library` change: the key no longer exists.** Moving CSRF protection to `net/http.CrossOriginProtection` (audit decision D1) removed the token entirely, and with it `csrf-secret` and `csrf-secret-file` — validation, both CLI flags, the file-backed secret entry, the embedded and test config keys, and the docs line. Sessions never had a signing secret of their own: `scs` with the Valkey store issues random server-side session ids. So there is nothing left to generate or rotate, and the remaining work this issue tracked (zero-downtime dual-key rotation, a keyring-shaped config, generation guidance) is moot rather than deferred.
Prior narrowing, 2026-07-31 by `hardening-polish` (10e): `docs/hosting.md` documented the hard-cutover rotation procedure and its blast radius.
- title: "Stripe mode is declared by a setting, not derived from the key, and is visible only on billing views"
kind: enhancement
areas: [billing, operator-ui, ops]
outcome: fixed
resolved: "2026-09-20"
by: "stripe-environment-stamp (design D3, D5, D7)"
summary: "The mode has been derived from the key's prefix since 782ca8f, which retired the `stripe-mode` key, and this change makes the derived mode visible wherever the Stripe mirror is touched: every mapping records the environment that made it, the purchasability panel and the Sync control name it, checkout and fulfillment refuse ids from the other environment, and the billing views show the key's environment. Not covered: the dashboard link's sandbox account id, and customers are not read back."
body: |
**✅ RESOLVED (2026-09-20) by `stripe-environment-stamp` (design D3, D5, D7)**: the mode has been derived from the key's prefix since 782ca8f, which retired the `stripe-mode` key; this change makes the derived mode visible wherever the Stripe mirror is touched. Every mapping records the environment that made it (`livemode` on the eight `stripe.*_mappings` tables), the purchasability panel's Payment processing row reads `Synced, test` or `Synced in live, key is test`, the Sync control reads `Create in test` when the recorded id is out of reach, checkout and the fulfillment paths refuse such ids before Stripe is called, the billing views show the key's environment with an absence line, an `env=all` switch and `Test`/`Live` badges, and a webhook event from the other environment is stored `refused` and counted on the provider page. A boot under a changed key starts a read-back of every synced product and price and marks the ones it cannot find `stale`. Not covered: the dashboard link's sandbox account id, and customers are not read back (open issue "The environment check reads back products and prices, not customers").
**Logged 2026-09-12** while wiring live Stripe on production. `stripe-mode` is a config key (default `test`) read once at boot; it drives the billing-view banner, the dashboard link, and the mode label on the Stripe integration page, and nothing else. The mode that matters is decided by the API key (`sk_test_`/`rk_test_` versus live) and by which dashboard the webhook endpoint was created in; the setting can disagree with both, and today it did (live key, setting still `test` until a restart). The maintainer's ask: make the effective mode visible in more places where it is relevant, and analyze where. Position for the exploration: (1) derive the mode from the key prefix at boot and turn the setting into a validation-only declaration (a mismatch refuses to boot, the way other config validation does), or retire it; (2) surface the derived mode where money or the Stripe mirror is touched: the purchasability panel's "Payment processing" row ("Registered in Stripe, test mode"), the Sync to Stripe control and its confirmation, the Prices view, the setup checklist's Stripe item, the member checkout affordance in test mode, and the existing billing banner; (3) a test-mode deployment should also be recognisable from the webhook events it stores (`livemode` is in every payload). Design exploration first, then one change. Vocabulary and links: `DashboardURL` returns `https://dashboard.stripe.com/test` for the test setting, Stripe's legacy test-mode URL; Stripe now isolates test data in Sandboxes, each with its own account id and `sk_test_` key, and a sandbox's dashboard lives under its account id, so the link is wrong for a sandbox account even when the mode is right. Deriving the mode from the key prefix (`sk_live_` / `sk_test_`) and the dashboard base from the account the key belongs to (`GET /v1/account`) covers both. Confirmed 2026-09-12: the maintainer's sandbox holds no product; the product the console synced at 18:16 UTC was created in the live account under the test label, and the console's `/test/products/prod_…` link, opened in the sandbox, shows Stripe's "Did you mean live mode? We couldn't find what you were looking for in test mode, but we found it in live mode." That interstitial is what made the product look test-side before the restart.
- title: "Location trail on every page, rooted at the surface (proposal)"
kind: enhancement
areas: [operator-ui, member-ui]
outcome: fixed
resolved: "2026-09-03"
by: "acceptance-fixes (design D18)"
summary: "Adopted and shipped in round 2 (task A9) — every page below a surface root now carries the trail (`surfaceRoot` per template set, `pageHeader` rendering root → crumbs → title, `PageHeader.NoTrail` on the two surface roots), and the top bar's surface label was removed."
body: |
Maintainer, 2026-09-01, going back and forth on the
Products-has-no-trail flag: "what if we prepended 'Operator' to the
breadcrumb and added the breadcrumb to every page?" The current rule
(chrome-conventions "One location trail") gives a trail only to pages
below a rail entry; rail entries carry none because the rail marks them.
Position (orchestrator, same day): adopt it. Three reasons. Below the lg
breakpoint the rail is an offcanvas drawer (`operator.html:70`), so on
tablets and at mobile width nothing on the page says where it sits; a
trail rooted at the surface is the only visible statement of location
there. Every page header gets the same anatomy, so the title sits at the
same height everywhere and the sheet review's H1 flags disappear by
construction. The root crumb links to the surface's home (`/operator`, the
overview; `/` for members), which gives every page the way home the
walkers kept looking for (ACC-9). Two costs: one extra line on the
rail-entry pages, and the root crumb duplicates the top bar's surface
label, which should then go (it is also the 3.28:1 contrast failure on
every page in `docs/operator-a11y-baseline.md`). Root crumb wording:
"Operator" on the operator surface; "Home" on the member surface, whose
dashboard is `/` (the deployment name is already the drawer's title).
Naming: the part is the "location trail" in both specs, the design-system
doc, the lint message, and `PageHeader.Crumbs`; "breadcrumb" is the
Bootstrap component that implements it. Prose uses "trail"; the doc keeps
its one parenthetical "a Bootstrap breadcrumb". If adopted: a
chrome-conventions and page-anatomy delta, a surface root on `PageHeader`,
the `page-without-header` lint rule gains a trail check, and all 28 sheets
re-accept. Adopted by the maintainer the same day ("Yes to both of your
suggestions"); its own change, after the 10k.4 triage.
- title: "Issue grant Description placeholder carried an instruction"
kind: bug
areas: [operator-ui]
outcome: fixed
resolved: "2026-09-03"
by: "acceptance-fixes (round 4)"
summary: "Settled and applied the same day in acceptance-fixes round 4: a one-line hint under the field, no placeholder; the Extend Note field followed."
body: |
Issue grant Description placeholder carried an instruction ("Audit
note: why this grant?"), a form-conventions deviation. Settled
2026-09-03 ("let's nuke the deviation and align that field with
convention"): a one-line hint under the field, no placeholder; the
Extend Note field followed. Applied in acceptance-fixes round 4.
- title: "Sessions and rebuilt databases: a stale session's person id broke writes"
kind: bug
areas: [identity]
outcome: fixed
resolved: "2026-09-03"
by: "acceptance-fixes (design D29)"
summary: "The auth middleware now ends a session whose person no longer exists, bouncing to sign-in on its next request."
body: |
Sessions and rebuilt databases (2026-09-03): the console trusted the
session's person id on every request; the demo operator is created
lazily at first sign-in, so a snapshot rebuild gives her a new id, and
a session minted before the rebuild carried an id that no longer
existed. Pages still rendered; the first write attributed to the
person failed deep in a handler ("4 failed… violates foreign key
constraint fk_grants_granted_by_person_id" on a plan ladder's rank-0
reorder with Grandfather, replicated 2026-09-03). Fixed in
acceptance-fixes round 4 (design D29): the auth middleware ends a
session whose person is gone on its next request. Left open: whether a
person whose `status` is no longer active should end the same way
(the check is existence only), for the auth-session round.
- title: "2026-09-09 security audit, second run (10l) — 5 confirmed, 2 notes"
kind: bug
areas: [identity, ops, fedwiki]
outcome: fixed
resolved: "2026-09-10"
by: "security-audit-remediation-2"
summary: "All five confirmed findings are fixed by that change (22 tasks, none open); its proposal names exactly these five and states that the two notes are deliberately not changed."
body: |
**Logged 2026-09-09** from the second outside-model run against the remediated tree (0b28a9d); every item below was verified live on the test stack, one recorded verdict per item. Fix as one OpenSpec change.
- **Database DSN with password logged at boot** (High/Low). `internal/db/database.go:93` and `:111` log the full DSN at Info on every DB-touching command; `internal/config/validate.go:40` echoes a malformed DSN into its error, which start logs. `db-dsn` was never marked secret in the config spec, so the existing masking never applied; every other secret checked is not logged. Fix: mark the key secret; log host, database and user only.
- **No session rotation at the OIDC callback** (Medium/High). `CallbackHandler` (`internal/auth/auth.go:487-750`) binds the identity without `RenewToken`; the cookie is byte-identical before and after sign-in. An attacker who starts a login, plants that state-matched cookie, and gets the victim to complete sign-in owns the session. Planting needs an on-path attacker on plaintext HTTP or host compromise (host-only, HttpOnly cookie), which is what keeps it Medium. Fix: renew the token after the state, nonce and PKCE checks; extend the spec's rotation requirement to the callback.
- **Anonymous requests persist a seven-day session each** (Medium/Trivial). `bounceToLogin` stores the return path and `LoginHandler` stores state/nonce/verifier, so every bare `GET /` or `GET /login` writes a store key with the full lifetime; 200 requests in 0.09 s added 201 keys, no throttling, and the store has no memory cap. Under `noeviction` sign-ins fail first; under LRU, real sessions are evicted. Fix: no store write until there is something to keep, a short pre-auth lifetime, and documented `maxmemory-policy` and reverse-proxy rate-limit guidance.
- **GET `/logout-callback` destroys a live session** (Low/Low). The handler calls `Destroy` unconditionally on a public GET; cross-origin protection skips GETs and Lax cookies ride top-level navigations. Reproduced from a foreign origin with a bogus `state`. `logout-ends-session` tested only the no-session and stale-cookie cases. Fix: an unguessable short-lived logout state issued at sign-out and required at the callback; the provider-initiated GET must keep working.
- **FedWiki delete, archive and status-change are an existence oracle** (Low/Low). `internal/integrations/fedwiki/web/api.go:357-362` and `partials.go:710-715,806-809` answer "Site not found" for a nonexistent site and "You do not own this site" for another tenant's; restore and keep-active (`partials.go:909,954`) already collapse both. The run-1 position covers only the custom-domain claim path. Fix: answer "Site not found" for a foreign site in the three handlers.
Notes, no fix required: three operator screens (person detail, lookup candidates, enrollment member list) show the email without the unverified badge the persons list already renders; a sign-in that yields no refresh token silently falls back to the week-long identity snapshot with no log line. Closed: operator role read from four claim locations (normative in `oidc-login`, needs an identity-provider misconfiguration against the setup guide); Discourse webhook replay (standing position, reconcile re-derives state); the five-minute revocation bound (verified live: 403 past the interval).
- title: "2026-08-22 first-contact UX re-walk (10g) — 19 findings, maintainer-triaged fix-all"
kind: enhancement
areas: [operator-ui, member-ui]
outcome: fixed
resolved: "2026-09-21"
by: "the five 10g changes"
summary: "`status/milestones.md` records the 10g row as Done, closed 2026-08-30, with its changes shipped and archived. The entry enumerates no individual finding, so nothing is split out."
body: |
The 10g re-walk ran per [first-contact-ux-process.md](../docs/first-contact-ux-process.md) (v1):
five naive browser/docs walkthroughs, two outside models (Kimi K3, DeepSeek),
48-screen dual-state evidence sweep, eight model-card honesty audits, three
independent heuristic reviews, and a code-verification pass that reclassified
three of four headline mysteries as environment artifacts before they could
mislead. The walk was synthesised into 19 deduplicated findings
UX-1…UX-19 (four severity-4). **Maintainer triage
2026-08-22: everything ships inside M10** (no won't-fix bucket exercised;
one reframe — the IdP realm name stays untouched, the app instead gets a
configurable deployment name applied to both surfaces). Work is sliced into
five 10g changes; see the 10g row in [milestones.md](milestones.md).
- title: "2026-07-02 adversarial UX audit — 56 confirmed findings (16 high)"
kind: bug
areas: [operator-ui, member-ui, billing, entitlements, catalog]
outcome: fixed
resolved: "2026-07-03"
by: "10h remediation"
summary: "All 56 findings executed per that day's triage (43 quick-fixes, 4 de-advertisements, the custom-domain interim hide, 7 interim guards for the structural defers); the 7 structural real fixes were inventoried in the deferred-remediation debt entry, whose open items are now issue files."
body: |
An adversarial operator/member UX audit (8 code-auditor agents over every operator and member flow; findings adversarially verified and deduped by a skeptic judge — 70 raw → 56 confirmed / 3 rejected) landed **56 confirmed findings: 16 high, 21 medium, 19 low**, dominated by lying copy, silent breakage, and dead ends. Headline highs: trial-days are collected, stored, and displayed but **never honored** anywhere in the billing chain; revoking an addon/usage/one-time grant **ends the org's plan attachment** while the addon keeps delivering; upgrading after a scheduled cancel **keeps the cancellation armed** (member pays for a switch, then loses the subscription at period end); checkout has no already-subscribed guard, so a stale tab creates a **second concurrent Stripe subscription**; a second entitlement rule for the same resource key turns the set into a **poison pill** that aborts all future materialization; the org-type backfill **mass-downgrades** orgs not exactly at the default rank-0 tier behind confirm copy claiming attachments are unchanged; and the ladder/set **Active toggles are complete no-ops** — inactive ladders still sell and provision.
Note the audit ran against the tree **before** that day's fixes (live-poll readiness, ladder append-at-end, db-error translation, multi-price), so each finding needs triage; the highs should be triaged into milestone phases (10e hardening / 10g first-contact UX, or dedicated changes).
**2026-07-03 update: remediated (10h, committed).** All 56 findings executed per that day's triage: 43 quick-fixes, 4 de-advertisements, the custom-domain interim hide (real remedy 10d), and 7 interim guards for structural-defers (17, 18, 19, 24, 25, 50; 56 verified as-is). The 7 structural *real* fixes still gate on 10i model cards — the deferred remainder is inventoried in the next issue. Verified: build/vet/lint clean, full DB-backed suite green, and the operator-walkthrough browser e2e suite green against the live stack. Two adjacent defects found and fixed during verification: the request-timeout middleware crashed the whole process on any request outliving its deadline mid-write (concurrent header-map write; replaced with `http.TimeoutHandler`), and the per-package test DB helpers assembled a truncated migration source list that mis-numbered fedwiki's goose versions against an app-migrated database (canonicalized in `internal/migrate.Sources()`).
- title: "Transition primitive: multi-axis & departed-ladder gaps (transition-primitive audit, 2026-06-24)"
kind: bug
areas: [entitlements, billing]
outcome: fixed
resolved: "2026-07-11"
by: "doc41-conferral-uniformity"
summary: "Gap 1 landed as `core.end_conferral` resolving strictly by source arc, DB-enforced via the per-source live-uniqueness indexes; gap 2 landed as the reconcile diff (desired-by-product versus live provisions, departed products ended via `end_conferral`). The `Transition` primitive itself is retired behind the enclosed `core.confer` function family."
body: |
**2026-07-11: both gaps closed by `doc41-conferral-uniformity`.** Gap 1's named fix direction ("give End an explicit scope, not newest-pool-wide") landed as `core.end_conferral` resolving strictly by source arc, DB-enforced via the per-source live-uniqueness indexes; gap 2 landed as the reconcile diff (desired-by-product vs live provisions; departed products are ended via `end_conferral`). The `Transition` primitive itself is retired behind the enclosed `core.confer` function family.
**Surfaced 2026-06-24** by a deliberate read-only audit of `entitlements.Transition` and its callers, run after fixing the two `reapplyDefaultsForPool`-delegation bugs (`transition-end-reapply-narrowing`, `transition-downgrade-honors-target`). The primitive and the Stripe reconcile path were built for today's **single-axis, 2-rung, evergreen** catalog; two latent gaps appear only once the **multi-axis** model (`member-ladder-aware-catalog`) or multi-item subscriptions go live. **None has a live trigger today.** Cross-verified against code; bundled here since they share a root (per-ladder/per-axis scoping) and a single consumer (multi-axis rollout). Four other candidate gaps were checked and **verified clear** (see bottom). (A third audit finding — the `Extend` spec self-contradiction — was resolved by `transition-extend-replace-semantics`.)
1. **`Transition(End)` ends an arbitrary attachment on multi-axis pools.** `resolveCurrentAttachment` for an End target returns `rows[0]` of `GetActiveAttachmentsByPool` (`internal/entitlements/queries/pool_provision_ladders.sql` — `ORDER BY activated_at DESC`, no axis filter), and `Transition` ends only that one (`transitions.go:176-183`, `:305-313`). The End target carries no subscription/provision/axis identifier. `endSubscriptionEntitlement` (`internal/fulfillment/reconcile.go:410-435`) computes `hasActive` for a *specific* subscription's provision but then calls `Transition{End:true}` with only `poolID` — so on a pool with >1 active attachment it can end the **wrong** axis (the most-recently-activated one). Grant-expiry (`internal/workflows/entitlements/activities.go:95`) and operator revoke pass bare `End:true` too. Latent (live pools have ≤1 attachment, so `rows[0]` is unambiguous). **Fix direction:** give `End` an explicit attachment/subscription scope (end the attachment for *this* provision/axis), not "newest pool-wide." High-severity blocker for multi-axis rollout.
2. **Reconcile converges *present* subscription items but never ends *departed* ladders.** `reconcileItems` (`internal/fulfillment/reconcile.go:266`) loops only `sub.Items.Data` (create/update); nothing ends entitlements for an item/ladder no longer on the subscription. Whole-subscription cancel is handled (`endSubscriptionEntitlement`), but partial removal is not. Three manifestations, one root cause: (a) a removed item that was the **sole occupant of a ladder** orphans its active attachment; (b) a **cross-ladder** paid→paid switch (new price tiers into a different ladder) classifies as `initiate` on the new ladder while the old ladder's attachment is never ended → two active attachments; (c) stale `billing.subscription_items` rows accrue (cosmetic). Masked today because live subscriptions are single-item/single-ladder and a same-ladder price swap is an in-place modify that supersedes correctly. **Fix direction:** reconcile should diff present-vs-prior ladders and end attachments for departed ones (pairs with #1's scoped-End). Tracks against `member-ladder-aware-catalog`.
**Verified clear during the same audit (recorded so they aren't re-investigated):** (i) the classification switch's `default` "same-rank, different-product" branch is **unreachable** — `billing.plan_ladder_tiers` has `UNIQUE (plan_ladder_id, rank)` (`00012_plan_ladders.sql:32`), so two products can't share a rank on one ladder; it's defensive dead code. (ii) `Transition(End)` with no current attachment correctly drops to baseline / reapplies the default, and the new end-at-default no-remint guard is correctly gated on `prior != nil`. (iii) A cross-ladder *non-end* target leaving both ladders active is **intended** (multi-axis = one active plan per axis), not a leak — it only matters if a cross-ladder *replace/switch* semantics is ever wanted (bundles, service migration). (iv) Reconcile is **idempotent** under double-fire (`pg_advisory_xact_lock`, same-target no-op, drift guards).
- title: "MaterializePoolEntitlements never lowers a key's limit when its last active rule goes"
kind: bug
areas: [entitlements]
outcome: fixed
resolved: "2026-09-15"
by: "entitlement-set-changes (Decision 143, task group 1)"
summary: "`MaterializePoolEntitlements` computes the whole fold before it writes (`computePoolFold`), `applyPoolFold` zeroes a numeric key and lapses a boolean key whose last active rule went, and `DryRunPoolEntitlements` shows the preview the same fold; `TestMaterializeLastRuleDeactivatedZeroesLimit` and `TestBooleanLastRuleDeactivatedLapses` pin both directions."
body: |
**Logged 2026-09-14** from the `entitlement-set-changes` design research (every candidate design failed the same stress finding; verified by reading the function). `MaterializePoolEntitlements` (`internal/entitlements/materialize.go:16-160`) builds `contributionsByResource` only from resource keys that at least one active provision's set still carries a rule for, calls `UpdateNumericEntitlementLimit` only inside the loop over that map, and zeroes limits only for provisions whose status is `ended`. So when the rule that fed a key is deactivated or deleted while its provision stays active, the key vanishes from the map, the loop never visits it, and `numeric_entitlements.resource_limit` keeps its old value with its stale `numeric_entitlement_contributions` rows intact. Nothing exposes it today because the rule delete path hard-deletes the row and never re-materializes anything ("rule changes apply at the next conferral"), and the incident's path (a rule added to a rule-less set) works because the key appears in the map. It is the prerequisite for any change that re-materializes pools after a rule change: without it, removing a rule lowers no limit while the preview says it does. Upstream's semantics for the repair (batch 2, answer 5): prune the contribution for the missing rule, recompute the limit from surviving contributions, a key with none goes to limit 0 with the entitlement and its usage row retained, usage untouched; a boolean key with no active rule flips `is_enabled` false and keeps the row. Fix inside `entitlement-set-changes` as its first task, with a test that removes a set's only rule for a key and asserts limit 0 and no surviving contribution.
**Resolved 2026-09-15 by the `entitlement-set-changes` change, under Decision 143 (a rule change propagates to every pool carrying the set), as the materializer repair the change's task group 1 delivered.** `MaterializePoolEntitlements` now computes the whole fold before it writes (`computePoolFold`), so a key's state is derived from the active rules alone; `applyPoolFold` zeroes a numeric key and lapses a boolean key whose last active rule went, and the same fold is what `DryRunPoolEntitlements` shows the preview. Tests `TestMaterializeLastRuleDeactivatedZeroesLimit` and `TestBooleanLastRuleDeactivatedLapses` in `internal/entitlements/materialize_test.go` pin both directions.
- title: "Purchasability says \"Ready to grant\" for an inactive private product that the grant form will not offer"
kind: bug
areas: [catalog, operator-ui]
outcome: fixed
resolved: "2026-09-13"
by: "slice3-followup-fixes"
summary: "The readiness panel carries its own Active row, marked unmet for every product whether listed or not, and the verdict precedence refuses both Purchasable and Ready to grant when a product is inactive (`internal/server/product_readiness.go`)."
body: |
**Logged 2026-09-12** from the maintainer's product walk on production. A private product with Active unchecked shows the verdict "Ready to grant" while the Active checkbox's own help says an inactive product is not offered when new grants are created, and the enrollment grant form indeed lists only `ListActiveProducts`. Cause in `internal/server/product_readiness.go`: the panel folds Active into the "Public & active" visibility row, marks that whole row Not applicable for private products, and computes the verdict from the remaining rows, so Active never enters the private path. Fix: split the row into "Active" (applies to every product; unmet blocks both verdicts) and "Public" (catalog visibility; Not applicable for private products), and make the grant-readiness verdict require Active. The `product-management` spec's panel rows change with it, so this is an OpenSpec change; pair it with the uncommitted `text-warning-emphasis` contrast fix on the same panel.
- title: "The console does not record which Stripe environment a synced object lives in; a key change leaves every mapping pointing at the old one while the pages describe the new"
kind: enhancement
areas: [billing, operator-ui]
outcome: fixed
resolved: "2026-09-20"
by: "stripe-environment-stamp"
summary: "Every mapping row records the environment it was created in, the six consumers refuse a row whose flag disagrees with the current key, checkout refuses before calling the provider, a read-back check marks unreachable ids stale, an event whose mode disagrees is stored refused, and the billing views name the environment. The one part left out, extending the read-back to customer mappings, is filed as `status/issues/environment-check-customer-mappings.md`."
body: |
**Logged 2026-09-13** from the maintainer's question about the billing banner ("You create products in a sandbox and then once you are ready, you set the settings to the live account... the old products synced would remain in the member-console... I imagine the opposite is also true"). Confirmed. `stripe.product_mappings`, `price_mappings`, `customer_mappings` and `subscription_mappings` store the Stripe id and a `sync_status` and nothing about the environment the id was created in (`internal/integrations/stripe/store/migrations/00001_init.sql`; `provider_configs`, which carried an account id and a mode, was dropped unused in 00002). A sync is one `product.New` or `price.New` at creation (`workflows/outbox.go`) and nothing ever reads the id back, so a mapping is a pointer, and an id resolves only in the environment that created it (live, the legacy test mode, or one sandbox). After the key moves to another environment, in either direction: the readiness row reads "Synced, live" or "Synced, test" from the current key (`syncedPaymentDetail`), not from where the object is; the Sync control refuses with "This price is already synced to Stripe." because the mapping holds an id; a member checkout sends the old price id under the new key, Stripe answers `resource_missing`, and the member sees "failed to start checkout" (`internal/server/billing.go`); the fulfillment reconcile's `subscription.Get` fails the same way; and the billing banner's sentence "Figures on these pages are test data" describes the key, while the figures are ledger rows that webhooks wrote under whatever key was configured when they arrived, so after a live-to-sandbox move the sentence is false. Webhooks do not cross-talk (a signing secret belongs to one endpoint in one environment), but nothing compares an event's `livemode` with the key's mode, so a secret left behind fails signatures silently. Fix, in order of cost: (1) stamp `livemode` from the returned object on every mapping row at creation and treat a row whose flag disagrees with the key's mode as not synced: the readiness detail names the recorded mode, the Sync control creates again instead of refusing, checkout refuses before calling Stripe; this catches sandbox and live in both directions at no cost. (2) A read-back (`product.Get` under the current key; a restricted key that writes products can read them) that marks a `resource_missing` mapping stale, run from the Stripe integration page or at boot when the key changed; this is the only way to catch a sandbox-to-sandbox move. (3) The webhook handler refuses an event whose `livemode` disagrees with the key's mode. (4) The banner's provenance sentence goes unless stored events carry `livemode` and can vouch for it. Not in `slice3-followup-fixes`, which ships the derived mode and the "Synced, live" detail as they are; its own change.
- title: "The add-rule form's Per unit checkbox is ignored; every rule authored through the form stores per_unit = false"
kind: bug
areas: [entitlements, operator-ui]
outcome: fixed
resolved: "2026-09-13"
by: "slice3-followup-fixes"
summary: "`SetBool` records the typed value beside the raw one (`internal/forms/parse.go`), so a bound checkbox answers `Bool` the way a parsed one does."
body: |
**Logged 2026-09-12** from the maintainer's production walk ("'Per Unit' being checked (or unchecked) in the add rule form creates an entitlement set with Per unit = No... Basically there is no way of setting it to yes"). Reproduced with a handler test: a POST carrying `resource_per_unit=true` for `fedwiki_sites` stores `{Bool:false Valid:true}`. Every limit rule in production carries false (Wiki Cafe Public 1, Wiki Cafe Standard 16, Legacy Grant 64 pack 64, Transitional 64), all authored through the form. Cause: `entitlementSetRuleFormValues` (`internal/server/operator_entitlement_set_forms.go`) binds the checkbox with `Values.SetBool`, which records the raw string and presence only, and `CreateEntitlementSetRule` (`operator_entitlement_sets.go`) reads `values.Bool("resource_per_unit")`, which reads the typed map that only `Parse` fills; the two halves of `forms.Values` disagree. Present since `entitlement-rule-authoring` (2026-07-21). The existing test posted the checkbox only on the boolean key, where the handler ignores it by design, so it never asserted the stored flag. Sibling audit: every other typed read in `internal/server` (`Bool`, `Int`, `Time`, `UUID`; four call sites in the entitlement set, product and grant handlers) reads values returned by `ParseSide`/`ParseWith`; this is the only handler reading typed values off a bound `Values`. Fix: `SetBool` records the typed bool too (a library invariant: a bound checkbox answers `Bool` the way a parsed one does), plus a handler test that asserts the stored flag for a numeric key. Impact today is nil because every live grant has quantity 1; `materialize.go` multiplies by quantity only when the stored flag is true, so a quantity-2 grant of any of these sets would deliver the base value. Fix-now, in the Slice 3 follow-up change with the contrast and readiness fixes.
- title: "Free rank-0 rung reads \"Included / Subscribe / Not available for purchase yet\" on the member catalog"
kind: bug
areas: [catalog, member-ui]
outcome: fixed
resolved: "2026-09-13"
by: "slice3-followup-fixes"
summary: "A rank-0 tier with no price and no rung held gets no move control and no unavailable line (`internal/server/member_products.go`)."
body: |
**Logged 2026-09-12** (maintainer: "Not honest."). `member_products.go` routes every non-current tier to a move: an org holding nothing on the ladder gets `Relation = available`, `MoveLabel = Subscribe`, `MoveKind = checkout`, and a tier with no price is not purchasable, so the control renders disabled with "Not available for purchase yet". A priceless rank-0 rung is never purchased: the default mechanism confers it, so for an org holding nothing on the ladder it is the org's plan and the card should read as current; for an org on a paid rung it is the cancel target, which the `Rank == 0` branch already handles. Fix: a `NoPriceTier` at rank 0 never gets a checkout move; when the org holds no rung on the ladder the card reads as the current plan without a control. Spec: `member-product-discovery`. For the Slice 3 follow-up change.
- title: "\"Public\" names the catalog flag badly; \"Listed\" says what it does"
kind: bug
areas: [catalog, operator-ui]
outcome: fixed
resolved: "2026-09-13"
by: "slice3-followup-fixes"
summary: "The checkbox label reads Listed with the help \"Shown in the member catalog.\", and the readiness row and the products list follow."
body: |
**Logged 2026-09-12** (maintainer: "public is a bad name... Maybe purchasable might be a better name"). The flag's meaning, per the product form's own help, is "sold in the catalog" versus "only issued by an operator as a grant". "Purchasable" fits paid rungs only: a free rank-0 rung is in the catalog and never purchased, and Purchasable is already the readiness verdict for public + active + priced + synced. "Listed" / "Unlisted" names the one fact the flag records, does not collide with product names such as "Wiki Cafe Public", and has a widely understood precedent. Proposed copy: label "Listed", help "Shown in the member catalog."; readiness row "Listed & active"; the private detail "Unlisted products are issued as grants." The column stays `is_public` (`product-catalog` names it; renaming is spec and migration churn with no behavior change). With the products-list Status rethink and the vocabulary entries.
- title: "The member Create site button hand-rolls its disabled state, so nothing tells the person it is disabled or why"
kind: bug
areas: [fedwiki, member-ui, testing]
outcome: fixed
resolved: "2026-09-13"
by: "slice3-followup-fixes"
summary: "The sites card renders the control through the disabled-control part, a lint rule refuses a hand-rolled disabled button outside it, and the over-limit state has its own sentence with the count and the limit."
body: |
**Logged 2026-09-12** (maintainer, over quota on production: "This Create site button style when it is disabled does not work... It isn't obvious that it is disabled"). `internal/integrations/fedwiki/templates/fedwiki_sites.html` renders `<button ... disabled title="Site limit reached">` directly. Bootstrap's `.btn:disabled` sets `pointer-events: none`, so the `title` never shows on hover and no cursor change reaches the pointer; there is no `aria-describedby`, no visible reason, and the 65% opacity of an outline button is the only cue. This is the hand-rolled shape `form-conventions` "Disabled controls carry their reason in the DOM" and `docs/design-system.md` §3 forbid, and the anatomy lint has no rule for a `disabled` button carrying a `title` outside the `disabledControl` part. Fix: render the button through the part's Visible variant with the reason ("Site limit reached", or the usage line itself as the described-by target so no copy is added); give the part's wrapper `cursor: not-allowed` in both variants (the tooltip variant already has a focusable wrapper; the Visible variant needs one, because the cursor cannot be set on an element with `pointer-events: none`), which is the deny cursor Carbon, Material, USWDS and Atlassian all use on disabled buttons; add the lint rule. The usage line reads "12 of 1 active sites used" when over the limit, which is honest and reads as impossible; the over-limit state gets its own sentence with the count and the limit (maintainer 2026-09-12: "When someone is over their limit, the page should say so in its own sentence, with the count and the limit"); candidate: "12 active sites, 1 allowed." For the Slice 3 follow-up change.
- title: "Residual SPA patterns: products and entitlement sets lack per-item pages"
kind: enhancement
areas: [operator-ui]
outcome: fixed
resolved: "2026-09-21"
by: "per-item operator pages"
summary: "`/operator/products/{productID}`, `/operator/entitlement-sets/{setID}`, `/operator/plan-ladders/{ladderID}`, `/operator/persons/{personID}` and `/operator/organizations/{orgID}` exist and are routed."
body: |
**Noticed 2026-06-23.** Post-M7 the operator panel is meant to be MPA with addressable per-item pages (e.g. `/operator/organizations/{orgID}`). But `/operator/products` and `/operator/entitlement-sets` are list pages whose **detail/management lives in partial-swap + modal flows** (`/partials/operator/products/{id}/edit`, `…/prices`; `/partials/operator/entitlement-sets/{id}/edit`, `…/rules`) rather than dedicated pages. There is no `/operator/products/{id}` or `/operator/entitlement-sets/{id}` — so a product/set isn't linkable, the back button doesn't work, and the surfaces still behave like the old SPA.
**Fix direction:** give products and entitlement sets first-class per-item MPA pages (prices, edit, rules rendered inline on the item page), consistent with the per-org composite view. Candidate for an operator-IA follow-up. Related: the M7 IA work that moved enrollment/grants to per-org pages.
- title: "Operator URL/route/code naming drift"
kind: bug
areas: [operator-ui]
outcome: fixed
resolved: "2026-09-21"
by: "MPA operator surface"
summary: "The tab parameter is gone from the operator surface, its bookmarks answer a permanent redirect, the script that drove it no longer exists, and the canonical route slugs are declared in `docs/operator-ia.md`."
body: |
The visible tab label, the `?tab=` URL parameter, the route slug, and the partial filename disagree. Examples: tab label "People" → `?tab=people` → route/partial `users`; tab label "Organizations" → `?tab=orgs` (but `?tab=organizations` does NOT switch tabs — `operator-tabs.js` only handles the short slug). Bookmarkability fails silently when a user infers the long name from the tab label. Discovered in Phase A v1 + reconfirmed in v2. Candidate for M7-7b/7d. **IA decision (2026-05-12, OpenSpec change `operator-ia`):** canonical route slugs are now declared in [`docs/operator-ia.md`](../docs/operator-ia.md#route-hierarchy). The `?tab=` short-slug shape disappears with M7-7d's MPA rewrite, which consumes the IA's route hierarchy as input — leave open until 7d closes it. **Update 2026-07-22 (fresh-eyes IA audit remediation):** the FedWiki instance of this drift is fixed — the operator surface moved to `/operator/integrations/fedwiki` (manifest, ia-position `integration:integrations:fedwiki`, legacy `/operator/fedwiki-sites` 301-redirects), so slug/URL/ia-position now agree; the capability/spec name `fedwiki-sites` and `resource_key` stay as-is.
- title: "Operator grant-issuance: two non-overlapping surfaces with no cross-link"
kind: bug
areas: [operator-ui, entitlements]
outcome: fixed
resolved: "2026-09-21"
by: "MPA operator surface"
summary: "The legacy global grant-creation path was removed; both issuance forms live on the per-organization page and the global Grants surface browses."
body: |
The global Grants tab and the per-org Enrollment "Issue Grant" form expose different products: global lets the operator pick any product (or an entitlement set); per-org Enrollment limits the Product dropdown to plan-typed products attached to the ladder. The two surfaces have no cross-link or "looking for X? try Y" guidance, and the operator must already know the distinction to choose correctly. Discovered in Phase A v2. Candidate for M7-7b IA. **IA decision (2026-05-12, OpenSpec change `operator-ia`):** both issuance forms move to the per-org composite view at `/operator/organizations/{orgID}`, labeled by intent ("Grant a plan" → `IssueGrant`; "Grant a non-plan product" → `CreateGrant`). The two code paths are kept (they align with the structural-vs-labeled product kind split per `membcons-db` Doc 35); only the *surface* is consolidated. Global Grants becomes read-only. 7d implements.
- title: "Two grant-revoke paths are non-equivalent and indistinguishable in UI"
kind: bug
areas: [operator-ui, entitlements]
outcome: fixed
resolved: "2026-09-21"
by: "MPA operator surface"
summary: "The legacy revoke path was removed; the composite revoke-and-transition is the only one the panel offers."
body: |
Global Grants tab "Revoke" → `POST /grants/{id}/revoke` (simple; body: *"entitlements will be recalculated"*). Per-org Enrollment "Revoke" → `POST /grants/{id}/revoke-and-transition` (composite; body: *"pool returns to its org-type default and a downgrade or end transition is recorded"*). The operator has no UI hint that these are non-equivalent — silent-correctness risk. The 2026-05 operator-UX research flagged it as the "two revoke paths" 7a.1 finding; this entry is the issue-tracker pointer so 7b can scope consolidation work. Candidate for M7-7b. **IA decision (2026-05-12, OpenSpec change `operator-ia`):** the per-org composite view at `/operator/organizations/{orgID}` is the sole UI entry point for grant revocation, using the composite revoke-and-transition behavior; the global Grants Revoke affordance is removed. (The broader IA also moves *all* grant action affordances — issue/extend/revoke — to the per-org view; the global Grants surface becomes read-only browse.) Implementation lands with M7-7d's MPA rewrite — leave open until 7d closes it.
- title: "Operator SPA partial eager-fetch"
kind: debt
areas: [operator-ui]
outcome: fixed
resolved: "2026-09-21"
by: "MPA operator surface"
summary: "The operator landing template issues no partial fetch at all; every surface is its own page."
body: |
On `/operator` initial page load, all 12 operator partials (users, organizations, org-types, grants, products, plan-ladders, entitlement-sets, billing accounts/subscriptions/invoices/payments, sites) are fetched eagerly via HTMX, then tabs are CSS show/hide on already-rendered DOM. Confirmed 2026-05 by a network capture of the landing load during the operator-UX research, which recorded the same finding. The architectural cause is what M7-7d (SPA → MPA conversion) is here to retire. Tracked here so it survives the M7 phase scoping. **IA decision (2026-05-12, OpenSpec change `operator-ia`):** the IA in [`docs/operator-ia.md`](../docs/operator-ia.md) replaces the flat-tab SPA model with a three-layer route hierarchy and a curated landing surface — M7-7d's rewrite consumes that hierarchy as input. Leave open until 7d closes it.
- title: "Contributor License Agreement: no text, no signing workflow, no lawyer's read of the grant"
kind: docs
areas: [licensing, meta]
outcome: fixed
resolved: "2026-09-08"
by: "CLA.md v1.0"
summary: "`CLA.md` v1.0 is the Apache ICLA v2.2 adapted through five decisions taken with the maintainer; signing is the `CLA-SIGNATORIES.md` pull-request procedure checked by the maintainer at merge, and `CONTRIBUTING.md` points at both. Still deferred: a Corporate CLA if a company ever contributes, and a severability clause if counsel wants one."
body: |
**Resolved 2026-09-08.** `CLA.md` v1.0 is the Apache ICLA v2.2 adapted through five decisions taken with the maintainer (outbound terms with Harmony's promise-back; copyright grant to the maintainer alone, patent grant still reaching recipients; transferable with multi-tier sublicensing; Wisconsin governing law; moral rights covered by definition plus a never-assert fallback). Signing is the `CLA-SIGNATORIES.md` pull-request procedure, checked by the maintainer at merge; no bot, so the forge question is moot. `CONTRIBUTING.md` points at both. Still deferred: a Corporate CLA if a company ever contributes (§4 offers permission or waiver only), and a severability clause if counsel wants one.
`CONTRIBUTING.md` states that a CLA is required and what it grants, but the
agreement does not exist: there is no text, no signing workflow, and nobody
qualified has read the relicensing grant. Today that costs nothing, because
every commit in the repository is the copyright holder's and the commercial
offer in `COMMERCIAL.md` is therefore unobstructed.
**It stops being free the moment a first outside contribution is merged.** A
copyrightable patch taken under plain AGPL makes its author a co-owner of the
combined work, and the commercial license can no longer be granted over the
whole of it without that author's sign-off. A Developer Certificate of Origin
does not substitute: it attests provenance and conveys no relicensing right.
This is a tripwire to arm before the repository is publicized, not a
prerequisite to the licensing already in place, and it is the one step in the
sequence that cannot be taken retroactively.
Work: pick the instrument (Project Harmony's CLA with the outbound option that
permits commercial and proprietary relicensing, or an Apache-style ICLA whose
sublicense right reaches the same result); have an IP lawyer read the
relicensing and patent grants specifically; publish the text as `CLA.md`; wire
the signing check into whichever forge the repository lives on. CLA Assistant
assumes GitHub, so the Gitea migration on [milestones.md](milestones.md)
decides whether that service is available or the check has to be manual. Then
point `CONTRIBUTING.md` at the result and drop its "not published yet"
paragraph. Reference §2 (why a CLA and not a DCO) and §5 (the template
comparison).
- title: "TestPlanLaddersWalkthrough flakes at MustWaitLoad"
kind: bug
areas: [testing]
outcome: fixed
resolved: "2026-08-23"
by: "removal of the racy construct at the suite's two call sites"
summary: "`plan_ladders_test.go` drops the redundant `MustWaitLoad`, `lookup_test.go` wraps its recovery-path settle in a tolerated `rod.Try` with a timeout; verified with three consecutive isolated greens and a green walkthrough package."
body: |
**Found during `ux-honest-surfaces` verification (reproduced identically at pristine HEAD, so not a regression), root-caused, fixed.** Mechanism: `MustNavigate` returns at navigation *commit*; a bare `page.MustWaitLoad()` immediately after evaluates rod's JS helper through a cached execution-context object from the *previous* document; during the document swap Chrome rejects the stale reference with `-32000 "Object reference chain is too long"`, which rod v0.116.2 does not classify as retryable (it retries only the exact string `"Cannot find context with specified id"` — `rod/page_eval.go:130`, `lib/cdp/error.go:26`) → panic. Other interleavings of the same race surface as `context deadline exceeded`. The race was always present (10j hit it once and blamed a dead dev server); a fast empty-DB dev stack made the window land reliably. Fix: removed the racy construct at both of the suite's only two call sites — plan_ladders_test.go drops the redundant `MustWaitLoad` (the following bounded `MustElement` is the stronger load signal and re-resolves contexts fresh per attempt), lookup_test.go wraps its recovery-path settle in a tolerated `rod.Try` with a timeout. Verified: 3 consecutive isolated greens + full walkthrough package green (62s, matching the 10j baseline). Latent residue for a future dep bump: rod's retry allowlist not recognizing modern Chrome's stale-reference message — worth rechecking on the next rod upgrade.
- title: "Makefile `sqlc-generate` target is stale"
kind: debt
areas: [meta]
outcome: fixed
resolved: "2026-08-22"
by: "model-doc-rot (10j)"
summary: "The target iterates the nine directories that carry a `sqlc.yaml`, failing on first error, and the first run surfaced and fixed the never-regenerated `internal/integration` code."
body: |
The target now iterates the nine directories that carry a `sqlc.yaml`, failing on first error, matching `docs/database-management.md`. Fixing it immediately paid off: the first run surfaced that `internal/integration`'s generated code had never been regenerated after migration 00010 added `suspended_at`/`retired_at` to `core.providers` (the broken target had hidden the drift); the regeneration is folded into this change, and a second run is a clean no-op. (Found 2026-08-22 during `purchase-path-blockers`.)
- title: "Per-stream migration ledgers: drop the ordered-source version namespace"
kind: debt
areas: [ops, integrations]
outcome: fixed
resolved: "2026-09-21"
by: "per-stream-migration-ledgers"
summary: "`internal/db/migrations.go` documents and uses one ledger per stream (`goose_db_version_<name>`), each stream keeps its own numbering, and the positional renumbering is gone."
body: |
**Decision taken 2026-07-05, implemented via
`openspec/changes/per-stream-migration-ledgers`**: per-stream goose ledgers
(`goose_db_version_core` / `_fedwiki` / `_stripe`), each stream keeping its
native `00001…` numbering; `assembleMigrations()`'s positional-namespace
renumbering is deleted. Core still runs first (FKs are integration→core
only); integration streams are mutually order-independent. Pre-production
wipe-volumes rule applied — this reset local goose bookkeeping, same as the
schema-consolidation baseline squash.
**Surfaced 2026-07-05** (maintainer question at schema-consolidation
close-out). The consolidation shrank `migrate.Sources()` from 10 ordered
sources to 3, but kept the mechanism: one `goose_db_version` ledger, with
each stream's versions derived from its *position* in the source slice
(`(index+1)*1000 + seq` in `internal/db/migrations.go`). That means an
integration's identity in the version ledger is a global list position —
append-only by convention, and inserting/reordering a source renumbers every
later stream and corrupts bookkeeping on any existing DB. A third-party
integration author should not need to know (or be assigned) a slot number.
The actual ordering requirements are weaker than an ordered list: (1) core
migrates before integrations (FKs point integration→core only — verified
2026-07-04, zero integration↔integration edges); (2) integration streams are
mutually independent. That's "core first, then the set in any order," not a
sequence.
Proposed fix: per-stream goose ledgers (`goose.SetTableName` per source,
e.g. `goose_db_version_core` / `_fedwiki` / `_stripe`), each stream keeping
its native `00001…` numbering; run core's ledger first, then each
integration's in any order; delete the `assembleMigrations()` renumbering
machinery entirely. Adding an integration then requires no coordination with
existing streams and never renumbers anything.
Related but separate: `Sources()` is compile-time (`go:embed` + a hand
edited Go slice), so a true third-party integration currently requires a
fork/recompile regardless of numbering — the registration story belongs to
the provider-extension-contract discussion. Timing note: switching ledgers
resets goose bookkeeping, so do this while pre-production (wipe-volumes
rule still in effect) — cheap now, migration-project later.
- title: "Provider dispatch transport: Temporal vs `integration.outbox` — unify or coexist?"
kind: design
areas: [integrations]
outcome: fixed
resolved: "2026-09-21"
by: "integration-extraction (Decision 4)"
summary: "Capability, not transport, with the rule for future providers stated in the entry itself. Nothing is left open in it except the payments seam, which is `status/issues/payments-provider-seam.md`."
body: |
**Decision taken 2026-07-05, implemented via
`openspec/changes/integration-extraction`** (Decision 4): capability, not
transport (option 3) — the contract describes the lifecycle capability; the
provider picks its own dispatch. Decision rule for future providers: **if
dispatch must be atomic with a domain commit, use the outbox handoff via a
shared enqueue helper** (`internal/integration`; core code stops writing raw
`core.outbox` SQL directly); **if the workflow owns the domain writes,
dispatch directly via Temporal**. FedWiki keeps direct Temporal
workflows/activities; Stripe keeps the transactional outbox, whose drainer
(`PollIntegrationOutbox`) moves into the Stripe integration tree as an
integration-owned workflow — it was already a Temporal workflow itself, so
Temporal remains the universal execution substrate underneath both
transports; the outbox is just the Postgres-transaction-to-substrate
handoff for the atomic case. The Stripe action-type vocabulary baked into
core moves out with the payments-provider-seam follow-up.
**Surfaced 2026-06-22** during the read-only / lifecycle undeferral explore (the M9 follow-on that consumes `wiki-plugin-farmmanager` v0.4.1).
Two providers dispatch mutating actions two different ways:
- **FedWiki** runs its lifecycle (create / delete / and now `set_status`) as **direct Temporal workflows + activities** (`internal/workflows/fedwiki/`). No outbox involvement.
- **Stripe** runs through the **transactional outbox** (`integration.outbox` + the drainer in `internal/workflows/stripe/outbox.go`).
The `provider-extension-contract` doc asserts "mutating verbs are applied provider-side via `integration.outbox`," but that is only true for Stripe — so the contract's transport language is already inaccurate for the FedWiki provider.
**Decision for the design team (do not delve now):** pick a direction —
1. **Temporal as the universal dispatch**, retire the outbox; or
2. **Outbox as the universal async dispatch**, move FedWiki onto it; or
3. **Per-provider by design** — the contract describes the *capability*, not the *transport*, and both coexist legitimately.
Trade-offs to weigh: the outbox gives transactional enqueue + dead-letter + DB-visible retry state and pairs naturally with webhook-driven providers; Temporal gives durable execution, the workflow UI, signals/queries, and is already FedWiki's substrate. **Not blocking** the read-only/lifecycle change — that dispatches `set_status` via FedWiki's existing Temporal path (option 3's default). Flagged so the contract's transport wording is reconciled and a uniform direction is chosen deliberately rather than by accretion.
- title: "Integration architecture (and operator IA placement)"
kind: enhancement
areas: [integrations, operator-ui]
outcome: fixed
resolved: "2026-09-21"
by: "integrations section and provider contract"
summary: "The operator surface has an integrations section, the legacy wiki sites path redirects into it, and the extension model exists as the provider contract with `docs/building-an-integration.md`."
body: |
The operator panel today places FedWiki sites at the same IA level as Products, Plan Ladders, Org Types, and Billing. This entrenches the assumption that FedWiki is the only external service the member-console will ever integrate with — which is wrong. The intended trajectory has the member-console acting as a hub for multiple external services (FedWiki today; NextCloud, Discourse, and others to come). Each is structurally a different concern from the catalog and runtime layers — they own their own resources, admin surfaces, and provisioning patterns, and they plug *into* the entitlements/billing model rather than being part of it.
Two distinct pieces of work fall out of this:
1. **M7 IA** (immediate): the operator panel should not have a top-level "Sites" tab. Integrations live in their own section (e.g. `/operator/integrations/...`) so adding a second integration does not require re-thinking IA again. M7 only needs to *not entrench* the FedWiki-as-first-class assumption; it does not need to design the extension contract.
2. **A dedicated milestone for the integration / extension model** (next-or-later): a standardized contract for how external services plug into the member-console — extension manifest, resource-key namespacing, per-integration entitlement displays, admin-surface registration patterns, and the boundary between member-console-owned state and integration-owned state. Discourse and NextCloud are the concrete drivers that will exercise the contract. See proposed milestone in `status/milestones.md`.
Discovered during M7 phase 7a (2026-05-08); supersedes the immediate scope of "FedWiki-only integration assumption" above by carving out the IA work and the architecture work as separate pieces.
- title: "Migration orchestration mechanics"
kind: enhancement
areas: [ops]
outcome: fixed
resolved: "2026-09-21"
by: "migration assembly"
summary: "The boot sequence collects each module's embedded migrations in dependency order, core first, then each integration stream in any order; superseded by the per-stream ledgers."
body: |
Per-module migrations need a boot sequence that collects from each module's embedded FS in dependency order.
- title: "Temporal auth race on first boot"
kind: bug
areas: [ops, testing]
outcome: fixed
resolved: "2026-09-21"
by: "temporal-first-boot-retry (M12b)"
summary: "Built and verified on a fresh boot; the compose file gates the workflow service on a healthy database and identity provider, with the reasoning in a comment above it."
body: |
After fresh `docker compose up -d`, first `member-console start` fails to reach Temporal. Second attempt works. **Live cold-boot testing (2026-07-01) found the dominant cause is server-side, not the JWKS/JWT race hypothesized from code reading:** the `temporal` auto-setup container races its own database — it runs schema setup before `temporal-db` accepts connections, fails (`no usable database connection found`), and exits, so nothing listens on the Temporal port and `start` gets `connection refused`. The `temporal` service used short-form `depends_on: [temporal-db]` (waits for container start, not readiness) and had no `keycloak` dependency. Two-part fix (in the `temporal-first-boot-retry` change, M12b): (1) compose ordering — gate `temporal` on `temporal-db` + `keycloak` `service_healthy` (+ a `temporal-db` healthcheck) so Temporal comes up; (2) a bounded client-side connect retry in member-console so it rides out Temporal's transient warm-up (observed: a `connection reset by peer`) and real-deployment Temporal/IdP restarts. Verified live: fresh boot → first `start` connects after one retry and fully boots.
- title: "FedWiki Sites operator tab empty under full seed"
kind: bug
areas: [fedwiki, testing]
outcome: fixed
resolved: "2026-09-21"
by: "sync schedule's immediate first run"
summary: "The sync activity upserts every site the farm reports into the local table, and the schedule is created with an immediate first run, so a seeded stack fills the tab on boot rather than waiting for a tick."
body: |
The compose seed provisions FedWiki fixtures at the FedWiki service but local `fedwiki.sites` rows are populated by integration workflows, not the seed. A "fully seeded" stack still shows an empty Sites tab. Related to "FedWiki sync does not populate DB from existing disk sites" (already filed) but distinct: that issue is about post-redeploy DB sync; this one is about first-run seed coverage. Candidate for M9 (Integration architecture) or earlier if the friction recurs.
- title: "FedWiki sync does not populate DB from existing disk sites"
kind: bug
areas: [fedwiki]
outcome: fixed
resolved: "2026-09-21"
by: "sync upsert and boot reconciliation"
summary: "The sync reads the farm's own listing and upserts, and the boot reconciliation adopts sites with no owner into the holding workspace."
body: |
After a DB nuke and redeploy, the `fedwiki.sites` table is empty even though site directories exist on disk (e.g. `test/data/fedwiki/`). The sync workflow does not re-discover existing FedWiki sites from the filesystem to repopulate the database. This means both the operator panel (`ListAllSites`) and member views (`ListSitesByWorkspace`) show no sites.
- title: "Mixed billing shape semantics"
kind: design
areas: [billing]
outcome: fixed
resolved: "2026-08-21"
by: "upstream Decision 138"
summary: "Doc 41 §6.4 adjudicates mixed co-occurrence as legal and explicitly classified (`product_shape` reports `billing_shape = 'mixed'` as a first-class answer); no member-console work implied."
body: |
**Resolved 2026-08-21.** Filed here as a doc-41 residual gap, but membcons-db corrected the reading: Doc 41 §6.4 adjudicates mixed co-occurrence as *legal and explicitly classified* — `product_shape` reports `billing_shape = 'mixed'` as a first-class answer, with a lifetime-license tier alongside a subscription as the canonical case — ratified under Decision 138 (`design/companion.md`, ratified 2026-08-21 with Decisions 134–139). No member-console work implied. Archive on next sweep.
- title: "`tier_reduction_policy` (Decision 125) is design-only — `clamp` is de-facto/emergent, not configurable"
kind: enhancement
areas: [entitlements, operator-ui]
outcome: fixed
resolved: "2026-09-15"
by: "entitlement-set-changes (Decision 147)"
summary: "Migration `00018_entitlement_set_changes.sql` adds `entitlement_set_rules.tier_reduction_policy` with the model's CHECK and default `defer`, plus `resource_keys.over_limit_behavior` and `over_limit_consequence` stamped at boot from the provider manifest; the policy is operator-editable on the rule form. Still not built: the switch-time precondition for `block` and the boundary scheduled change for `defer` at a tier switch."
body: |
Decision 125 (`design/companion.md`, `design/data-model.md`) models a `tier_reduction_policy ∈ {block, defer, clamp, force_reduce}` (default `defer`) on `entitlements.entitlement_set_rules`, enforced per-rule at the locus that knows allocation (`block` as a switch precondition, `defer` via a boundary scheduled change, `clamp`/`force_reduce` at materialization). **The column does not exist in the code schema** — `entitlement_set_rules` is `(rule_id, set_id, rule_type, resource_key, resource_value, resource_per_unit, stacking_policy, reset_period, credit_amount, credit_currency, description, is_active, …)`, no policy column (confirmed 2026-05-31).
So downgrade reconciliation is **not configurable today**; the live behavior is **`clamp` by emergence**: the FedWiki site-create guard checks `usage < limit` (`internal/workflows/fedwiki/activities.go`), so on a tier reduction the ceiling drops, existing usage is preserved, and new creation is blocked until back under cap. Verified live 2026-05-31 (Standard→Public: `sites` limit 16→1, 3 sites retained, member Create Site disabled "Site limit reached"). No `block` / `defer` / `force_reduce` path exists.
For the 8c MVP, `clamp` is adopted as the **documented de-facto policy** and needs no schema change. Making reduction policy **configurable** (the Decision 125 column + enforcement: materialization-time for `clamp`/`force_reduce`, switch-precondition for `block`, boundary scheduled-change for `defer`) is a later schema-sync — same family as the design-only `credit_disposition` / `invoice_line_items.line_type` columns (see "Subscription scheduled-change + commitment schema implemented from design-only model"). Discovered during 8c live verification 2026-05-31.
**Resolved 2026-09-15 by the `entitlement-set-changes` change under Decision 147.** Migration `00018_entitlement_set_changes.sql` adds `entitlement_set_rules.tier_reduction_policy` with the model's CHECK and default `defer`, migrates the existing `fedwiki_sites` rules to `force_reduce` so the FedWiki sweep keeps parking on this deployment, and adds `resource_keys.over_limit_behavior` and `over_limit_consequence`, stamped at boot from the provider manifest (`park` for `fedwiki_sites`, platform keys CHECKed to `deny_new`). The policy is operator-editable on the rule form and travels in the act's JSONB snapshots. At a rule change there is no switch to block and no boundary to defer to, so the preview resolves `block` and `defer` to `clamp` and says so (`FedWiki Sites: reduction policy defer becomes clamp.`, `The limit applies now. Usage above it is kept.`), while `force_reduce` exercises the key's over-limit behaviour and the sweep parks only under it (`GetGoverningReductionPolicy`). Still not built: the switch-time precondition for `block` and the boundary scheduled change for `defer` at a tier switch, which Decision 147 leaves to the switch's own locus.
- title: "TestModalMechanicsWalkthrough always skipped in a full-suite run"
kind: bug
areas: [testing]
outcome: fixed
resolved: "2026-08-01"
by: ""
summary: "The trigger scan is now generic (`button[data-action-url]`) with a fallback to the plan-ladders listing, whose delete triggers exist on any seeded stack, so the walkthrough no longer depends on a revoke button the `grant_revoke` package removes; verified in full-suite order, both steps run and pass."
body: |
**✅ 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`.
- title: "Walkthrough demo preconditions + schema-consolidation-gate e2e failures"
kind: bug
areas: [testing]
outcome: fixed
resolved: "2026-08-01"
by: "a43a789, the Doc 41 rework and the demo seeder"
summary: "Three parts: the form-split step dissolved with the Doc 41 rework, the Must-defeats-skip recurrences were converted to non-Must calls (a43a789), and the demo seeder now points the personal org type at `demo-ladder` and floors vacant personal pools, so the walkthrough dataset self-sustains."
body: |
**✅ 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.
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.
- title: "Test isolation: shared test DB polluted across parallel packages"
kind: debt
areas: [testing, ops]
outcome: fixed
resolved: "2026-08-01"
by: "test-db-isolation"
summary: "All three vectors closed: tests moved off the application database onto `member_console_test`, dropped, recreated and migrated at the start of every run; the in-process e2e suite moved to its own `member_console_e2e`; and `make test` runs packages serially, with the cluster-global `CREATE ROLE` guarded so a second database in the cluster migrates."
body: |
**✅ 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.
`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.
- title: "Inter-package test race: provisioning precondition vs org-type-change fixtures"
kind: bug
areas: [testing]
outcome: fixed
resolved: "2026-08-01"
by: "test-db-isolation"
summary: "`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."
body: |
`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.
- title: "Discourse reconcile tests still exposed to committed group_mappings"
kind: bug
areas: [testing, discourse]
outcome: fixed
resolved: "2026-08-01"
by: "test-db-isolation"
summary: "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."
body: |
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.
- title: "Operator panel: heading hierarchy is broken (H1 → H6, no H2/H3/H4/H5)"
kind: bug
areas: [operator-ui]
outcome: fixed
resolved: "2026-07-31"
by: "hardening-polish (10e)"
summary: "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."
body: |
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.
- title: "Operator forms: inputs missing `autocomplete` attribute"
kind: bug
areas: [operator-ui]
outcome: fixed
resolved: "2026-07-31"
by: "hardening-polish (10e)"
summary: "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)."
body: |
Chrome flagged 3 inputs at the operator landing without `autocomplete`. Violates WCAG 1.3.5. Cross-cutting across operator forms. Candidate for M7-7c.
- title: "Operator template has at least one inline event-handler attribute"
kind: bug
areas: [operator-ui]
outcome: fixed
resolved: "2026-07-31"
by: "hardening-polish (10e)"
summary: "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."
body: |
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.
- title: "Operator revoke-and-transition: empty product name in confirm body"
kind: bug
areas: [operator-ui]
outcome: fixed
resolved: "2026-07-31"
by: "hardening-polish (10e)"
summary: "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."
body: |
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.
- title: "Operator plan-ladder Tiers: \"no products to add\" alert is misleading when ladder is full"
kind: bug
areas: [operator-ui, catalog]
outcome: fixed
resolved: "2026-07-31"
by: "hardening-polish (10e)"
summary: "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."
body: |
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.
- title: "Catalog feature summaries render raw resource keys"
kind: bug
areas: [member-ui, catalog]
outcome: fixed
resolved: "2026-07-31"
by: "hardening-polish (10e)"
summary: "`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."
body: |
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).
- title: "Container image should not run as root"
kind: enhancement
areas: [ops]
outcome: fixed
resolved: "2026-07-31"
by: "hardening-polish (10e)"
summary: "The runtime stage adds `adduser -D -H -u 65532 app` and `USER app`; verified UID 65532 in the built image; the app makes no runtime filesystem writes."
body: |
**✅ 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.
- title: "`/login` state-overwrite race on parallel requests"
kind: bug
areas: [identity]
outcome: fixed
resolved: "2026-07-31"
by: "hardening-polish (10e)"
summary: "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)."
body: |
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.
- title: "Serve HTMX assets locally instead of from CDN"
kind: enhancement
areas: [ops]
outcome: fixed
resolved: "2026-07-31"
by: "hardening-polish (10e)"
summary: "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."
body: |
Include SRI hashes.
- title: "Role extraction doesn't check resource_access"
kind: bug
areas: [identity]
outcome: fixed
resolved: "2026-07-31"
by: "hardening-polish (10e)"
summary: "`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."
body: |
`extractRoles` in auth.go doesn't check `resource_access.<client-id>.roles`. Investigate best pattern for IDP-agnostic role mapping.
- title: "Better configuration handling"
kind: enhancement
areas: [ops]
outcome: superseded
resolved: "2026-07-31"
by: "startup-configuration (10b)"
summary: "Closed as superseded: boot-time configuration validation with aggregated, meaningful error messages shipped with 10b (`startup-configuration` spec)."
body: |
Validate config at boot with meaningful error messages.
- title: "Custom error pages"
kind: enhancement
areas: [operator-ui, member-ui]
outcome: fixed
resolved: "2026-07-31"
by: "hardening-polish (10e)"
summary: "Styled full-page 404/500 rendering shipped: `error.html` in the page chrome, wired at the router catch-all and the panic-recovery middleware; HTMX partial requests keep plain-text errors for the toast contract."
body: |
**✅ 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).
- title: "Make test-stack secrets unmistakably test-only"
kind: debt
areas: [testing]
outcome: fixed
resolved: "2026-07-31"
by: "hardening-polish (10e)"
summary: "The three mirrored Keycloak client secrets are re-valued to self-labeling `TEST-ONLY-...` strings across `mc-config.yaml`, `seed-keycloak.sh`, `compose.yaml` and the fedwiki `config.json.tpl`; the other files already carried banners, the tracked realm export was already deleted and gitignored, and the fedwiki admin token stays covered by its file banner per the spec's carve-out."
body: |
**✅ 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.
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.
- title: "provider_configs should not be per-organization"
kind: enhancement
areas: [integrations, ops, operator-ui]
outcome: fixed
resolved: "2026-07-22"
by: "dynamic-provider-config (9g)"
summary: "Config is app-level and the stillborn `stripe.provider_configs` table is dropped (stripe stream 00002); runtime-managed non-secret config is delivered generically through operator-set overrides in `core.integration_config_overrides` layered over env by a boot-time overlay, surfaced per integration at `/operator/integrations/{slug}/settings`."
body: |
**✅ 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.
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.
- title: "Tier removal with active attachments: spec says permitted + reconciled, code hard-blocks"
kind: bug
areas: [catalog, entitlements, operator-ui]
outcome: fixed
resolved: "2026-07-12"
by: "tier-removal-preview-commit"
summary: "Reconciled removal is implemented as a preview then commit flow (holders classified by position source; other-source positions align-shrink with delivery continuing; default-sourced holders require an explicit `keep` or `migrate`), the commit is atomic, and the spec's confirmation-modal wording was amended to the preview shape."
body: |
**✅ 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.
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.
- title: "Member-console reads `products.product_type` directly instead of `billing.product_kinds` view"
kind: debt
areas: [catalog]
outcome: superseded
resolved: "2026-07-11"
by: "doc41-conferral-uniformity (upstream Doc 41 Decisions 134–136)"
summary: "Resolved by dissolution: `product_type` is renamed `display_category` and demoted to presentation-only metadata, `billing.product_kinds` is deleted, and every behavioral read goes through structure; the read-discipline cleanup this issue tracked is superseded, since no label with structural claims remains to mis-read."
body: |
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.
- title: "Directory structure for integrations"
kind: debt
areas: [integrations]
outcome: fixed
resolved: "2026-07-05"
by: "integration-extraction"
summary: "FedWiki and Stripe both now live under `internal/integrations/{fedwiki,stripe}/`, each with its own module, store, and workflows subpackages."
body: |
If more integrations arrive beyond FedWiki, consider `internal/integrations/fedwiki/`. Not urgent — database schema namespace provides separation.
- title: "Purchasability checklist: no operator affordance to fulfill \"Stripe-mapped pricing\""
kind: enhancement
areas: [catalog, billing, operator-ui]
outcome: fixed
resolved: "2026-07-03"
by: "purchasability-sync-completion and multi-price-support (M10c)"
summary: "The operator product readiness card now surfaces mapping state per product with an explicit Sync to Stripe action, a live-polling pending indicator, a failed/dead-letter state with Retry, and a clean empty state when Stripe isn't configured; multi-price shipped alongside, so the card tracks the default price and the sync is product-idempotent."
body: |
**✅ 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).
**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.
- title: "Org-type default select silently NULLs when the defaulted ladder loses its rank-0 tier"
kind: bug
areas: [operator-ui, catalog]
outcome: fixed
resolved: "2026-07-03"
by: ""
summary: "`ListPlanLaddersWithRankZeroProduct` now LEFT JOINs the rank-0 tier/product, so a rank-0-less ladder still renders as an option and an org type's saved default keeps its selection instead of NULLing on save; `UpdateOrgTypeDefaultPlan` refuses a rank-0-less ladder as a new default while re-saving the already-configured value stays allowed."
body: |
**✅ 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.
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.)
- title: "Temporal UI can't complete SSO login inside the Docker network"
kind: bug
areas: [testing]
outcome: fixed
resolved: "2026-07-01"
by: "keycloak-dedicated-test-realm"
summary: "`temporal-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."
body: |
`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).
- title: "Embedded example mc-config.yaml is unused dead weight"
kind: debt
areas: [ops]
outcome: fixed
resolved: "2026-07-01"
by: "member-console-init-scaffold"
summary: "`cmd/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."
body: |
`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).
- title: "Keycloak account console errors (\"Something went wrong\") — test stack ran on `master` realm"
kind: bug
areas: [testing, identity]
outcome: fixed
resolved: "2026-07-01"
by: "keycloak-dedicated-test-realm"
summary: "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`); live-verified, the account console renders with the profile populated and zero console errors."
body: |
**✅ 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.
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).
- title: "FedWiki active-site swap cooldown is circumventable (archive+restore) and hardcoded"
kind: bug
areas: [fedwiki, entitlements]
outcome: fixed
resolved: "2026-06-24"
by: "fedwiki-swap-cooldown"
summary: "The identity-based rotation gate closes the archive+restore bypass, and the window is now a configurable flag (default 30 days)."
body: |
**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.
- title: "Orphaned farm sites are invisible in the operator panel but block creation"
kind: bug
areas: [fedwiki, operator-ui]
outcome: fixed
resolved: "2026-06-24"
by: "system-tenant"
summary: "Config and 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.\""
body: |
**✅ 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.
- title: "Transition.End reapplies default on every end, not only on \"downgrade that leaves the ladder empty\""
kind: bug
areas: [entitlements]
outcome: fixed
resolved: "2026-06-24"
by: "transition-end-reapply-narrowing (with transition-downgrade-honors-target)"
summary: "Ending a pool already at its default no longer re-mints the default, and downgrades land on their explicit target."
body: |
`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:
```go
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).
- title: "checkout.session.completed fulfillment provisions zero items — paid upgrade grants no entitlement"
kind: bug
areas: [billing, entitlements]
outcome: fixed
resolved: "2026-05-23"
by: "stripe-fulfillment-reconcile (workstream E)"
summary: "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 and entitlements via the transition primitive; the skip-if-mapping-exists guards are removed."
body: |
**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.1–5.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.
- title: "Downgrade read-only enforcement (`force_reduce`)"
kind: enhancement
areas: [fedwiki, entitlements]
outcome: fixed
resolved: "2026-06-23"
by: "fedwiki-lifecycle-states"
summary: "`wiki-plugin-farmmanager` v0.4.1 shipped the reversible read-only/archived states, and `fedwiki-lifecycle-states` implemented `fedwiki.sites.status`, the `set_status` contract verb, a `SetSiteStatusWorkflow` saga and the `ReconcileFedWikiQuotaActivity` force_reduce engine, plus the member swap picker with a per-workspace cooldown. Remaining refinement: enforcement latency is the sync interval, not instant."
body: |
**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.
- title: "Operator UX: no clear path to making a product \"available for purchase\" (price + Stripe mapping unguided)"
kind: enhancement
areas: [catalog, operator-ui, billing]
outcome: fixed
resolved: "2026-05-31"
by: "operator-purchasability-readiness"
summary: "The operator product edit page now shows a Purchasability panel evaluating each precondition (published, public and active, structural kind, active price, Stripe-mapped) and renders a single Purchasable or Incomplete verdict naming the missing steps, computed from `computePriceReadiness`, the same gate the member catalog uses; the create-to-sell path is documented in `docs/plan-management.md`."
body: |
**✅ 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.
- title: "Domain ambiguity: must a plan be on a ladder to be shown/purchasable? (NULL-type off-ladder products are an invisible limbo)"
kind: enhancement
areas: [catalog, operator-ui]
outcome: fixed
resolved: "2026-05-31"
by: "operator-purchasability-readiness"
summary: "The rule is documented in `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), and the readiness panel surfaces the invisible-limbo case on the product edit page with remediation; whether off-ladder plans should ever be sellable is settled by the data model."
body: |
**✅ 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.
- title: "Keycloak seed ID pinning assumption is fragile"
kind: bug
areas: [testing]
outcome: fixed
resolved: "2026-05-11"
by: "2026-05-11-keycloak-id-pinning-fix"
summary: "Keycloak 26.x silently drops the `id` field on `POST /admin/realms/{realm}/users`; user creation in `seed-keycloak.sh` now uses `POST /admin/realms/{realm}/partialImport`, which preserves the pinned id."
body: |
**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.
- title: "Operator panel tabs are not HTMX-idiomatic: stale state and lost position on reload"
kind: bug
areas: [operator-ui]
outcome: superseded
resolved: "2026-05"
by: "MPA operator surface"
summary: "The operator panel's tab model was replaced by the MPA operator surface, where each section is its own page with its own URL, so there is no tab pane to go stale and no position to lose."
body: |
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.
- title: "Plan concept needs depth evaluation before M6"
kind: design
areas: [catalog, entitlements]
outcome: fixed
resolved: "2026-04"
by: "upstream design commit 732197a"
summary: "Design commit `732197a` structurally enforces `product_type='plan'` via `plan_ladders`, `plan_ladder_tiers` and a CHECK, with mutual exclusion per (pool, ladder) through the GiST exclusion constraint and upgrade/downgrade semantics expressed through `rank`; the remaining work became Milestone 6, Plan Management Foundation."
body: |
`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.
- title: "`pool_provisions.status` needs a `dormant` value for superseded provisions"
kind: design
areas: [entitlements]
outcome: superseded
resolved: "2026-04-18"
by: "end-and-re-apply supersession approach"
summary: "Abandoned on reconsideration (2026-04-18) because the accreting complexity (new status value, new column, per-source policy, GiST predicate audit, canonicalization rules) exceeded the value over a simpler alternative; superseded by the end-and-re-apply on reversal approach."
body: |
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.
- title: "Supersession via end-and-re-apply (no `dormant` status needed)"
kind: design
areas: [entitlements]
outcome: superseded
resolved: "2026-04-18"
by: ""
summary: "Adopted for M6b: on supersession the prior provision and ladder row end, and on reversal the transition primitive calls `ReapplyDefaultsForPool` to re-issue the org-type default; the `Transition` primitive that carried it was later retired behind the enclosed `core.confer` function family."
body: |
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.
- title: "New `entitlements.pool_provision_transitions` table for plan transition audit history"
kind: design
areas: [entitlements]
outcome: superseded
resolved: "2026-04-18"
by: ""
summary: "Proposed `entitlements.pool_provision_transitions` as the canonical plan-position history of a pool (initiate, upgrade, downgrade, end; actor-attributed), complementing `billing.subscription_changes`, refined after the design-team review of 2026-04-18; the M6 plan-management milestone and the conferral rework that followed superseded the proposal as written."
body: |
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`):
```sql
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.
- title: "Auto-provisioning does not backfill existing orgs when `default_product_id` changes"
kind: design
areas: [entitlements, operator-ui]
outcome: superseded
resolved: "2026-04-18"
by: ""
summary: "Proposed that changing `org_types.default_product_id` warn that existing orgs are unchanged and offer an operator backfill action over `ReapplyDefaultsForPool`; the org-type commit flow that shipped later re-floors vacant pools through `ReapplyDefaultsIfVacant`."
body: |
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).
- title: "`core.webhook_events` ON CONFLICT dedupe only collapses same-instant duplicates — Stripe handler affected"
kind: bug
areas: [billing, integrations]
outcome: fixed
resolved: "2026-07-20"
by: ""
summary: "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, which recorded 2 rows)."
body: |
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).
- title: "`/operator/fedwiki-sites` 500s — operator-shell template set missing the lookup partial"
kind: bug
areas: [fedwiki, operator-ui]
outcome: fixed
resolved: "2026-07-20"
by: ""
summary: "`internal/integrations/fedwiki/web/operator.go` parses `partials/operator_lookup_result.html` alongside the shell (the Discourse handler's pattern); the render regression is guarded by `internal/integrations/fedwiki/web/operator_render_test.go`, red-verified. The building-an-integration guide §4 already documents the shell-partial dependency rule."
body: |
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).
- title: "FedWiki-only integration assumption pervades UI and data patterns"
kind: enhancement
areas: [member-ui, integrations, fedwiki, discourse]
outcome: fixed
resolved: "2026-07-21"
by: "dashboard-display-genericity (M9 9f)"
summary: "The member dashboard is registry-driven: integrations declare cards via `server.DashboardCardProvider` and core's `index.html` renders generic shells only; FedWiki's card and modals moved into its tree, Discourse gained a member forum card, and entitlement rows lead with `core.resource_keys.display_name` with provider attribution."
body: |
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.
- title: "Operator entitlement-set rules: no UI to add boolean rules"
kind: enhancement
areas: [entitlements, operator-ui]
outcome: fixed
resolved: "2026-07-21"
by: "entitlement-rule-authoring"
summary: "The form is now key-first and kind-adaptive: `core.resource_keys` gained `kind ∈ {boolean, numeric}` 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 are unreachable."
body: |
`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.
- title: "Discourse group converge: one dead username poisons the whole batch add"
kind: bug
areas: [discourse]
outcome: fixed
resolved: "2026-07-22"
by: ""
summary: "Members still absent after the batch attempt are diagnosed individually against the forum's id-keyed admin record: renamed users heal the stored `discourse_username` and retry, and users the forum no longer knows are quarantined as `status = 'orphaned'`, guarded by a `VerifyKey` re-probe so a dead admin key cannot orphan healthy links."
body: |
`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.
- title: "Grant-extend walkthrough fails on a long-lived stack DB (toast never shows; server returns 200)"
kind: bug
areas: [testing, operator-ui]
outcome: fixed
resolved: "2026-07-23"
by: ""
summary: "The extend card rendered on `HasAttachment` alone while `ExtendGrant` requires a grant-backed provision; `PoolEnrollmentViewModel.HasGrantDelivery` now gates the extend form, and the walkthrough's `gotoExtendableOrgComposite` helper scans the org listing for a composite that renders the form, skipping when no org qualifies."
body: |
`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 (`GetActivePoolProvisionsByPoolID` → `GrantID.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).
- title: "Discourse operator walkthrough lacks a forum-reachability skip guard"
kind: bug
areas: [testing, discourse]
outcome: fixed
resolved: "2026-07-23"
by: ""
summary: "`skipUnlessForumReachable` reads the forum base URL the app is actually configured with from the integration settings page and probes it over HTTP, skipping on connection failure or a 500-class answer; validated live in all three modes."
body: |
`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.
- title: "Unconfiguring Discourse leaves its durable sweep schedule firing forever"
kind: bug
areas: [integrations, ops, discourse, fedwiki]
outcome: fixed
resolved: "2026-07-23"
by: ""
summary: "`internal/workflows/schedule_dormancy.go`: `PauseLeftoverSchedule` (called by an unconfigured or disabled `Startup`) pauses an existing running schedule with a frozen dormancy note, and `ResumeDormantSchedule` unpauses only a pause carrying that exact note, so an operator's deliberate pause survives restarts; wired into both Discourse and FedWiki `Startup` hooks."
body: |
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.go` — `PauseLeftoverSchedule` (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.
- title: "Custom-domains entitlement gate lives in fedwiki's web layer, not core"
kind: debt
areas: [domains, entitlements]
outcome: fixed
resolved: "2026-07-26"
by: "centralize-external-claim-gate"
summary: "Enforcement moved into `Registry.ClaimExternal` via an injected `domains.ExternalClaimGate` (pre-lock, so neither of the original objections applies); one constructor (`internal/server.NewExternalClaimGate`) feeds both member-facing registries and both surfaces' affordance checks, and the duplicated helpers and constants are gone."
body: |
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.
- title: "Operator domains: force-release renders for placed claims the server will refuse"
kind: bug
areas: [domains, operator-ui, testing]
outcome: fixed
resolved: "2026-07-26"
by: ""
summary: "`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\" while operator roots keep their label, and the walkthrough pins the no-button-on-in-use invariant before moderating."
body: |
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).
- title: "FedWiki demotion residue — remaining parity gaps vs Discourse (post-M9)"
kind: debt
areas: [integrations, fedwiki, testing, ops]
outcome: fixed
resolved: "2026-08-01"
by: "integration-config-parity and test-stack-integration-profiles"
summary: "Resolved in two slices: `integration-config-parity` moved every hand-declared integration key out of core into each integration's `ConfigSpec` and replaced `fedwiki-custom-domain-target` with the core `domains-connect-target` key; `test-stack-integration-profiles` put the fedwiki chain and Caddy behind a compose profile so the default composition runs core services only and no script or walkthrough assumes a service exists."
body: |
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.
- title: "Entity slugs should not exist — survey and eradicate"
kind: design
areas: [operator-ui, member-ui]
outcome: fixed
resolved: "2026-08-28"
by: "drop-entity-slugs"
summary: "Class 1 (display and entry) removed across both panels and locked by a template-source guard; class 2 (columns) kept because upstream data-model v16 defines them; class 3 (provider slug, org-type key, ladder key, resource keys) kept as machine identifiers, now labeled \"Key\" and never leading a presentation."
body: |
**Filed 2026-08-22 (maintainer direction during 10g triage).** Slugs are
neither unique IDs nor useful display values, and to the maintainer's
knowledge they appear nowhere in the upstream design (membcons-db) — they are
a local invention that leaks database-shaped strings into the UI (org rows
badged `mockshot-1260`, members shown `hosted-website` pills). Direction:
eradicate entity slugs; entities are identified by UUID and presented by name.
Actual footprint (surveyed 2026-08-22): four real `slug` columns —
`core.organizations`, `core.workspaces`, `core.resource_pools`,
`core.providers` — plus two slug-shaped text keys not named slug:
`core.plan_ladders.ladder_key` (the `hosted-website` badge) and
`core.org_types.org_type` (load-bearing in code as `personal`/`system`).
The change must distinguish three classes: (1) UI display of slugs — remove
outright (overlaps the `ux-ia-naming` work); (2) redundant slug columns on
orgs/workspaces/pools — drop if the survey confirms nothing load-bearing
(FedWiki site naming and operator lookup both need checking); (3) code-identity
keys (`providers.slug`, `org_type`, `ladder_key`, resource keys) — these are
registry/natural keys, not vanity slugs; converting them is a separate,
riskier decision the design phase must argue explicitly. Verify the
no-slugs-upstream claim against data-model v16 during the survey; if upstream
does define any, that becomes a design-feedback note, not a local rewrite.
**2026-08-28 survey outcome (change `drop-entity-slugs`):** the no-slugs-upstream
premise is wrong; see the design-feedback entry below. The change keeps the
columns and removes every place a person sees, types, or searches a slug
(three rules now in `ui-vocabulary`), relabels the ladder key "Key", and adds a
template-source guard.
**Resolved 2026-08-28** by change `drop-entity-slugs` (archived under `openspec/changes/archive/`): class 1 (display and entry) removed across both panels and locked by a template-source guard; class 2 (columns) kept because upstream data-model v16 defines them (the `design-feedback` entry "Upstream defines entity slugs that carry no product function in member-console" stays open in `issues.md`); class 3 (provider slug, org-type key, ladder key, resource keys) kept as machine identifiers, now labeled "Key" and never leading a presentation. Evidence: findings' Change 5 status in `ux-walks-2026-08/findings-2026-08.md`.
- title: "Entity identifiers: slugs replaced by keys"
kind: design
areas: [identity, catalog]
outcome: fixed
resolved: "2026-08-29"
by: "entity-keys (upstream data model v16.3, Doc 45)"
summary: "The identifier round replaced dropping the columns with a policy: database-generated UUIDv7 IDs (Decision 30), display names, and a nullable unique `key` by entity class; landed as `entity-keys`, upstream data model v16.3 and Doc 45, conventions in `docs/identifiers.md`."
body: |
Filed 2026-08-28 as "upstream defines entity slugs that carry no product
function". The first cut dropped the columns; the identifier round replaced
that with a policy: database-generated UUIDv7 IDs (Decision 30), display
names, and a nullable unique `key` by entity class. Landed as
`openspec/changes/archive/2026-08-29-entity-keys/`, upstream data model
v16.3 and Doc 45, `design/` synced 2026-08-29. Conventions:
`docs/identifiers.md`. Record: `status/explorations/identifiers-2026-08/README.md`.
- title: "Returning-login resync overwrote a person's display name with an empty string"
kind: bug
areas: [identity]
outcome: fixed
resolved: "2026-08-29"
by: "entity-keys"
summary: "Both login paths now share `provisioning.DisplayNameFromClaims` (the `name` claim, then `preferred_username`, then the stored name); landed with `entity-keys`."
body: |
Both login paths now share `provisioning.DisplayNameFromClaims` (the `name`
claim, then `preferred_username`, then the stored name). Found by the
person-name exploration; landed with `entity-keys`.
- title: "Two shells, one console: the operator top bar carried every link and phones could not navigate the operator surface"
kind: enhancement
areas: [operator-ui, member-ui]
outcome: fixed
resolved: "2026-08-30"
by: "unify-shell"
summary: "Landed as `unify-shell`: one shell for both surfaces, the account menu top-right, sections in the rail, the mirrored surface switch as the rail's last entry, and the rail as an offcanvas drawer below `lg`; convention in `docs/design-system.md` §5."
body: |
Reported 2026-08-29; researched the same day
(`status/explorations/shell-navigation-2026-08/`, frozen); approved with the
proposal's recommendations. Landed as
`openspec/changes/archive/2026-08-30-unify-shell/`: one shell for both
surfaces (`partials/shell_topbar.html`, `partials/shell_rail_member.html`,
`server.Shell`), the account menu top-right (Identity and Access, Sign out),
sections in the rail, the mirrored surface switch as the rail's last entry,
the rail as an offcanvas drawer below `lg` with `static/shell.js` closing it
on navigation. Convention: `docs/design-system.md` §5; contract:
`chrome-conventions`, `operator-panel-navigation`, `ui-vocabulary`.
- title: "Dense-row widths were declared per field; the control now decides"
kind: enhancement
areas: [operator-ui]
outcome: fixed
resolved: "2026-09-05"
by: "dense-widths"
summary: "The control's natural width picks its rung (number and date a sixth, date-time a quarter, select and text a third, textarea the full row, checkbox its content), `Width` is a one-rung step the invariants bound to the ladder, and no CSS is declared per field; landed as `dense-widths`, convention in `docs/design-system.md` §8."
body: |
Raised 2026-09-05 by the maintainer on the round-4 sheet of `forms-library`
("I don't know why product takes half the width then the next three take a
sixth"): each dense field declared Wide, Narrow or nothing, mapped onto
Bootstrap's twelve columns as 6, 2 and 3, so the grid arithmetic lived in
the declaring file, the unset width was a fixed quarter called auto, and a
select at a sixth clipped "Choose a reason". Decided the same day in three
steps: the control type decides the width and a declaration only overrides;
columns stay on the grid rather than rem widths ("messy look"); the fractions
are only full, half, third, quarter and sixth ("I Don't want awkward stuff
like 7/12"), with complementary widths placed side by side where the reading
order allows. Landed as `openspec/changes/archive/2026-09-06-dense-widths/` (two sheet rounds: below 1200px every dense field takes its own line; the grant's free text is Note and both ledgers show it):
the control's natural width picks its rung (number and date a sixth,
date-time a quarter, select and text a third, textarea the full row,
checkbox its content), `Width` is a one-rung step (`WidthNarrower`,
`WidthWider`) the invariants bound to the ladder, four steps remain (grant
reason, price amount, currency, interval), no CSS. Convention:
`docs/design-system.md` §8; contract: `form-library`, `form-conventions`.
- title: "Overview page styling diverged from the rest of the operator panel and the member console"
kind: bug
areas: [operator-ui]
outcome: fixed
resolved: "2026-09-06"
by: "overview-consistency"
summary: "The overview's three regions render through `sectionHeader`, its tiles and the two queue and catalog cards through the new `readout` part, a linked card carries the `chevronIcon` glyph, and the activity feed is a boxed, paged ledger; the anatomy gained the readout, the linked card, the flush list, the boxing rule and the `raw-section-title` lint rule."
body: |
Raised and closed the same day. Audit dossier `status/explorations/overview-consistency-2026-09/` (18 dimensions, findings F1 to F12), change `openspec/changes/archive/2026-09-06-overview-consistency/`. The overview's three regions render through `sectionHeader`; its tiles, the Stripe delivery queue and the Products catalog overview render through the new `readout` part (one size, `fs-3`, the uppercase eyebrow the console's one uppercase text); a linked card is marked by the `chevronIcon` glyph and the row's hover shade; the System card lost its category title and keeps the setup checklist's flush-list idiom; the activity feed is a boxed, paged ledger like the composite's with Summary first and When last; the title stays "Operator overview" (maintainer: "Panel homes need no rail"). The anatomy gained the readout, the linked card, the flush list, the boxing rule (list pages bare, record pages and roots boxed) and the `raw-section-title` lint rule; the four hand-written `<h2>` titles elsewhere were reclassified. Two follow-ups filed in `issues.md`: the person page's bare memberships table, and the four tables with small dates. The maintainer's original ask, verbatim, is in the dossier's README.
- title: "Tier changes ledger: two rows per event, machine reasons with IDs, and the note where the reason should be"
kind: bug
areas: [operator-ui, entitlements]
outcome: fixed
resolved: "2026-09-06"
by: "tier-changes-ledger"
summary: "The list folds only the supersession pair `core.confer()` writes in one call, machine reasons render as sentences with the raw string as the cell's tooltip, both grants ledgers order by creation time newest first, and Extend tier writes `operator_extension` so its row reads Extended."
body: |
Raised and closed the same day. Change `openspec/changes/archive/2026-09-06-tier-changes-ledger/` (design D1 to D7). The list folds only the supersession pair `core.confer()` writes in one call: the incumbent's `end` row hides behind the typed row that already carries from and to. An expiry, revocation, or cancellation stays two rows, because the machinery performs two acts. Machine reasons render as sentences (Grant expired, Default restored after a grant expired, Revoked by an operator, Extended by an operator, and the rest of D3's table) with the raw string as the cell's tooltip; the machine's sentence leads, and a grant-backed row keeps the grant's note under it. Both grants ledgers order by creation time alone, newest first. Folded in on the maintainer's "ride it": Extend tier carries the extended grant's reason (manual for a default) and writes `operator_extension`, so its row reads Extended instead of Transferred under a manual reason the operator never chose (D7). Accepted and archived on "Accept! archive both".
- title: "Products list status should be purchasability, not lifecycle"
kind: enhancement
areas: [operator-ui, catalog]
outcome: fixed
resolved: "2026-09-06"
by: "purchasability-status"
summary: "The products list's Status column shows the readiness verdict (Purchasable, Ready to grant, Incomplete naming the first missing leg, Draft and Retired), computed per page through batched lookups; lifecycle became a facet on the list and a facts card on the detail page."
body: |
Raised 2026-09-01, closed 2026-09-06. Change `openspec/changes/archive/2026-09-06-purchasability-status/`. The products list's Status column shows the readiness verdict (Purchasable in the success tone, Ready to grant as info, Incomplete as warning naming the first missing leg, Draft and Retired as secondary), computed per page through batched lookups (product shapes, prices, price mappings, entitlement set rules and names by id), so a page costs a fixed handful of queries rather than one per row. Lifecycle stopped being a badge: it is a facet on the list and a one-row facts card on the detail page. Per-row lookups elsewhere are tracked under "Audit: one query per row in list and ledger builders" in issues.md.
- title: "Mobile top bar: two menus, and the brand not first"
kind: bug
areas: [operator-ui, member-ui]
outcome: fixed
resolved: "2026-09-09"
by: "mobile-shell-menu"
summary: "Below `lg` the deployment name is the bar's left-most element and one toggler sits at the right; the account dropdown does not render there, and the rail's existing drawer became the shell's one menu, with the account items as their own list at the drawer's foot (design D8)."
body: |
Logged and closed the same day. Change `openspec/changes/archive/2026-09-10-mobile-shell-menu/` (design D1 to D8). Below `lg` the deployment name is the bar's left-most element and one toggler sits at the right; the account dropdown does not render there, and the rail's existing drawer became the shell's one menu. A second drawer was rejected: it would have copied each surface's section list, and the first change to add a section would have left the mobile menu silently short.
The drawer holds two groups, and how it tells them apart is the change's second pass. The first build separated them with a third hairline of the same weight as the other two; the maintainer read it as messy and asked for the pattern to be researched rather than guessed. Design D8 records what the research says and what it rejects: the account items are their own list, named by the person through `aria-labelledby` (they are not destinations, and one list of twelve items announces three of them as places they can go); the group is pushed to the drawer's foot with an auto margin, which also lands it in the same place on both surfaces however many sections the rail carries; one hairline above it and none inside it, since the dropdown's divider before Sign out buys nothing in a drawer with room to spare. Sources: NN/g's proximity principle ("varying amounts of whitespace... is key to communicating meaningful groupings"), the W3C WAI menu-structure tutorial's "Label menus" and its responsive rule, Material's navigation drawer (its account header layout, and "dividers and subtitles"), and IBM Carbon's UI shell ("left-to-right translates to product-to-global"). Rejected: an "Account" subheading (a caption the copy rule refuses), a tinted region (novel styling in a shell that has none), and the identity in the drawer's own header (splits the menu across two places).
The `chrome-conventions` capability gained "The top bar holds the brand and one control" in place of "The top bar holds three things", whose 390px measurement counted a bar that no longer renders; "One shell for both surfaces", "Session controls live in the account menu on both surfaces" and "The surface switch is a visible, mirrored rail entry" were amended for the two widths. The same pass closed drift that predated the change: "One shell for both surfaces" still described a muted "Operator" label in the top bar, which design D18 had moved to the trail's root crumb in 10k and which "The top bar holds three things" already forbade. Desktop is unchanged in behaviour and geometry: rail at 240px, entry text at 32px, no account entries, no toggler.