# Issues Tracked items structured for eventual migration to Gitea issues. Resolved/closed items are archived in [archive/issues-resolved.md](archive/issues-resolved.md). ## Cross-cutting audits ### 2026-09-11 10d Slice 3 production walk — 10 findings, 4 fix-now, plus the cutover gaps Labels: `audit`, `ux`, `correctness`, `operations`, `M10` **Logged 2026-09-11** from the maintainer's first walk of the production deployment at console.wiki.cafe (10d Slice 3, gate G5). Each finding was checked against code and, where it is a count, against the production database over SSH. All ten belong to M10; the split below is the proposed triage, not yet confirmed by the maintainer. **Fix now (one OpenSpec change, launch-blocking or trivially cheap) — all four shipped 2026-09-12 as `slice3-walk-fixes` (archived), which also paged the operator FedWiki sites list the new lint rule caught:** - **~~FedWiki site count and quota gate read a counter the backfill never wrote (BUG, launch-relevant)~~ — RESOLVED 2026-09-12 (`slice3-walk-fixes`; deployed to production 2026-09-12 as image 2026-09-12T06-19Z).** The member sites card shows "1 of 64 active sites used" above a list of many more. `count` in `internal/integrations/fedwiki/web/partials.go:273` is replaced by `core.numeric_entitlement_usage.current_usage`, which only `AtomicIncrementUsage` bumps on console-driven creates; the backfill, boot adoption and farm sync insert `fedwiki.sites` rows without touching it. Production: 101 active site rows, one workspace holding 64. Consequence beyond the label: `canCreate = currentUsage < resourceLimit` (`partials.go:272`, `api.go:460`), so every backfilled member is over-quota-blind and can keep creating sites. Fix: derive usage for countable resources from `CountActiveSitesByWorkspace` under the existing per-workspace advisory lock, or have every row-inserting path maintain the counter; the first is the smaller surface. Either way the production counters need a one-time repair. - **~~People tile caption counts the system person (BUG)~~ — RESOLVED 2026-09-12 (`slice3-walk-fixes`; deployed to production 2026-09-12 as image 2026-09-12T06-19Z).** Tile says 13, caption says "14 joined in the last 30 days". `CountActivePersons` is corrected by subtracting the reserved system person (`operator_overview.go:120-133`); `CountPersonsJoinedLast30Days` (`internal/identity/queries/persons.sql:54`) is not, and the system person is created at first boot so it always lands inside the window. Fix: apply the same exclusion. Related: backfilled persons carry the import date as `created_at`, so the caption reads as "everyone joined this month" for the whole first month of a migrated deployment. - **~~Operator Domains page renders every claim with no pagination~~ — RESOLVED 2026-09-12 (`slice3-walk-fixes`; deployed to production 2026-09-12 as image 2026-09-12T06-19Z).** `GetDomainsPage` (`operator_domains.go:349`) calls `loadDomainsPageData` with no page state and the template has no list controls. `operator-list-scale` enumerates the governed lists (organizations, grants, people, four billing views) and domains was never added when Slice 2 shipped, so no spec or lint refused it. This is why exceptions survive: the convention is an enumerated list, not a property of "a table that grows with the deployment". Fix: add domains to the governed set (spec delta) and wire `operator_list_controls`; add an anatomy-lint rule that flags a body table without list controls unless allowlisted, so the next runtime list cannot ship unpaged. **Residual after `slice3-walk-fixes` (2026-09-12):** the rule checks per template, so a second table beside a governed list (Domains placements and history, the secondary tables on the grants, organizations, people and products pages) escapes it; the follow-up is a proximity rule that wants the controls or the marker within a few lines above each table. The rule did catch the operator FedWiki sites list on its first run, which is now paged too. - **~~"Multiply by purchased quantity" on rules that reach members through grants~~ — RESOLVED 2026-09-12 (`slice3-walk-fixes`; deployed to production 2026-09-12 as image 2026-09-12T06-19Z).** Label and help in `operator_entitlement_set_forms.go:224-227`. Grant quantity multiplies the limit exactly the same way (`materialize.go`, `value = base × quantity`; the backfill's grandfathering relies on it), so the copy is wrong for the grant path, not only awkward. Fix: quantity-neutral label ("Per unit") and help ("Multiplied by the quantity purchased or granted"). **Design, one exploration (products, entitlement sets, taxonomy):** the maintainer's five product-model observations are one problem seen from five screens and should be researched together before any spec, per the eradication rule. - Product creation forces a side-quest to create an entitlement set first; the maintainer cannot name a case where a set is authored in isolation from a product. Question: is a set a first-class object or a product's rules? - Product detail names its entitlement set with no way to see the rules or reach them. - Editing an entitlement set to add a boolean rule (custom domains) confused the operator; the text above the form is not enough. - Lifecycle "Published" with Public off is the state of every grant-only product; the vocabulary reads as "for sale". - `display_category` feels like dead weight; it is the residue of Doc 41 dissolving `kind`, and it has 42 code references and one badge. **Design, one exploration (domains and FedWiki):** - The custom-domain chooser is a poor experience; the maintainer suspects domains and FedWiki capabilities need to be conveyed as decoupled. Follows 10d; the registry already separates claims from placements, the UI does not. **Cutover gaps found the same day (not findings against code):** Stripe secrets absent (G2), no backfill report on record (G3), persons 14 against a planned ~26 and two sites left unplaced by adoption (G4), sync disabled (G6), Caddy on-demand ask still answered by the filesystem answerer (G7), old stack removed before soak (G8). ### 2026-09-09 security audit, second run (10l) — 5 confirmed, 2 notes Labels: `security`, `audit` **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). ### 2026-08-22 first-contact UX re-walk (10g) — 19 findings, maintainer-triaged fix-all Labels: `audit`, `ux`, `first-contact` 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). ### 2026-07-02 adversarial UX audit — 56 confirmed findings (16 high) Labels: `audit`, `ux`, `bug` 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()`). ### Deferred-remediation debt from the 2026-07-02 audit — 11 postponed items Labels: `audit`, `debt`, `tracking` 10h resolved the audit's *symptoms*; these items were deliberately postponed and each stays open until its real remedy lands. Four are **de-advertised affordances** (hidden or re-scoped instead of built — each needs either the real build or a conscious decision to drop the feature), and seven are **interim guards** whose real fixes gate on the 10i model cards for their root-cause model. The custom-domain hide is *not* debt in this sense — it is scheduled work tracked as milestone phase 10d. **De-advertised — hidden/re-scoped until built (or consciously dropped):** 1. **Trial period days** (`payments-billing`) — the price form no longer offers trial days; the value was collected, stored, and displayed but never honored anywhere in the billing chain. Real work: build trials in the payments model (Stripe `trial_period_days` through checkout, fulfillment, and entitlement timing) — or drop the column. 2. **'Replace' stacking policy** (`entitlements-materialization`) — removed from the rule form's stacking dropdown and rejected server-side; it contributed 0 to materialized limits. Real work: define replace semantics in the materialization model, or retire the enum value. **Update 2026-08-22:** authoring is now additive-only (`schema-hardening`), so no non-additive rule of any kind can be created; the semantics question moved into "Stacking policies need a design exploration". 3. **Entitlement-set Active toggle** (`entitlements-materialization`) — re-scoped to its real, enforced meaning ("Show in operator pickers"); it never affected delivery. Real work: set-retirement semantics — what happens to products selling, and pools provisioned from, a retired set. 4. **Ladder Active toggle** (`plan-transitions`) — fully hidden 2026-07-03 (edit-form toggle, list column, and topology badge removed; `UpdatePlanLadder` preserves the stored value). It was a complete no-op: inactive ladders still sold and provisioned. Real work: ladder retirement — an inactive ladder must stop selling to members and provisioning new orgs; re-surface the control only then. **Interim guards — real fixes gate on 10i model cards.** *(2026-08-21: the cards exist (`docs/models/`), so every gate below is settled — each open item now names the card that carries its root-cause model. Items 8 and 9 were already resolved 2026-07-11.)* 5. **Synchronous workflow waits** (`fedwiki-lifecycle`) — guard: a 6s wait budget then an honest "still working" response + list refresh (plus the `http.TimeoutHandler` rewrite). Real work: a pending-state lifecycle — persist in-flight site operations and render them as first-class states instead of blocking requests on Temporal. *Card gate: the provider-resource lifecycle sits adjacent to the provider-integration card (per-instance lifecycle dimension); the pending-state design is unblocked.* 6. **Entitlement lapse hides live sites** (`fedwiki-lifecycle`) — guard: transient lookup failures render a try-again notice and sites stay listed; genuine no-entitlement shows a warning above the still-listed sites. Real work: lapse wind-down UX (what members see and can do when entitlements lapse) in the lifecycle model. *Card gate: the entitlements card pins the lapse mechanics (boolean lapse-to-false, provision suspension vs end); the UX design is unblocked.* 7. **Rule deletion never re-materializes** (`entitlements-materialization`) — guard: the delete-rule confirm now states honestly that existing pools keep their current limits. Real work: re-materialization semantics for rule changes (when do existing pools re-evaluate?). *Card gate: the entitlements card pins materialization's transactional contract, and its ledger entry ("Rule changes never reach existing pools") is the same item sharpened — design the fan-out there.* 8. **Backfill vs. enrolled orgs** (`plan-transitions`) — guard: backfill is initiate-only (pools with any active plan attachment are counted but never transitioned; the NULL-default end-to-baseline path is disabled). Real work: reapply semantics — what "backfill" should mean for orgs already on a plan, including deliberate mass moves off a plan type. **2026-07-11 resolved** by `org-type-default-change-flow`: the standalone backfill dissolved into a select → preview → commit flow whose classification gives every population a decided disposition — plan-less pools initiate, outgoing-default holders are explicitly grandfathered (operator-attributed `legacy` grants, `transfer`-superseded) or migrated (end + floor-guarded re-apply), other-source positions are never touched, and clearing the default to NULL is a defined end-to-baseline commit. The interim guard comment and `SkippedActive` punt are deleted; the restoration vacancy guard is broadened to floor semantics (any live plan position blocks it) across revoke/expiry/cancellation. 9. **Non-plan grant revoke** (`product-kind`) — guard: grants whose product sits on no ladder revoke via `RevokeGrantAndRematerialize` instead of `Transition(End)` (which ended the org's *plan*). Real work: the product-kind model — route all kind discrimination through `billing.product_kinds` (see the read-discipline issue below) and give each kind its own revoke path. **2026-07-11 resolved structurally** by `doc41-conferral-uniformity` (upstream Doc 41): revocation is decree-first + `core.end_conferral` resolved by source — "whatever's on the ladder" is no longer expressible, and the interim guard and `RevokeGrantAndRematerialize` are deleted. 10. **Workspace status semantics** (`workspace-identity`) — guard: badge color maps from status and `SwitchWorkspace` refuses non-active workspaces. Real work: define the workspace lifecycle (what suspended/archived mean, who transitions them, member-facing behavior). *Card gate: the identity card pins the as-built truth (free-text status, switch guard as sole reader) and its ledger carries the unchecked-status gap; the lifecycle design is unblocked.* 11. **Member self-serve workspace creation** (`workspace-identity`) — guard: the Create Workspace affordance stays hidden behind the existing count>1 gate (hidden, not lying). Real work: decide the creation policy in the workspace-identity model, then relax the gate in `server.go`. *Card gate: the identity card pins workspace creation as-built (default-pool attachment, the swallowed-lookup trap in the pools ledger); the policy decision is unblocked.* ## Entitlements & plan transitions ### Transition primitive: multi-axis & departed-ladder gaps (transition-primitive audit, 2026-06-24) **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). ### Doc 41 rollout notes (doc41-conferral-uniformity, 2026-07-11) Labels: `entitlements`, `billing`, `upstream-membcons-db`, `ops` Three notes from implementing upstream Doc 41 (Decisions 134–139): 1. **Purchase arc is dormant.** `core.confer` implements the purchase source per Doc 41 (exclusive arc, live-uniqueness index on `purchase_id`), but member-console has no purchase-record flow — `pool_provisions.purchase_id` remains a loose slot with no purchases table and no Go caller. When one-time purchases ship, they get a caller of the same primitive, not a new pathway. 2. **Billing residue is a named upstream open question** (Doc 41 §11.2/§12): a grant superseding a subscription-held position correctly ends the position, but the subscription keeps billing for a tier the pool no longer holds. Doc 41 deliberately does not reach into billing; downstream mitigation is the issuance-form advisory warning the operator before conferring over a subscription-sourced position. Revisit when the design team logs the follow-up decision. 3. **Rollback is snapshot-based.** Migrations 00004–00006 carry mechanical Down sections (views, rename, functions, grants), but the data normalizations (reason buckets, set-direct repoint, provision `product_id` backfill) are one-way; the rollback unit for a bad window is the pre-migration snapshot plus the previous binary. The old binary cannot run against the migrated schema — its direct position-table DML fails loudly (enclosure), which is the intended fail-closed posture. ### Design gap: grants.status lifecycle vs operational delivery is undocumented Labels: `design-feedback`, `documentation`, `upstream-membcons-db` `design/entitlements/model.md` defines `grants.status ∈ {active, expired, revoked}` and `pool_provisions.status ∈ {active, suspended, ended}` independently, including the "Pool Provision Status" table at §pool_provisions that maps grant lifecycle events to provision lifecycle outcomes. What it never states is the **converse invariant**: a grant whose provision is ended by a `Transition` or `Extend` stays `grants.status='active'` indefinitely — `grants.status` only changes through explicit revocation or time-based expiry. The grants table is a ledger; operational delivery lives one layer down on the provision and ladder-attachment status fields, and the GiST exclusion constraint on `pool_provision_ladders` guarantees at-most-one active provision per `(pool, ladder)`. This silence cost real bugs in `member-console`: both the operator per-org composite and the member-facing entitlements Sources panel were filtering grants by `status='active'` and treating the result as "currently delivering," producing visible UI lies (5+ "active" Public Plan rows for a pool the schema guarantees has exactly one active provision). Fixed downstream by adding `ListGrantsWithDeliveryByOrgID` (LEFT JOIN with derived `delivery_state`) and `ListDeliveringGrantsByOrgID` (INNER JOIN on active provisions), and documenting the rule in `docs/operator-ux-conventions.md` §9a. Upstream ask: add a short paragraph to `design/entitlements/model.md` §grants (or §pool_provisions) explicitly naming the lifecycle-vs-delivery split — something like *"`grants.status` reflects only formal lifecycle events (revoke / expire). Asking 'which grants currently deliver entitlements to a pool?' requires joining to `pool_provisions` with `status='active'`, not filtering on `grants.status`. A grant remains `active` after its provision is ended by `Transition` or `Extend`; this is intentional and preserves the audit trail."* The companion spec (`openspec/specs/entitlements/spec.md` §Grants) likewise has nothing on this invariant and would benefit from a one-paragraph requirement codifying it. **2026-07-11 update:** the downstream half closes when `doc41-conferral-uniformity` archives — its `entitlements` delta codifies grants-as-ledger (delivery answered from provisions) as a spec requirement. The upstream half is committed by Doc 41 §7.3 (documentation sync incl. `extends_grant_id` and the status-vocabulary correction); keep this open until the `model.md` paragraph actually lands in the design repo. ### Per-org disposition overrides in default-change / reorder / removal previews Labels: `enhancement`, `plan-transitions`, `operator-ux` The consequence previews for org-type default changes, tier reorder, and tier removal all apply one disposition to the whole outgoing-default population: every affected org is either grandfathered/kept or migrated. Mixed dispositions — grandfather some orgs, migrate the rest — were deferred from `org-type-default-change-flow` (archived 2026-07-12, see its Open Questions). If wanted later: the commit payload grows a per-org override list and the preview grows per-row controls (the count-first disclosure lists already render one row per org), with no change to the enactment shapes — `enactOrgDisposition` already operates per org. Deferred 2026-07-11; promoted here from the archived change so it stays findable. ### MaterializePoolEntitlements never lowers a key's limit when its last active rule goes Labels: `bug`, `entitlements` **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. ### Pending recompute obligations carry no visible age Labels: `entitlements`, `operator-ux` **Logged 2026-09-15** from the `entitlement-set-changes` design's risks. The set page's transient line `3 pools not recomputed.` and the History cell `412 of 4,812 recomputed.` count pending obligations without saying how long they have been pending, so a drain that finished a second ago and one that stalled at the last deploy read the same until attempts are exhausted and the failed line appears. The data exists: `core.entitlement_set_change_obligations.created_at` is the age (doc-47 §3.2, recorded in the companion under the resolved Issue 37). A follow-up can carry the oldest pending row's age on the transient line or in the History cell without a new column; whether that is a fact the operator needs on the page or a number for the log is the open question. ### Reduction policies from several sets on one pool: the sweep folds them, nothing else does, and the preview can contradict the sweep Labels: `entitlements`, `correctness`, `design-gap` **Logged 2026-09-18** (maintainer, during the rule-change UX rethink: "what do we do when reduction policies get combined? ... one entitlement set says one thing and then another says another thing and they both are applied to one pool"). Half considered. The only reader that combines is `GetGoverningReductionPolicy` (`internal/entitlements/queries/entitlement_set_rules.sql:22-39`): over the active limit rules that fund a pool's key through its active provisions, `force_reduce` wins, then `clamp`, else `MIN()` of the rest, so `block` beats `defer`. Its sole caller is the FedWiki quota sweep (`internal/integrations/fedwiki/workflows/reconcile.go:133`). The fold is stated in the query's comment and nowhere else: `fedwiki-sites` (`:57`) and `plan-downgrade` (`:69,90`) say "the governing rule" in the singular, and the archived `entitlement-set-changes` design (A14, A18) says "the sweep reads the rule's policy" without naming what happens with two rules. Three consequences. (1) The rule-change preview discloses the **proposed rule's** policy alone (`internal/entitlements/rule_change.go:321-323`, `consequenceFor` at `:408`), so for a pool funded by two sets it can say "The limit applies now. Usage above it is kept." while the sweep, reading the fold, parks the excess because the other set says `force_reduce`. (2) The effects ledger stamps the **changed rule's** policy on each effect row (design.md:194, Decision 147), not the policy that governed the pool, so History records a decision the sweep may not have taken. (3) Today only `force_reduce` changes behaviour: `block` and `defer` have "no boundary at a rule commit" (`entitlement-set-management` spec `:268`) and the console has behaved as `clamp` on downgrades since v14 (design A18, doc-47 §13 item 13), so the four-option select offers two choices that do the same thing as `clamp`. **Decided 2026-09-18 (maintainer): strongest wins**, the fold the current query already computes, chosen over the alternative (the primary provision's rule governs), because a stronger promise to the member's provider cannot be weakened by a second product they also hold. What follows, owed by the rule-change redesign change, which rebuilds the preview anyway: a spec requirement stating the fold; the preview's disclosure and consequence sentence computed per pool from the governing policy; the effect row stamping the governing policy beside the rule's; and the control offering only the values that do something until `block` and `defer` are wired. The as-built definition of the four values, their strength and the fold now lives in `docs/models/entitlements.md`, "Reduction policy"; the combination rule is drift to raise upstream as a `membcons-db` Stage-1 issue once the console's spec states it. **Built 2026-09-18** by `staged-rule-changes`: the fold in Go (`governingPolicy`, `internal/entitlements/materialize.go`) and the query state the same explicit order and share a test; every effect row carries `governing_policy` beside the rule's own (migration 19); the tray offers only Clamp and Force reduce, keeping a stored `block` or `defer` as its own option. The per-pool disclosure did not return to the surface (the maintainer removed the per-change summaries); the batch dry run carries the governing policy for the review-details link a later change adds. Open here: the upstream Stage-1 issue. ## Billing, Stripe & purchasability **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. ### Checkout is headed by a business name the console never introduces Labels: `billing`, `ux`, `operations`, `M10` **Filed 2026-09-20**, launch-relevant. The Subscribe control hands off to Stripe Checkout with a bare redirect (`BillingCheckoutHandler`, `internal/server/billing.go:185`), and the Checkout page is headed by the Stripe account's public business name, which Stripe fills from the account's legal name until the operator sets one. When that legal name differs from the deployment's brand, the first time a member reads it is above the card fields on a stripe.com page, with nothing on the console's own domain having said who charges the card. Both UX walkers reported the surprise independently against the test stack's sandbox account (M5 member-purchase walk, 2026-08; M1 first-boot walk, 2026-09). Two layers, deployment first: - Stripe Dashboard: set the public business name, icon and statement descriptor (Settings, Business details, Public details; Branding) on the live account and the sandbox. Nothing in the console tells the operator this matters or shows what Checkout will display. - Console: the Stripe integration page (`operator_integration_stripe.html`) and the setup readiness check read the account back (`stripe.Account.Get`: `business_profile.name`, `settings.branding`, the statement descriptor) and show the name members will see, flagging an unset one the way the environment check flags a stale price; the member billing page states the merchant of record from the same read before the hand-off (one fact, "Charged by through Stripe"), so the name is on the console's domain before it is on Stripe's. ### Product retirement and Stripe-mapping visibility in operator UI Labels: `design-feedback`, `billing`, `ux` The operator product UI today has no archive/delete affordance — products that go out of fashion accumulate. Two facts shape what the right answer is: 1. Local products are 1:1 mapped to Stripe products via `stripe.product_mappings(product_id, stripe_product_id, sync_status)`. Deleting a local product silently breaks that mapping; even if the operator is OK with the local row going away, the Stripe-side product (which Stripe never deletes — it archives) and the mapping row need a coherent story. 2. M6a already plans a `lifecycle_status` column on `billing.products` (`draft` / `published` / `retired`). That is the right primitive: published products are sellable; retired products cannot be granted to new orgs but existing grants survive; draft products are operator-visible only. What 7b/7c should do: - Surface the Stripe mapping in the product UI — operators need to see which local product maps to which Stripe product, and the sync status of that mapping. Today this relationship is invisible from the panel. - Replace any future "delete product" affordance with a "retire product" action that flips `lifecycle_status` to `retired` (gated on whether any active grants exist; prevent retire if so, or offer a clear cascade preview). - Same logic applies to entitlement sets that are referenced by products and to plan ladders that have orgs enrolled. Discovered during M7 phase 7a (2026-05-08). ### Multi-currency money on the operator overview: largest bucket shown, no conversion Labels: `billing`, `operator-ui`, `enhancement` The overview's Monthly recurring headline and Open invoices outstanding caption (`overview-money-and-teams`, 2026-07-27) sum money per currency and display only the largest currency bucket, acknowledging the rest in the caption ("plus N more currencies"). That is honest but incomplete for a deployment that genuinely bills in several currencies: the headline understates total recurring value. Doing better means converting, and converting needs infrastructure this codebase deliberately doesn't have yet: a rate source (ECB feed? manual operator-entered rates?), a staleness policy for those rates, and a display convention that doesn't present converted sums as exact ("~$3,100 equivalent"). Stripe-side reporting solves this with Stripe's own FX data — a `stripe` provider surface might be the cheaper path than a core rate table. Single-currency deployments (the expected norm) are unaffected either way. Filed 2026-07-27 while replacing the subscriptions count tile with monthly-normalized recurring money. ### The environment check reads back products and prices, not customers Labels: `billing`, `enhancement`, `low-priority` **Filed 2026-09-20** by `stripe-environment-stamp` (design D3). The check that runs after an API key change asks Stripe for every synced product and price under the current key and marks the ones it cannot find `stale`. Customer mappings are not read back. A customer mapping that records the other environment is caught without a read (the recorded `livemode` disagrees with the key, and checkout creates the customer again), but a move between two sandboxes leaves both worlds reporting test, so a customer id from the old sandbox stays `synced` until that member's next checkout fails on `resource_missing` and the console creates the customer again. Extending the check to `stripe.customer_mappings` is one more loop over one more table in the check activity (`internal/integrations/stripe/workflows`), once the two-table run has been observed in production. ### Stripe environment stamp rollout notes (stripe-environment-stamp, 2026-09-20) Labels: `billing`, `ops` Two notes for the first wiki.cafe deploy after this change (design D8): 1. **The migrations add columns and no data.** Store migration `00004_mapping_environment.sql` adds nullable `livemode` to the eight `stripe.*_mappings` tables and `verified_at` to product and price mappings; core migration `00020_webhook_event_environment.sql` adds nullable `provider_environment` to `core.webhook_events`. Every existing row stays NULL, which reads as unverified and blocks nothing: readiness, checkout, reconcile and the billing views treat a NULL row as the key's own. 2. **The first boot runs the check by itself.** Boot finds no `stripe.environment_check` record in `core.instance_settings`, starts `StripeEnvironmentCheckWorkflow`, and the check reads every synced product and price back under the live key, one GET each, a few dozen in total. They come back `Synced, live` and verified with no operator step; the Stripe integration page's Environment check section shows the result (`36 checked, 0 stale.`). If Temporal is unreachable at boot the start is logged and the section offers **Check now** to run it by hand. The projection rows (customers, subscriptions, invoices, payments) stay NULL until their next webhook rewrites them. ## FedWiki integration ### Member-console has no UI to manage sites it doesn't own (operator panel read-only; ownerless sites unmanageable) Labels: `enhancement`, `fedwiki`, `operator-ux`, `member-ux`, `affordance-gap` **Noticed 2026-06-23**, while resolving the orphaned-farm-sites issue (the "B1 holding workspace" fix below). Every site-mutation surface today is **member-side and ownership-scoped**: `archive` / `restore` / `keep-active` / hard-`DELETE` (`internal/server/fedwiki_partials.go`) all require `site.WorkspaceID == session.WorkspaceID`. The operator FedWiki Sites page (`/operator/fedwiki-sites`, `operator_pages.go:100`) is **read-only by M8c design** — a table over `ListAllSites` with external links and no forms. Consequences: - **Operators cannot act on any site** — not their own org's, not a member's, not an ownerless one. No archive, restore, purge, or reassign from the operator surface. - **Ownerless sites are unmanageable from the UI entirely.** Sites parked in the System / "Orphan Reconcile" holding workspace (farm sites with no member owner — e.g. `admin.localtest.me`, the farm root, leftover test sites) have no logged-in member, so the member-side handlers' ownership check can never match, and the operator page can't mutate. Freeing such a name still requires a direct FarmManager hard-purge, out-of-band. - **No "claim" / "reassign owner" path** to move an orphaned or ownerless site into a real member's workspace. **Fix direction:** give the operator FedWiki surface (or a dedicated admin tool) write affordances — at minimum purge (`?hard=true`) and archive/restore — guarded by operator role, plus a "claim/reassign to workspace" action for ownerless sites. This is the operator-side counterpart to the member lifecycle controls shipped by `fedwiki-lifecycle-states`, and the residual half of the orphaned-farm-sites issue (whose config/visibility half is now resolved). Pairs with the read-only→writable operator-panel work and the per-item MPA pages already tracked. ### Replace FedWiki sync polling with push-based (webhook) reconciliation Labels: `enhancement`, `fedwiki`, `integration`, `real-time` **Noticed 2026-06-23** while testing the `fedwiki-lifecycle-states` downgrade flow. The FedWiki state projection and the `force_reduce` quota reconcile both ride the periodic **sync** workflow (`SyncFedWikiSitesWorkflow`, default `fedwiki-sync-interval`). That makes enforcement *eventually* consistent — a downgrade's read-only parking, an out-of-band farm status change, and the retention purge all wait for the next tick. For testing we drop the interval to `1m`, but the real fix is to stop polling and react to **farm change events**. This is gated on the FarmManager **change-webhooks** capability, the fourth of the six capability requests sent upstream in 2026-06 (reversible read-only mode, explicit reversible lifecycle transitions, per-site storage usage, change webhooks, custom domain attach/detach, and rename or owner reassignment in place); `wiki-plugin-farmmanager` v0.4.1 shipped the first three and has not shipped webhooks. When it lands: FarmManager POSTs site created/status-changed/deleted (and usage-threshold) events to `integration.webhook_events` (the inbound table already exists and is idempotent), a processor projects them immediately, and the periodic sync degrades to a slow safety-net reconcile rather than the primary path. Also revisit the related deferred refinement noted on the resolved force_reduce issue: an *immediate* per-trigger reconcile from the downgrade/upgrade paths, independent of the sync tick. ### FedWiki HTTPS farm is a cross-stack singleton — needs a per-stack domain for true parallel isolation Labels: `test-infra`, `fedwiki`, `https`, `enhancement`, `low-urgency` **Discovered 2026-06-22** while adding the Caddy TLS layer for the `wiki-security-social` migration. Browser access to FedWiki farm sites now goes through a Caddy TLS proxy (`wiki-security-social`/better-auth sets Secure cookies, which browsers only store over HTTPS). The proxy binds host port `443`, which is fixed rather than slot-allocated — so only one worktree stack can serve browser login at a time. A second stack's `caddy` container fails to bind `443` (the rest of that stack's services come up fine; only browser login is down). **Why a slot-allocated HTTPS port isn't enough:** better-auth scopes its cookies to the shared `*.localtest.me` farm domain. Cookies are per-domain, not per-port (RFC 6265 — see the cookie note in `test/bootstrap-stack.sh` / `.env`), so two stacks on `*.localtest.me` clobber each other's session cookie jar regardless of port. The HTTP path is unaffected: member-console's farm API uses a bearer token over the slot-allocated `FEDWIKI_PORT` (no cookies), so provisioning stays isolated; only the browser/login surface is shared. **Fix direction:** give each stack its own farm domain, e.g. `*..localtest.me` (slug derived from `COMPOSE_PROJECT_NAME`/slot). Touches: - `test/bootstrap-stack.sh` — derive + export the per-stack farm domain (and a slot-allocated `FEDWIKI_HTTPS_PORT`, which becomes safe once the domain isolates cookies). - `test/seed/caddy/Caddyfile` — templated site address instead of the hardcoded `*.localtest.me`. - `test/seed/fedwiki/config.json.tpl` — `wikiDomains` key + the rendered admin/owner entries. - member-console — `fedwiki-allowed-domains`, `fedwiki-site-scheme`, and `buildSiteURL` (`internal/server/fedwiki.go` + `fedwiki_partials.go`), which currently emits no port. - Keycloak `fedwiki` client — already `redirectUris: ["*"]`, so likely no change. Caddy `tls internal` can mint per-stack wildcard certs, so the cert side is free. **Severity:** low, and lower since 2026-08-01. `test-stack-integration-profiles` put the fedwiki chain and Caddy behind a `fedwiki` compose profile that is **off by default**, so the collision surface shrank from "every stack" to "stacks that opt into the profile" — a default `docker compose up -d` now binds nothing on `443`. The underlying singleton is unchanged: two stacks that both select `fedwiki` still contend, and the shared `*.localtest.me` cookie jar still defeats a slot-allocated port. Single-stack dev and CI are unaffected; this only bites when two worktrees need FedWiki **browser login** simultaneously. Documented as a known singleton in `test/AGENTS.md` and `test/README.md`. **Scope:** test-infra only; no app-behaviour change beyond `buildSiteURL` learning an optional port. Natural home: whenever parallel-worktree FedWiki UI testing becomes a real need. ### FedWiki Sites operator tab empty under full seed Labels: `bug`, `fedwiki`, `seed` 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. ### FedWiki sync does not populate DB from existing disk sites Labels: `bug`, `fedwiki` 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. ## Domains registry ### Operator Domains page: Placements and History tables render every row, unpaged Labels: `operator-ui`, `domains`, `operations`, `M10` **Filed 2026-09-20.** It was only a residual inside the resolved "renders every claim with no pagination" bullet of the 2026-09-11 walk, in no roadmap or milestone. `slice3-walk-fixes` paged the live-claims table and left the other two tables on the page unpaged as a non-goal (D4, `operator_domains.go:115`): `loadDomainsPageData` calls `ListAllPlacements` and renders the full slice, and History does the same. Served names outnumber live claims on any deployment that places more than one name per claim, so the Placements section is the longest table on the page and the only one without controls; when the claims table fits one page the whole page reads as unpaged. The `table-without-list-controls` lint (`internal/lint/anatomy.go:366`) checks per template, so a second table beside a governed list never fires. Fix: page both tables with `ParseListParamsNS` prefixes (the collision-free design D4 already reserved), a search over served name and organization for Placements, and the true total in each section header; then the proximity lint rule from the walk residual (controls or the marker within a few lines above each table) so the secondary tables on the grants, organizations, people and products pages get caught too. ### A released hosted name is instantly re-claimable — no tombstone Labels: `enhancement`, `domains`, `security`, `design-gap` **Noticed 2026-07-25**, reviewing what the abandonment ledger (`claim-lifecycle-hardening`, `c85ac6a`) does *not* cover. The ledger meters names that are **abandoned before proving control**. It says nothing about names released *after* being verified and used. Releasing a claim requires zero placements (`ErrClaimHasPlacements`), so by the time a name is free its site is genuinely gone and no content is exposed — but everything *pointing at* the name survives: inbound links, other wikis' federation neighborhoods and page references, bookmarks, search results. The next holder inherits all of it, and the console will mint a TLS certificate for them at exactly that name, by design. That is impersonation and traffic inheritance, not content disclosure. **Scope is narrower than it first looks: this is a `member`-claim problem only.** For `external` claims, re-claiming requires proving zone control by TXT, so DNS itself is the tombstone — an attacker cannot take `example.org` from its owner by claiming it here. The exposure is carved names under an operator shared root (`alice.`), where the registry is the only authority and the operator's DNS answers for every name uniformly. **Fix direction:** a quarantine on release — a `member` claim that ever carried a servable placement stays unclaimable for N days after `released`, by anyone (including its previous holder, so it cannot be used to dodge the quarantine). Terminal claim rows are already retained with their status and timestamps, so the data exists; this is a predicate in `evaluateWith`'s occupancy branch plus a policy knob (`domains-release-quarantine`, default off or short), not new schema. Decide deliberately whether the previous holder is exempt — re-claiming your own just-released name is a plausible mistake-recovery path, and exempting them costs nothing because they already held it. **Related, not the same:** the pending-hold question (should an *unverified* claim reserve its whole subtree, or only its exact root?) is tracked separately — see the domains section of `status/model-card-notes-domains.md` and the non-goals of `openspec/changes/archive/2026-07-25-claim-lifecycle-hardening/design.md`. ### Subtree exclusivity ignores DNS delegation for bring-your-own domains Labels: `enhancement`, `domains`, `design-gap` **Noticed 2026-07-25**, from a maintainer question: if I hold `fruits.example.org` and my friend holds the delegated subzone `apricot.fruits.example.org`, why can't we each claim ours? A live claim owns its whole subtree exclusively, and the rule is applied uniformly to both claim kinds (`domains-registry` spec, "Claims are disjoint DNS subtrees with a single live owner"). For **carved member claims under an operator shared root** that is correct and load-bearing: members hold no DNS authority there — the operator answers for every name — so the registry is the only thing that can make "your subtree is yours" true. For **external (BYO) claims it is arguably over-restrictive**, because DNS already arbitrates and does it better. Control of a name is proven by publishing TXT in its zone; a delegated subzone holder can prove control of `apricot.fruits.example.org` precisely because the parent delegated it, and the parent can revoke that at the nameserver whenever they like. The hierarchy is self-enforcing. Today the console refuses the child's claim purely because the parent claimed the parent first — with a generic "unavailable" that tells them nothing — even though both parties can independently prove exactly what they hold. **Fix direction:** allow an `external` claim to nest inside another live `external` claim when the claimant proves control of the nested name, keeping exclusivity absolute for `member` claims under operator roots. Placement binding already resolves to the *deepest* live claim containing a name, so serving needs no change; what changes is the disjointness predicate (kind-aware) and the activation-time re-check when a parent verifies after a child already has. Weigh against the simplicity of one uniform rule — and note this is the same invariant the pending-hold narrowing question would disturb, so the two should be designed together if either is taken up. ### A refused ask leaves no trace of the name it refused Labels: `operations`, `domains` **Logged 2026-09-14** during the G8 soak. With Caddy's on-demand ask pointed at the console, the request log shows about one refused ask per second in bursts of two hundred a minute (5,299 asks in 90 minutes at 01:40 to 03:10 UTC, every one 404, p99 under 3 ms), which is the internet scanning a wildcard DNS name and was invisible while the filesystem answerer took it. The console's `request completed` line records the path and never the query string, the authorizer logs a refusal only at debug, and the fallback answerer logs nothing, so nothing on the host can say which names are asked without a packet capture. Twice tonight that was the question (is it a scan, or one stuck client retrying a deleted site?). Position: not a per-request Info log (a line per second of noise), but a rolling per-name counter the Domains page shows as "asks refused in the last hour" with the top names, and the same counter behind a log line once a minute when nonzero. Cheap, and it doubles as the signal for a member whose site is asked for before its placement exists. ## Operator panel — UX, IA & accessibility ### Low-contrast state text escaped the accessibility baseline because the demo fixture never renders that state Labels: `a11y`, `frontend`, `ui-quality-gate` **Logged 2026-09-12** from the maintainer's product walk on production. The Purchasability panel's "Not shown" catalog-visibility state rendered in `text-warning` (`#ffc107` on white, 1.63:1 against the 4.5:1 AA floor for small text); `operator_product_name_warning.html` used the same class. Both switched to `text-warning-emphasis` (7.99:1) the same day, uncommitted. Why the gate missed it: the Lighthouse baseline audits the rendered DOM of one fixture state per screen, and the demo product is on a ladder, so the panel showed "Shown" and the "Not shown" branch never existed in any capture (`docs/operator-a11y-baseline.md` records product detail at 100 with no failed audits). The 2026-09-01 shell fix removed the last `color-contrast` cells, so nothing in the ledger pointed at state branches. Two remedies, both open: (1) a fixture variant for an off-ladder product so the readiness panel's third state is captured and audited; (2) an anatomy-lint rule that refuses the low-contrast text utilities (`text-warning`, `text-info`, and `text-success` at 4.53:1 is marginal) as text colour outside badges, so the class cannot be typed again; only the `-emphasis` variants pass on the page ground. `text-danger` uses (nine) sit at 4.53:1, passing by 0.03. ### Purchasability says "Ready to grant" for an inactive private product that the grant form will not offer Labels: `bug`, `frontend`, `correctness` **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. ### 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 Labels: `design`, `billing`, `operations` **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. ### The add-rule form's Per unit checkbox is ignored; every rule authored through the form stores per_unit = false **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. ### Org-type default change against a rule-less set materialized every personal org's site limit to 0 (production incident, 2026-09-12 18:51 UTC) **Logged 2026-09-12.** At 18:51:51 UTC the Personal org type's default ladder was set to Wiki Cafe Plans with the migrate disposition (ledger: 13 `end` transitions "org-type default change (personal): migrated off the outgoing default", 13 `initiate`). The handler (`operator_org_types.go`) ended the 13 default-sourced Transitional provisional grant provisions (the grants themselves stay `active` in the grants ledger), conferred Wiki Cafe Public (rank 0) as a grant-sourced provision on each pool and materialized. Wiki Cafe Public's entitlement set received its `fedwiki_sites` limit-1 rule 64 seconds later (18:52:55), and creating a rule never re-materializes the pools that carry the set (`ruleResourceKeyIsLiveBacking`'s comment states this for deletion; it holds for create and edit too). State as verified over `psql`: 13 pools with `fedwiki_sites` limit 0 and usage 1 to 64 (nine at 1, then 2, 12, 14, 64). `AtomicIncrementUsage` requires `current_usage < resource_limit`, so no personal org can create a site until its pool is re-materialized. Repair without code: conferring a grant re-materializes the pool (`conferral.go`), so issuing "Legacy Grant - FedWiki 64 pack" to each of the 13 orgs yields 1 + 64 = 65 per pool and is the grandfathering the plan's open question 2 asked about; the default-change preview offered the grandfather disposition for exactly this case and migrate was chosen. Gaps: (1) the default-change and tier add/remove previews confer a rank-0 product whose set has no active rules without a word; refuse or warn. (2) Creating, editing or deleting a rule on a set that live provisions carry must re-materialize those pools, or the page must state that the change does not reach them (ties to "Should entitlement sets be immutable?"). (3) No operator action recomputes a pool's entitlements. (4) The organization composite and the FedWiki operator page show usage without the materialized limit, so a 0 limit is invisible until a member's site creation fails. Fix-now candidate: (2) or (3); (1) and (4) for the rethink. Repaired by the maintainer at 19:59 UTC without code: the default was removed (ending every default provision) and set again to Wiki Cafe Plans, so the 13 pools were re-materialized with the rule present. Verified: limit 1 on all 13; nine orgs at 1 of 1, four over it (2, 12, 14 and 64 sites). Those four still need the 64-pack grant (or whatever the legacy decision is) before they can create anything; the nine are at their limit. **Gaps 1, 2 and 3 resolved 2026-09-15 by the `entitlement-set-changes` change.** (1) Decision 148 puts `active_rule_count` on `core.product_shape`, and the org-type default change preview, the tier add and removal forms, the tier reorder message and Issue grant all read it and render ` provides nothing: its entitlement set has no active rule.` with the set linked; the commit stays enabled, because the maintainer's pick (M1) is warn everywhere and refuse nowhere. (2) Decisions 143 and 145: `core.commit_rule_change` is the only write path to `core.entitlement_set_rules` and writes the rule, its act row and one obligation per carrying pool in one transaction; at or below the measured cap of 250 pools the commit materializes every carrying pool in that transaction, above it the drain settles the rest within seconds, and every commit is preceded by a dry-run preview of the pools it reaches. (3) Decision 145 writes no recompute act and the console offers no recompute control: the drain is automatic and the one residual control, `Retry`, only requeues obligations whose attempts are exhausted. Gap 4 stays open. ### Free rank-0 rung reads "Included / Subscribe / Not available for purchase yet" on the member catalog **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. ### "Public" names the catalog flag badly; "Listed" says what it does **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. ### Per-unit multiplicability may belong on the product, not on each rule (thought to retrospect) **Logged 2026-09-12** (maintainer: "Maybe that multiplicability ought to be in the product instead of the entitlement set... we may have to go back at some point and retrospectively investigate on our previous conversations every time this concept has surfaced"). Not a design; a retrospective comes first. Seed list (grep for per-unit, `resource_per_unit`, multiply by quantity): archived changes `2026-03-25-grant-based-fedwiki-access` (first appearance, design and entitlements delta), `2026-03-26-entitlement-sets`, `2026-07-11-doc41-conferral-uniformity`, `2026-07-21-entitlement-rule-authoring`, `2026-09-03-acceptance-fixes`, `2026-09-06-dense-widths`, `2026-09-12-slice3-walk-fixes`; explorations `ux-walks-2026-08`, `ux-walks-2026-09` (four files), `forms-audit-2026-09` (FA-23); two `status/log` entries; two `status/archive` files. The retrospective must weigh that since 2026-07-21 the form has silently stored false (entry above), so part of the friction observed with the concept was the bug, not the concept. ### The member Create site button hand-rolls its disabled state, so nothing tells the person it is disabled or why **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 `