Files
member-console/status/issues.md
T
cgalo5758 8e3c68c6be Make UI surfaces honestly reflect system state
- Add deployment-name branding to titles, mastheads, and OG tags
- Share one grant delivery-state query with lineage across grants
  surfaces
- Show pool status/usage, org owners, and config readiness
- Make billing views projection-aware with recency and sync vocabulary
- Guard FedWiki creation without domains and render route-aware 404s
2026-08-23 01:45:52 -05:00

722 lines
114 KiB
Markdown
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
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-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](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. Synthesis: [ux-walks-2026-08/findings-2026-08.md](ux-walks-2026-08/findings-2026-08.md)
— 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).
### Entity slugs should not exist — survey and eradicate
Labels: `design`, `schema`, `ux`, `10g`
**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-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.
Full findings: [audit-2026-07-02-operator-ux.md](audit-2026-07-02-operator-ux.md). 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 the [triage](audit-triage-2026-07-02-triage.md): 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 134139):
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 0000400006 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.
## Billing, Stripe & purchasability
### 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.
## 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 4th wishlist item in `fedwiki-farmmanager-capability-wishlist.md`, not yet shipped). 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. `*.<slug>.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
### 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.<shared-domain>`), 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.
## Operator panel — UX, IA & accessibility
### Residual SPA patterns: products and entitlement sets lack per-item pages
Labels: `tech-debt`, `operator-ux`, `ia`, `mpa`
**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.
### Operator URL/route/code naming drift
Labels: `bug`, `frontend`, `dx`
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.
### Operator product edit: `lifecycle_status` not exposed
Labels: `bug`, `frontend`
The schema added `lifecycle_status` (`draft`/`published`/`retired`) per M6a, and 7a.1 explicitly called out that retire-vs-delete is the right archive primitive. The product edit form (`/partials/operator/products/{id}/edit`) shows an `Active` checkbox but no lifecycle-state transitions. Operators cannot retire a product through the UI today. Discovered in Phase A v2. Candidate for M7-7b/7c (covered by existing "Product retirement and Stripe-mapping visibility in operator UI" issue — leave that as the umbrella).
### Operator grant-issuance: two non-overlapping surfaces with no cross-link
Labels: `bug`, `frontend`, `ia`
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.
### Two grant-revoke paths are non-equivalent and indistinguishable in UI
Labels: `bug`, `frontend`, `correctness`
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. Already flagged in `docs/operator-ux-research.md` 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.
### Operator plans surfaces — consolidate Plan Ladders + Plan Topology, reconcile grid orientation (very low priority, no milestone)
Labels: `enhancement`, `frontend`, `ux`, `low-priority`, `deferred`
Three connected, deliberately-unscheduled refinements to the operator *plans* surfaces, noted 2026-05-30 after shipping `operator-topology-legibility`. **Very low priority — intentionally NOT added to `status/milestones.md`; captured here only so the ideas survive.**
1. **Consolidate Plan Ladders + Plan Topology into one surface.** They are two views of one concept — ladder/tier *configuration* (`/operator/plan-ladders`) vs. the cross-ladder *read* overview (`/operator/plan-topology`) — and may read better seen together than as two sibling Catalog nav entries, e.g. a single "Plans" / "Plans Management" page. Possibly nest the combined surface **under Products** as a child of that nav link rather than as top-level Catalog entries. Would touch the `operator-panel-navigation` IA contract.
2. **Possibly transpose the topology grid axes.** Today ladders are columns and ranks are rows; may prefer **ladders as rows, ranks as columns** — likely reads better at the realistic shape (many ladders, few ranks) and avoids the very-wide grid seen during verification.
3. **Reconcile rank-order direction across the two surfaces.** The operator "Manage Tiers" page (Plan Ladders → a ladder → Tiers) lists **rank 0 at the top**, increasing downward. The Plan Topology grid puts **rank 0 at the bottom** (highest rank on top — the member-catalog mirror chosen in the `operator-topology-legibility` design). These disagree; pick one convention and apply it consistently. (Rank-0-bottom was a resolved open question in the topology design; the inconsistency with Manage Tiers is the new observation.)
### Operator SPA partial eager-fetch
Labels: `tech-debt`, `frontend`, `perf`
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 by network capture in `docs/operator-ux-walkthrough-evidence/landing/network.json`. The architectural cause is what M7-7d (SPA → MPA conversion) is here to retire. Tracked here so it survives the M7 phase scoping. Already noted in `docs/operator-ux-research.md`. **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.
## Integration architecture
### Per-stream migration ledgers: drop the ordered-source version namespace
Labels: `architecture`, `integration`, `migrations`, `decided`
**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.
### Provider dispatch transport: Temporal vs `integration.outbox` — unify or coexist?
Labels: `architecture`, `integration`, `providers`, `decided`
**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.
### payments-provider-seam (follow-up to integration-extraction)
Labels: `architecture`, `integration`, `needs-design-decision`
**Surfaced 2026-07-05** during `openspec/changes/integration-extraction`
(Decision 6) — Stripe extracts only partially; the payments seam is
carried forward as its own follow-up. `internal/fulfillment` is
Stripe-coupled despite its core-sounding name (all non-test files import
`stripe-go`), and `internal/server/billing.go` (checkout),
`operator_billing.go` (catalog sync + the Stripe action-type outbox
vocabulary), `product_readiness.go`, and `member_products.go` all call
`stripe-go` directly from core code, alongside `internal/stripetest`. These
files encode "core billing UX consumes a payments provider" without the
provider abstraction existing yet. Extracting them requires designing a
payments-provider port against the billing model first — bundling it into
the mechanical `integration-extraction` move would have coupled that move
to an open-ended design decision.
### Integration architecture (and operator IA placement)
Labels: `design-feedback`, `architecture`, `ux`
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.
### Out-of-tree integration distribution — explicit non-goal
Labels: `architecture`
Integrations are first-party and in-tree: the contract surface lives under `internal/`, which the Go toolchain restricts to this module, and composition is compile-time via `internal/integrations/registry.go` — an external repository cannot import the interfaces today, and Go offers no viable dynamic-plugin path. Supporting out-of-tree integrations (separate modules compiled in by a builder tool, Caddy/xcaddy-style) would require publishing a semver-stable public contract API plus a conformance suite. Recorded 2026-07-06 as a deliberate non-goal, not an oversight — the in-tree model (branch/fork, add tree + registry line, PR) is documented in `docs/building-an-integration.md` § Distribution model. Revisit only if a concrete third party wants to build an integration that cannot live in-tree.
### Core domain events have no fan-out to interested integrations (9e contract finding)
Labels: `architecture`, `integration`, `contract-evolution`
The 9d contract's outbox is caller-targeted (`integration.Enqueue(providerSlug, ...)`) and core handlers writing `org_members`/grants don't know which integrations care — so an integration whose delivery *derives from* core state (Discourse: group membership from entitlement × org-membership) has no transport for change-triggered nudges. FedWiki never hit this because provisioning flows *through* its own workflows. The Discourse integration ships correct without it (level-triggered sweep + provider webhooks bound staleness to one sweep interval), but entitlement-driven changes wait for the sweep when webhooks are quiet. Proposed evolution: manifest-declared core-event subscriptions (integration declares event types; core emits through a generic seam at its commit points). Recorded 2026-07-17 during `discourse-integration` (design.md D3, findings #3). **Upstream 2026-08-21:** ratified as a contract-cannot-express gap by the Doc-39 conformance check ([doc39-conformance-2026-08-21.md](doc39-conformance-2026-08-21.md)); now tracked upstream as **Issue 32** (`design/companion.md`), with candidate evolutions and advance triggers recorded in `design/documents/doc-44-provider-contract-ratification.md`.
### Manifest cannot express set-shaped (converged) delivery (9e contract finding)
Labels: `design-feedback`, `integration`, `contract-evolution`
The provider manifest's verb set (`create`/`set_status`/`delete` + read class) models operator-driven per-instance lifecycles. Discourse's delivery is a *converged set* — membership reconciles automatically; there is no operator-invoked mutating verb. Declaring only `list`/`describe` validates and registers cleanly, so the contract *permits* the shape, but nothing in the registry can say "this provider reconciles" — the operator Integrations surface shows a provider with no mutating operations and no way to tell that's by design. Candidate: an operation class (or manifest flag) for converged delivery. Recorded 2026-07-17 (`discourse-integration` findings #2). **Upstream 2026-08-21:** ratified as a contract-cannot-express gap by the Doc-39 conformance check; now tracked upstream as **Issue 33** (`design/companion.md`, co-traveler of Issue 32 per `doc-44-provider-contract-ratification.md`).
### `member-console lint` never scans integration-registered routes
Labels: `tooling`, `testing`
The lint's route scanner covers `internal/server` registrations only ("66 routes" with or without the Discourse tree), so mutation routes registered through the contract's `RegisterRoutes` hook silently escape `walkthrough-coverage` and `raw-error-render`. FedWiki never exposed this (read-only operator page); Discourse's mapping surface declared its `// covers:` claims voluntarily. Teach the lint to walk `internal/integrations/*/web` (or collect routes via the registry). Found 2026-07-17 (`discourse-integration` findings #7).
### Integration skeleton generator
Labels: `tooling`, `dx`
Scaffold a new integration's mechanical surface from a slug: the `internal/integrations/<slug>/` tree (front-door adapter implementing the mandatory interface, with capability hooks stubbed in comments), `store/sqlc.yaml` with the two-levels-up schema paths, a `migrations/00001_init.sql` template (schema, role triple, grants, `core_reader` grant, `member_console` membership, mirrored Down), `workflows/` and `web/` stubs, a registry-line reminder, and `test/seed/<slug>/`. Deliberately sequenced **after 9e**: the Discourse build is the first hand-build against `docs/building-an-integration.md` and should validate that the doc alone suffices — the generator then encodes what two conformant integrations agree on, rather than guesses. Recorded 2026-07-06.
## Auth & security
### Session/CSRF secret generation and rotation strategy
Labels: `security`
**Scope narrowed 2026-07-31 by `hardening-polish` (10e), which deliberately trimmed this issue:** `docs/hosting.md` now documents the hard-cutover rotation procedure and its blast radius — sessions are stored server-side in Valkey and are unaffected; in-flight forms invalidate (their CSRF tokens no longer verify). Remaining open work: zero-downtime dual-key rotation (accept old+new keys during a grace window; keyring-shaped config) plus generation guidance beyond the current one-liner. Candidate for M11.
### Auth setup review
Labels: `security`, `auth`
Remove Keycloak-specific code, backchannel logout, session timeout, rate limiting.
### IdP-agnostic role-mapping design (follow-up to hardening-polish role merge)
Labels: `auth`, `design`
`hardening-polish` (10e) made `extractRoles` a deduplicated union over all four claim locations, which fixes client-scoped roles on Keycloak but is still convention-based: the role NAME is the contract, claim locations are hardcoded, and there is no per-deployment mapping (claim path selection, role renaming, operator-role aliasing) for IdPs like Authentik/Authelia/Zitadel that use different claim shapes. Full design deliberately deferred out of 10e (see the change's proposal "Deliberately trimmed" section): decide between merge-with-config-overrides vs a declarative claim-path mapping, and where operator-role naming is declared. Note the union semantics are a documented behavior change: a same-named role in any location now grants.
## Infrastructure, operations & testing
### ~~TestPlanLaddersWalkthrough flakes at MustWaitLoad~~ — RESOLVED 2026-08-23 (root-caused and fixed same day)
Labels: `test-infra`, `flaky`, `e2e`
**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.
### Temporal schedule management on redeploy
Labels: `operations`
Old schedules not cleaned up when config changes.
Resolved (2026-07-11) for the FedWiki sync schedule's redeploy args-refresh half
by the `fedwiki-sync-schedule-resilience` change: `EnsureSyncSchedule` now
classifies `Describe` errors correctly (only Temporal `NotFound` routes to
`Create`; any other error is surfaced, not masked as absence), so a transient
describe failure no longer skips the `Update` that refreshes stale schedule args.
See that change's proposal.md Impact list.
### Billing sweep schedule shares the FedWiki describe-error misclassification
Labels: `operations`, `tech-debt`
`internal/workflows/billing/schedule.go`'s `EnsureSweepSchedule` was **not**
audited or fixed by `fedwiki-sync-schedule-resilience` (explicitly out of that
change's scope). Confirmed by inspection it shares the same pre-fix shape: its
`if _, err := handle.Describe(ctx); err == nil { ...update... }` else
unconditionally `Create`s, so any non-`NotFound` `Describe` error is misread as
"schedule absent" and routed to `Create` — the exact bug fixed in
`EnsureSyncSchedule` (task 2.4). Unlike the FedWiki case it carries no baked-in
workspace UUID, so there is no FK-violation follow-on, but a transient describe
failure would still fail the create-after-describe and skip the sweep schedule's
args refresh. Follow-up debt: apply the same `NotFound`-only routing +
create-race fallback.
### Database backup before migrations
Labels: `operations`
### Add middleware tests
Labels: `testing`
CSRF, logging, compression, recovery, request ID, timeout, secure headers, CORS.
### HTMX handler file structure cleanup
Labels: `refactor`
### Temporal auth race on first boot
Labels: `bug`, `operations`
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.
### Migration orchestration mechanics
Labels: `design-feedback`
Per-module migrations need a boot sequence that collects from each module's embedded FS in dependency order.
### ~~Makefile `sqlc-generate` target is stale~~ — RESOLVED 2026-08-22 (`model-doc-rot`, 10j)
Labels: `tooling`, `resolved`
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`.)
### Shared trigger function ownership
Labels: `design-feedback`
`update_updated_at_column()` is used by all modules. Probably belongs in a shared migration or the `db` package.
**Update 2026-08-01** (`test-db-isolation`): checked against the
cluster-global question and cleared. Functions are database-scoped, not
cluster-global like roles, so each database in a cluster gets its own copy
and `public.update_updated_at_column()` never blocked a second database's
migration. The same holds for every `ALTER FUNCTION … OWNER TO` and `GRANT`
in the streams. This stays open purely as the code-organization question it
started as.
### Database vs app layer: where enforcement lives is ad hoc
Labels: `architecture`, `conventions`, `design`
Observed 2026-08-21 while writing the product-catalog model card (10i): some load-bearing rules are schema-enforced (CHECK constraints, partial unique indexes, conferral reading only its view), while equally load-bearing rules are app-layer-only (publication gating on catalog queries, `display_category` behavioral neutrality, the composite purchasability gate). Which layer a new rule lands in has been ad hoc — whatever the implementing session reached for that day. Wanted: a documented convention for when a rule belongs in the schema versus the app layer (candidate axes: cross-surface consistency, whether violation corrupts data or merely misleads the UI, migration cost, testability). The model cards' **[db]**/**[app]** invariant tags now surface the de-facto split per model; the convention would make the split deliberate. Candidate home: a developer doc under `docs/`, referenced from the model-catalog index.
## Deferred to future milestones (metering, commitments, bundles)
### Storage compliance check at downgrade presupposes storage metering (unbuilt) — deferred to M13
Labels: `enhancement`, `entitlements`, `billing`, `metering`, `deferred`, `blocked`
The M8c milestone named a **storage compliance check** as new downgrade work (Standard → Public storage reduction). The 8c scoping pass (2026-05-31) found it presupposes storage **metering**, which is entirely unbuilt: no `storage_bytes` numeric-entitlement is seeded, there is no usage tracking, and no enforcement consumer exists. The `storage_bytes` resource key is recognized in the data model / `design/entitlements/`, but `design/entitlements/README.md` calls storage metering "future metering infrastructure." The "16 MB" / "256 MB" tier figures are documented but never enforced in code.
A "compliance check" needs a measured usage value to compare against the new limit — there is nothing to measure today. This is a metering build, not a downgrade-UX build, so it belongs with network-egress metering in **M13 (Network / Storage Metering & Flexible Service)**, which builds on the M9 integration contract's namespaced resource keys.
**Scope when scheduled (M13):** seed a `storage_bytes` resource limit per tier, meter per-site/per-pool storage usage (`usage_events` or a periodic measure), then a downgrade-time check that surfaces over-quota state. Until then, 8c's downgrade does not gate on storage. Split out 2026-05-31 during the 8c scoping pass. Related: the `force_reduce`/site-selection split-out above, and the integration-contract-before-metering swap (2026-05-31) that put M9 ahead of the metering milestone (now M13).
### Pooled metered resources across arbitrary provider sets — scalar `resource_keys.provider` can't express subset scoping (model rework, deferred to M13)
Labels: `design-feedback`, `architecture`, `entitlements`, `metering`, `deferred`
The M9 contract gives `entitlements.resource_keys` a single nullable `provider` column encoding **fact-source cardinality** (`provider='fedwiki'` = usage from one provider; `provider IS NULL` = pooled across all providers, or platform-native — see the platform-owned-metered analysis, 2026-06-07). A scalar column expresses exactly two scopes: **one** provider, or **all/none**. It cannot express an **arbitrary subset** — e.g. a single storage quota pooled across `{FedWiki, NextCloud}` while Discourse meters separately, or a platform pool with a per-provider sub-cap nested inside it. Per-provider and pool-across-everything are just the endpoints (set size 1, set = all); the general case is "this resource is metered over *this set* of providers."
**Why it needs model rework, not a column tweak:** the scalar can't carry a set. Supporting arbitrary scopes needs a grouping construct — a junction (`resource_key` ↔ N `providers`), or a named "metering scope" / "resource group" entity that a key and its contributing providers attach to, with usage rolling up over the group's members. That also interacts with the limit side (a pooled limit on the group vs. per-provider sub-limits, meter enforcing the tighter — the "nested quotas" option already flagged as available-if-wanted).
**Deferred — not designing now.** Flagged so M13's metering model either accounts for arbitrary-set scoping or consciously punts to a flat scalar for v1. The pooled-vs-per-provider call already handed to M13/product generalizes to "*which set* of providers shares this quota." Noted 2026-06-07 during the M9 9a extension-model exploration. Related: the storage-compliance / storage-metering split-out above; "Integration architecture (and operator IA placement)" and "FedWiki-only integration assumption pervades UI and data patterns" below; the dotted-display-form retirement (bare key = machine id, `provider` column = grouping, `display_name` = UI label) settled in the same exploration.
### Commitment `early_termination_policy='fee'` branch unimplemented (block/allow built; fee deferred)
Labels: `enhancement`, `billing`, `deferred`
Decision 126 (`design/billing/model.md`) models minimum-term commitments on `billing.subscriptions` (`commitment_end`, `commitment_renewal`, `early_termination_policy ∈ {block, fee, allow}`) and dispatches a mid-term downgrade/cancellation by policy: `allow` proceeds immediately, `block` defers the change to `commitment_end` via a `subscription_scheduled_changes` row (`effective_trigger='term_boundary'`), and `fee` proceeds immediately **but emits an `early_termination_fee` invoice line item** (`invoice_line_items.line_type += 'early_termination_fee'`, carried so the patronage pipeline attributes it correctly).
`plan-switch-mechanics` (M8 workstream F) implements **`block` and `allow`** — they ride the scheduled-change firing path that change already builds, so they were nearly free. It does **not** implement **`fee`**: emitting the `early_termination_fee` line item and wiring its patronage/credit attribution is genuinely new economic machinery, out of scope there. Until it lands, a `fee`-policy committed downgrade/cancel is **refused with a reason** at the endpoint and rendered **disabled-with-reason** in the member catalog (never proceeds without emitting the fee it cannot yet emit).
**Severity:** none today — the live deployment is entirely **evergreen** (`commitment_end IS NULL` on every subscription), so no subscription carries a `fee` policy and nothing exercises this path. It becomes real only once an operator configures a `fee`-policy commitment.
**Scope of change:** a later billing change — emit the `early_termination_fee` line item on the next invoice when a `fee`-policy commitment is broken mid-term, attribute it through the patronage pipeline, then flip the endpoint/catalog from refuse/disabled to active. Depends on the patronage-attribution path for the new `line_type`. Split out 2026-05-30 during `plan-switch-mechanics` design (correcting an earlier mischaracterization that treated all committed moves as a flat refusal — real `block` defers, it does not refuse).
### Member catalog does not pre-label committed moves (`block`/`fee`) — post-click enforcement only
Labels: `enhancement`, `frontend`, `billing`, `deferred`
The member catalog (`internal/server/member_products.go` `buildPlansData``member_plans.html`) renders paid switch/cancel controls **generically enabled**. It does not resolve each enrolled ladder's backing subscription to read `commitment_end` / `early_termination_policy` and reflect the policy *on the control*, as the `member-product-discovery` spec scenario "Committed move reflects the early-termination policy" describes: `allow` = active immediate, `block` = active **labeled** "takes effect at commitment end", `fee` = disabled-with-reason.
Policy is instead enforced by the **endpoints after the click**: a `block` downgrade/cancel defers to `term_boundary` (the member sees it scheduled for the boundary date), and a `fee` move is refused (`ErrCommitmentFee` → error banner). So enforcement is correct and tested (`plan-switch-mechanics`, 2026-05-30); only the *pre-click labeling* is missing.
**Why deferred:** the live deployment is entirely **evergreen** (no subscription carries a commitment), so the `block`/`fee` label branches would never render — the labeling is dead code today and would cost an extra per-enrolled-ladder subscription + commitment lookup in `buildPlansData`. Correctness holds regardless because the endpoints gate the action. The only gap: a committed member would see a normal-looking control and learn the policy only *after* clicking, rather than up front.
**Scope:** thread the backing subscription's commitment (`commitment_end` + `early_termination_policy`) into the per-(ladder, tier) view model, then branch the control's label/enabled state. Pairs naturally with the deferred `fee` branch and with operator-side commitment configuration — none of which exist yet. See the `early_termination_policy='fee'` issue above and the "Minimum-term commitments" backlog item in `status/milestones.md`.
### Member plan bundles — cross-ladder SKUs & exclusivity messaging (deferred, blocked)
Labels: `enhancement`, `frontend`, `billing`, `deferred`, `blocked`
A **bundle** is a single product that occupies rungs on **multiple ladders at once** (cross-axis) — e.g. a SKU covering hosting-Standard *and* support-Basic together — as opposed to a normal tier, which sits on one axis. Member-facing work (`member-plan-bundles` capability, named-but-deferred in the `member-ladder-aware-catalog` proposal) would present: what a bundle includes, **cross-ladder exclusivity messaging** ("subscribing replaces your standalone hosting / support"), and disabled-with-reason on the standalone tiers a held bundle blocks. **Blocked:** no bundle concept exists in code, and there is no operator-side bundle configuration — so there is nothing for the member UI to present yet. Phase 2 depends on operator bundle support landing first; expect `member-plan-bundles` to become its own change paired with an operator-side counterpart. Tracked here so it survives archival of the `member-ladder-aware-catalog` proposal. Split out 2026-05-24 during that change's design review. **Update 2026-05-30:** workstream D shipped as `operator-topology-legibility`, scoped to **legibility only** — it makes the existing M:N (a product as a tier in several ladders) legible but deliberately does **not** introduce the bundle domain. So both halves remain unbuilt: the operator bundle-configuration counterpart is still a separate future change, and this member half stays blocked on it.
### Add-on multi-purchase semantics & checkout
Labels: `enhancement`, `frontend`, `backend`, `billing`
Add-ons are off-ladder, stackable products a member may hold multiple times (unlike ladder-gated plans, which are mutually exclusive per axis). The `member-ladder-aware-catalog` change frames add-ons as a distinct stackable section but deliberately defers the *mechanics*: repeat purchase, quantity, and the checkout/provisioning path (one `pool_provision` per purchase, `chk_pool_provisions_source = 'purchase'`). Belongs to a later checkout-mechanics change alongside the paid plan-switch work (`plan-switch-mechanics`), not the catalog presentation change. Split out 2026-05-24 during `member-ladder-aware-catalog` design review.
## Model documentation
### Product-catalog card: drift & trap ledger (2026-08-21)
Labels: `documentation`, `debt`, `billing`, `bug`
The model cards keep their bodies free of drift bookkeeping (calibration-gate decision in the `model-cards` change); traps observed while writing each card are ledgered here with full references. From the product-catalog card:
- **~~The designed reference tells the pre-doc-41 story~~ — RESOLVED 2026-08-21.** Decisions 134139 ratified (Issue 29 closed), the Data Model Reference bumped to v15 with the full doc-41 integration (60 tables — `pool_provision_transitions` enters the master reference for the first time), doc-35 carries a superseded-by banner, and the refreshed surfaces are synced into `design/`. The designed and as-built product models now agree.
- **`core.products.entitlement_set_id` has no FK.** The column is a bare nullable UUID (`internal/db/migrations/00001_init.sql`) while `openspec/specs/product-catalog/spec.md` describes it as an FK to `entitlement_sets`; enforcement is app-side only. Contrast `core.pool_provisions.entitlement_set_id`, which does carry the FK. Fix: add the FK or correct the spec prose.
- **~~A draft product can be bought (BUG, launch-relevant)~~ — RESOLVED 2026-08-22 (`purchase-path-blockers`, 10j).** The catalog queries now filter `lifecycle_status = 'published'`, checkout re-checks the shared member gate (`evaluateMemberGate`, `internal/server/product_readiness.go`) before any Stripe call, and an org enrolled on a now-unpublished tier keeps its current rung via a targeted fetch (`buildPlansData`). Verified by DB-backed tests plus a live browser walkthrough (draft hidden, direct checkout POST 400s, publish flip appears). Card updated: product-catalog invariant 11. Original entry: The member path never checked `lifecycle_status`: `ListPublicProducts`/`ListPublicPlanProducts` (`internal/billing/queries/products.sql`) filter `is_active` + `is_public` only, `resolvePurchasable` (`internal/server/member_products.go`) checks only price activity and the Stripe mapping, and `HandleCheckout` (`internal/server/billing.go`) validates only the price, the mapping, and a ladder guard. So a draft that is active + public + priced + mapped renders as purchasable and passes checkout, and only `core.confer` rejects it — after payment. The full composite verdict exists solely in `buildProductReadinessVM` (`internal/server/product_readiness.go`), which the member surface never calls. Confirmed 2026-08-21 by the model-card verification pass (adversarial fact-check with citations). **Triaged 2026-08-21: scheduled for M10** (maintainer: "a fix for this milestone for sure") as its own small change; the fix should route member surfaces through the shared gate rather than adding a fourth partial re-derivation.
- **`core.prices.unit_amount` is a bare INTEGER** with no range CHECK (`internal/db/migrations/00001_init.sql`); overflow protection is handler-level (2026-07-02 audit finding, `status/audit-2026-07-02-operator-ux.md`).
- **`mixed` billing shape has no consumer.** `core.product_shape` reports `mixed` (recurring and one-time prices both active) but nothing blocks or blesses selling that way — tracked upstream, see the mixed-shape entry under Design feedback below.
- **`prices.trial_period_days` stored but honored nowhere** — already tracked under "Deferred-remediation debt" above; listed here only because the column sits in this model.
### Payments-billing card: drift & trap ledger (2026-08-21)
Labels: `documentation`, `debt`, `billing`, `bug`
From writing `docs/models/payments-billing.md` (code-verified evidence sheet + adversarial pass). Two entries are live bugs needing triage:
- **~~BUG: `core.webhook_events` partitions run out~~ — RESOLVED 2026-08-22 (`purchase-path-blockers`, 10j).** The promised creator now exists: `EnsureWebhookEventPartitions` (`internal/db/partitions.go`, current + 3 months ahead, mirrors the migration's naming/bounds) runs logged-not-fatal at boot in `cmd/start.go` and on a 24h Temporal schedule (`webhook-partition-ensure`, `internal/workflows/maintenance/`, knob `webhook-partition-ensure-interval`); DB-backed idempotency test proves insert-fails-before/succeeds-after. Live-verified: boot created `webhook_events_2026_11`. Card updated: payments-billing map. Original entry: The baseline migration creates only the current + next two monthly partitions and its comment promises "application boot or a scheduler" creates future ones — but no such code exists anywhere (repo-wide grep). Roughly three months after a deployment's initial migration, inserts hit a missing partition and **every webhook write fails**. Fix candidates: partition creation at boot in the migration/boot path, or a Temporal maintenance schedule shared with the Discourse webhook path. Launch-relevant: a fresh OSS deployment hits this silently at month three.
- **~~BUG: webhook receipt can silently lose events~~ — RESOLVED 2026-08-22 (`purchase-path-blockers`, 10j).** The receiver now answers 500 when the insert fails so Stripe redelivers, and 200 only once the event is recorded or deduplicated (`TestServeHTTP_InsertFailureReturns500`). Card updated: payments-billing invariant 14. Original entry: The receiver returned 200 to Stripe even when the `core.webhook_events` insert failed (`internal/integrations/stripe/web/webhook.go` logs and still answers 200), and Stripe only redelivers on non-2xx — so a transient DB failure at receipt drops the event permanently. Subscription events later self-heal via reconcile triggers; invoice/payment events do not.
- **The no-Stripe-ids-on-core rule has an undocumented exception.** `core.subscription_changes.stripe_event_id` is NOT NULL on a core table, and the reconciler writes attribution strings into it that are not always event ids ("eager:checkout-return", "fire:cancellation"). Either rename it to `attribution`/`reason` or move it provider-side.
- **~~`core.subscriptions.status` is bare TEXT with no CHECK~~ — RESOLVED 2026-08-22 (`schema-hardening`, 10j).** Migration 00010 adds a CHECK over Stripe's full closed vocabulary (eight values, including `incomplete_expired`, which the reconciler really receives). The designed vocabulary omits `incomplete_expired` — see the Design feedback note filed 2026-08-22.
- **Invoice/payment projection trusts the webhook payload.** The "never trust the payload" cardinal rule is scoped to subscriptions; invoice and payment rows are built straight from scrubbed payload fields — a deliberate, carded posture (display facts). **Update 2026-08-22 (`schema-hardening`, 10j): the unguarded int64→int32 casts within that projection are now checked** — out-of-range amounts fail the activity with a non-retryable error naming the event instead of storing a wrapped negative value. En route the executor found and fixed a live bug: `ProcessWebhookEvent` wrapped dispatch errors with `fmt.Errorf`, and the Temporal SDK judges retryability by the top-level error type without unwrapping, so every non-retryable error in the package was silently retryable; now unwrapped via `errors.As` (regression-tested).
- **~~The outbox customer path is dead code~~ — RESOLVED 2026-08-22 (`model-doc-rot`, 10j).** `EnsureStripeCustomer` deleted; `EnsureStripeCustomerPayload` kept (the outbox worker still unmarshals it, now commented with its consumer); the test that exercised only the deleted path removed, the live outbox-executor test kept.
- **~~`docs/stripe.md` claims mapping `sync_status` can become `dead_letter`~~ — RESOLVED 2026-08-22 (`model-doc-rot`, 10j).** Doc corrected: mappings go `pending``synced` (or `deleted`); dead-letter is an outbox status; a terminally failed sync leaves the mapping `pending` until the operator retry.
- **One-pending-scheduled-change has no index backing** — subscription-scoped supersession in the writers only; the design's item-scoped partial unique index doesn't exist. Already tracked under "Subscription scheduled-change + commitment schema implemented from design-only model" (Design feedback); listed here for the card's sake.
- **Commitments are read-complete, write-absent** — gating honors the columns; no code, query, or UI sets them, so production is all-evergreen and the block/fee branches are dead paths. Already tracked (commitment fee entry, deferred section).
- **`prices.trial_period_days` still stored-but-never-honored** — checkout builds no trial parameters. Already tracked (Deferred-remediation #1).
### Plan-ladders card: drift & trap ledger (2026-08-21)
Labels: `documentation`, `debt`, `plan-transitions`, `bug`
From writing `docs/models/plan-ladders-transitions.md` (code-verified evidence sheet). The headline is developer-doc rot: three living `docs/` pages describe machinery deleted by the doc-41 change — launch-facing, since these are the docs a new contributor reads:
- **~~`docs/plan-architecture.md`, `docs/grant-plan-safety.md`, and `docs/plan-management.md` all describe the dead Go `Transition` primitive~~ — RESOLVED 2026-08-22 (`model-doc-rot`, 10j).** plan-architecture and grant-plan-safety retired (near-total redundancy with the plan-ladders card; the 2026-04 incident narrative preserved at `status/archive/incident-2026-04-grant-provision-sync.md`; `docs/README.md` index and the `internal/entitlements/doc.go` package comment updated); plan-management corrected in place with its model description delegated to the card. Original entry: plan-architecture says "Transition is defined in `internal/entitlements/transitions.go`" (file deleted); grant-plan-safety's reference list names `transitions.go` and `RevokeGrantAndTransition` (neither exists); plan-management says Extend "invokes Transition with Extend=true" recording `transition_type='extend'` ('extend' is CHECK-rejected for new writes; extend records 'transfer' via grant lineage). As-built: the five SECURITY DEFINER conferral functions (`internal/db/migrations/00005_doc41_conferral_functions.sql`) fronted by `internal/entitlements/conferral.go`. All three docs need a doc-41 rewrite pass — M10-relevant.
- **~~`docs/plan-management.md` documents restore-as-downgrade; the code restores-as-initiate~~ — RESOLVED 2026-08-22 (`model-doc-rot`, 10j).** Corrected in the same plan-management pass: restoration is now documented as recording `initiate` via the floor-guarded reapply path.
- **~~`docs/grant-plan-safety.md`'s wrong-path heuristic is inverted post-doc-41~~ — RESOLVED 2026-08-22 (`model-doc-rot`, 10j).** Resolved by the doc's retirement; the card carries the correct as-built shape story.
- **The plan-transitions spec mandates a rank-shape CHECK that doesn't exist.** `openspec/specs/plan-transitions/spec.md` requires a CHECK binding each transition type to its rank pattern; migrations carry only the type-list and occupancy CHECKs — rank shape holds db-code via `core.confer`'s derivation. Either add the (small, NOT VALID) CHECK or amend the spec to accept db-code enforcement.
- **Rank contiguity is detection-only.** 0-based contiguous ranks are maintained by UI discipline and reported by the validation page (chosen 2026-07-23, commit `93bf698`); out-of-UI writers can still create gaps. Candidate: deferrable constraint or trigger (nontrivial — reorder's park-then-assign fights a non-deferrable UNIQUE).
### Entitlements card: drift & trap ledger (2026-08-21)
Labels: `documentation`, `debt`, `entitlements`, `bug`
From writing `docs/models/entitlements.md` (code-verified evidence sheet). Two functional gaps lead:
- **Rule changes never reach existing pools.** Design promises rule add/modify/deactivate re-evaluate affected pools; as built, `CreateEntitlementSetRule` and `DeleteEntitlementSetRule` (`internal/server/operator_entitlement_sets.go`) call no materializer, so limits and boolean grants stay stale until an unrelated conferral event happens to touch each pool — and the operator has no way to push a rule change at all. Needs a fan-out decision (synchronous over N pools vs queued) plus the UX; filed as its own question below the ledger entries in this section's history — treat this bullet as the tracking item.
- **~~Mixed stacking policies on one resource key are order-sensitive~~ — RESOLVED 2026-08-22 (`schema-hardening`, 10j, maintainer decision).** Resolved by removal rather than by either candidate guard: rule authoring now accepts only `additive` and the selector left the form, so new mixtures cannot be created (existing rows keep computing as before). The deeper questions — what stacking policies are for and how mixtures should combine — moved to "Stacking policies need a design exploration" below.
- **Person- and billing-account-targeted grants are schema-legal but undeliverable.** The recipient CHECK admits them; every conferral path is org-scoped (`ConferGrantTx` accepts only OrgID; the expiry activity skips pool settlement without one). Constrain the schema or build the delivery path.
- **`entitlement_sets.is_active` is an authoring-time gate, not a delivery switch** — inactive sets keep selling, conferring, and materializing; the toggle only trims the product-form picker. Already re-scoped by the 10h de-advertisement (#3, "Show in operator pickers"); listed here because the card states the honest meaning.
- **Design tables that were never built:** `credit_grants`, `usage_events`, `pool_ondemand_config`; `quota` and `credit` rule types are authorable per the CHECK but the materializer skips them. Build-or-prune is an upstream question (quota/credit belong with M13 metering).
- **~~Stale code comment:~~ RESOLVED 2026-08-22 (`model-doc-rot`, 10j).** The `operator_entitlement_sets.go` comment now cites the `00001_init.sql` baseline, keeping the finding #40 attribution.
### Stacking policies need a design exploration
Labels: `entitlements`, `design`, `product`
Filed 2026-08-22 (maintainer decision during `schema-hardening` triage). Rule authoring is now restricted to `additive`; the stacking-policy selector left the rule form because it made operators wonder what "stacking policy" even means, and mixing policies on one resource key made an org's limit depend on purchase order. Before any non-additive policy returns, this exploration must answer: what the stacking policies were designed to do and what value each offers a deployment; a scenarios document explaining, with concrete examples, when an operator would want each policy (`additive`, `maximum`, the removed `replace`, and any future ones); whether per-rule authoring in the entitlement-set form is even the right UI home for this concept, or whether it belongs at a different level; and the combination semantics for mixtures on one resource key (a defined tie-breaker at materialization plus an authoring warning were sketched and deliberately not built). Related: the materializer still contributes 0 for any policy value outside `additive`/`maximum` on existing rows (legacy `replace`), which this exploration should also settle. Design-level parts likely go upstream once scoped.
### Resource-pools card: drift & trap ledger (2026-08-21)
Labels: `documentation`, `debt`, `entitlements`, `bug`
From writing `docs/models/resource-pools.md` (code-verified evidence sheet). Three unenforced-but-load-bearing rules lead; each is a small, schema-only or single-file fix:
- **~~One default pool per org is unenforced and resolution is nondeterministic~~ — RESOLVED 2026-08-22 (`schema-hardening`, 10j).** Migration 00010 adds `uq_resource_pools_one_default_per_org` (partial unique on `org_id WHERE pool_type='default'`) plus `chk_resource_pools_pool_type_valid` (`default|shared|dedicated`, per design/data-model.md; only `default` is written today). Verified by DB test and live psql rejection.
- **~~Primary-assignment uniqueness per workspace is convention only~~ — RESOLVED 2026-08-22 (`schema-hardening`, 10j).** Migration 00010 adds `uq_pool_assignments_one_primary_per_workspace` (partial unique on `workspace_id WHERE is_primary`) with a deterministic dedup pre-flight (earliest assignment survives). The double-debit path is closed: the quota subquery can now match at most one pool.
- **~~Workspace creation swallows a missing default pool~~ — RESOLVED 2026-08-22 (`schema-hardening`, 10j).** Workspace-plus-assignment creation moved into a shared transactional function (`internal/provisioning/workspace.go`, `CreateWorkspaceWithPrimaryAssignment`) used by both signup and the member handler; the handler now rolls back and renders an error instead of reporting success on a poolless workspace.
- **Pool lifecycle is columns-only.** `status` (`suspended`/`archived`) and timestamps have no writer. A manually suspended pool would sever *delivery-side* resolution (the default-pool resolvers filter pool status) while quota consumption kept working (those paths consult only assignment status) — an incoherent half-cascade; doc-22 §10.8 admits the cascade is undefined. Do not add writers before it is specified upstream.
- **~~The grant-extend flow confers into a URL-named pool~~ — RESOLVED 2026-08-22 (`schema-hardening`, 10j).** `ExtendGrant` now refuses multi-pool orgs (same copy as issuance's finding-#32 guard) and verifies the URL pool belongs to the org and is its default before conferring; refusals write nothing.
- **Design/code drift on pool parentage:** design hangs the default pool off the billing account; code hangs it off the organization with no billing linkage (deliberate orthogonality as-built — upstream sync candidate). `pool_type`'s designed `shared`/`dedicated` values are unreachable. On-demand/overage (`pool_ondemand_config`, `usage_events`, `pending_charges`) is design-only — nothing exists in code (M13 territory).
### Cross-workspace quota sharing was decided by code, not by design (upstream)
Labels: `design-feedback`, `entitlements`, `upstream-membcons-db`
Usage counters are pool-grain — `core.numeric_entitlement_usage` keys on (pool, resource key) with no workspace column — so sibling workspaces of one org consume from a single shared bucket (e.g. `fedwiki_sites`). Doc-22 §10.2 leaves cross-workspace sharing undefined; the code has de facto decided "shared". Upstream should either bless pool-grain sharing as the model or specify sub-allocation. Surfaced 2026-08-21 while writing the resource-pools model card.
### Provider-integration card: drift & trap ledger (2026-08-21)
Labels: `documentation`, `debt`, `integration`
From writing `docs/models/provider-integration.md`. The Doc-39 conformance check ran alongside this card — full per-clause record in [doc39-conformance-2026-08-21.md](doc39-conformance-2026-08-21.md); its six new findings are upstream ratification input and are not repeated here. Member-console-side items:
- **~~`Integration.Slug()` and the manifest's slug are documented as "must match" but nothing verifies it~~ — RESOLVED 2026-08-22 (`schema-hardening`, 10j).** Boot now asserts the match per integration (`internal/integration/slug.go`, called from `cmd/start.go` before provider registration) and fails startup naming the integration and both strings.
- **~~Env-sourced enum config values are not enum-checked at startup~~ — RESOLVED 2026-08-22 (`schema-hardening`, 10j).** `ValidateStart` now validates every declared enum key's resolved value (unset passes), sharing one membership helper with the override path (`CoerceOverride`) so they cannot diverge; failures aggregate into the boot error report naming key, value, and allowed set.
- **Registry tables' home drifted from design:** doc-39 specified `billing.providers`; as-built they live in the core schema's squashed baseline. With v16 the designed model carries them as seam tables of the provider seam, so the conceptual-address convention (Decision 140) covers the homing. Substrate naming drift (`integration.outbox``core.outbox`) likewise stands as conceptual addressing.
- **Convergence expected as-built (Doc 44 ruling, 2026-08-21):** ratification deliberately kept the designed `core.providers` discipline — a CHECK on `status` and the `suspended_at`/`retired_at` timestamps — rather than absorbing the as-built free-text column. **Update 2026-08-22 (`schema-hardening`, 10j): the member-console half is done** — migration 00010 adds the status CHECK and both timestamps, closing the card-tracked drift. The §6.3 boot/lint declaration↔execution check and `Enqueue` validation against `provider_operations` likewise stay **normative** upstream — implementation debt deferred to the dispatch layer, not amended away.
- **Webhook dedup reference corrected upstream:** the designed global two-column UNIQUE was unimplementable on the partitioned table; the reference now documents handler-level `WHERE NOT EXISTS` dedup as the binding contract. Both as-built receivers (Stripe and Discourse) already conform — verified 2026-08-21 by grep; a conformance-report aside claiming Stripe retained the old shape was itself wrong and has been corrected with membcons-db.
- **`fedwiki_sites` is the documented historical exception** to own-your-keys (seeded by core's `00002` rather than FedWiki's stream); the authoring guide says to copy Discourse, not FedWiki. Fine as documented; listed so nobody "fixes" the guide against the exception.
- Already tracked, restated by the card: the payments-provider seam (Stripe's genus-only registration), the converged-set manifest gap, the free-form outbox `action_type` vocabulary, and the deferred declared-verb-to-activity binding check.
### Domains-registry card: drift & trap ledger (2026-08-21)
Labels: `documentation`, `debt`, `domains`
From writing `docs/models/domains-registry.md`. Two of the parked notes' traps are now CLOSED in code and recorded here so nobody re-fixes them: the own-root carve loophole (closed by `claim-expiry-and-carve-guards` D4's intent-based evaluation) and the caller-side entitlement gate (moved into `Registry.ClaimExternal` by `centralize-external-claim-gate`). Open items:
- **The schema looks like it constrains what only Go constrains.** `reversed_labels` is plain TEXT with no generated-column or trigger tie to `root_fqdn` (a row written outside the registry silently breaks every subtree scan), and nothing constrains placement-inside-claim containment. Candidate domains migration `00003` if accepted as a design decision.
- **Zero means three different things across the config layers.** Operator-set `domains-abandon-budget=0` maps to PolicyDisabled(-1) → ledger off; but a raw `Policy.AbandonBudget == 0` means "unconfigured" → default 3. A composition root passing a literal 0 straight through flips the ledger ON with the default budget. Also the hardening design's non-goal text ("approval-only falls out of budget 0") is backwards as shipped — 0 disables the ledger. Doc fix + consider a typed knob.
- **`/domains/ask` 200 is ambiguous while the strangler fallback is configured** — 200 means "servable placement OR the legacy answerer said yes". Migration-window caveat; retires with the fallback (10d Slice 3). Nothing dates that window.
- **~~`reconcileSite`'s doc comment lists six adoption outcomes; the code has seven arms plus an earlier exit~~ — RESOLVED 2026-08-22 (`model-doc-rot`, 10j).** The comment now enumerates the actual seven branches in code order (including the non-active-claim skip) and names `GrandfatherCarvedClaim` with its deliberate name-policy bypass.
- **~~Cancel doesn't probe for evidence~~ — RESOLVED 2026-08-22 (`schema-hardening`, 10j).** `MarkClaimCanceled` gained expiry's `@evidence` parameter, and the member cancel path now runs a fresh TXT probe before the write (3 attempts, ~2s each, ~5s budget; probe runs before the registry lock so DNS latency never holds the allocation transaction). Strict on exhaustion per maintainer decision: all-attempts-failed counts as no evidence. Note: the workflow's probe couldn't be imported (cycle); the record format is reimplemented locally with a drift-pin cross-reference, following the ledger test precedent.
- **Dead generated surface:** `SetPlacementServable` and `DeletePlacementByResource` have no non-test callers. Cheap cleanup.
- **Assumptions to keep visible:** budgets key on workspace_id with no Sybil bound beyond the operator-granted boolean (an operator attaching `external_domain_claims` to a free tier changes the abuse model); the ledger's ScopeLabels is a label count, deliberately PSL-free — the co.uk-style false positive is accepted, not solved. Hosted-label carving is unmetered by design (only the FedWiki numeric quota bounds it), and `name_rules` has no UI — lift the recorded non-goal or declare it seed-only.
- **Do-not-fix note:** `BudgetExceeded.RetryAt`'s doc comment is correct (the "oldest counted entry" is the budget-th newest overall); it reads wrong on a skim. The sibling Count doc pins the meaning.
### Identity card: drift & trap ledger (2026-08-21)
Labels: `documentation`, `debt`, `identity`, `bug`
From writing `docs/models/identity-organization-workspace.md`. The operational risk leads:
- **~~Rank-0 fragility can 500 every new-user login~~ — RESOLVED 2026-08-22 (`schema-hardening`, 10j, both guards per maintainer decision).** (a) Deleting a ladder's last tier is refused when the ladder is a live org-type default (narrower than the blanket guard the 2026-07-03 remediation rejected — emptying non-default ladders stays legal); (b) `AutoProvision` now completes a plan-less signup with an Error-level alarm naming the ladder when rank 0 is missing, instead of failing the login; the org is repairable via reapply-defaults once the ladder is fixed.
- **~~`docs/identity-provider-setup.md` contradicts the code, stale-safe direction~~ — RESOLVED 2026-08-22 (`model-doc-rot`, 10j).** The role-extraction section now documents the union of all four claim locations (deduplicated, order-stable) and the stale `resource_access` limitation callout is gone.
- **Database role permissions are dormant.** Seeded roles carry rich permission arrays; signup writes memberships and assignments; no server code reads any of it for authorization. Decide (with upstream doc-03) whether to enforce or remove; until then reviewers must not assume they gate anything.
- **Returning-login session binding is positional** — first owned org, then its first workspace; multi-org membership has no session story, and `org_members` rows in non-owned orgs never surface. Contract or placeholder? Needs the org-switcher decision before multi-org ships.
- **All lifecycle status columns on identity tables are unchecked free text** (users, persons, organizations, workspaces, org_members) — the vocabulary lives only in Go string comparisons. Same class as the subscriptions.status gap in the payments ledger.
- **Design far richer than schema (upstream divergence to sync):** invitations, service accounts, PATs, retention holds, person merges, and soft-delete lifecycles are all design-only; even the seeded permission vocabulary names service accounts that cannot exist. Record for upstream; settle when member-facing sharing is scheduled.
### Sub-models within the model cards (low priority)
Labels: `documentation`, `enhancement`, `low-priority`
Noted by the maintainer 2026-08-21 while reviewing the first card: the cards' invariants allude to smaller models with their own coherent rule-sets — purchasability and conferral shape are the product-catalog examples; more will surface as cards land. At some point, identify these sub-models explicitly (as named subsections or their own cards). Deliberately unscheduled.
## Design feedback (upstream design repo)
### Designed subscription-status vocabulary omits `incomplete_expired`
Labels: `design-feedback`, `billing`, `upstream-membcons-db`
Filed 2026-08-22 during `schema-hardening`. `design/data-model.md`'s subscriptions section lists a seven-value status state machine (incomplete, trialing, active, past_due, unpaid, paused, canceled) but omits `incomplete_expired`, a real Stripe subscription status the reconciler receives and stores (a Checkout session abandoned past its expiry window). Member-console's new `chk_subscriptions_status_valid` CHECK (migration 00010) uses Stripe's full eight-value closed vocabulary, so as-built is now stricter *and* more complete than designed. Upstream fix is one line: add `incomplete_expired` to the designed list. Until synced, this is the one place the as-built CHECK deliberately exceeds the designed text.
### Mixed billing shape semantics — RESOLVED upstream (Decision 138)
Labels: `design-feedback`, `billing`, `upstream-membcons-db`, `resolved`
**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 134139). No member-console work implied. Archive on next sweep.
### Cross-source supersession vs. continued billing (upstream Issue 31)
Labels: `design-feedback`, `billing`, `entitlements`, `upstream-membcons-db`
The doc-41 §11.2 residue that *did* survive ratification, logged upstream as Stage-1 Issue 31 per Doc 41 §12's own instruction: when an operator grant supersedes a subscription-sourced position, the subscription vehicle keeps billing — the member pays for a position a grant now covers. Upstream owns the normative answer; member-console consumes it when decided.
### Suspended-only pools have no baseline floor during dunning (design question)
Labels: `design-feedback`, `entitlements`, `billing`, `upstream-membcons-db`
A pool whose only plan position is suspended (past-due subscription) gets no default restoration: `ReapplyDefaultsIfVacant` counts suspended as occupied (correctly — the paid position must not be superseded by the free baseline), so the organization has no live plan delivery for the whole dunning window. Is that dark period intended, or should suspension trigger a temporary baseline? The guard's mechanics are documented in `internal/entitlements/reapply_defaults.go`; the dunning-period intent is not decided anywhere. Surfaced 2026-08-21 while writing the plan-ladders model card.
### Provider schemas: `provider_configs` prescription is per-org and as-built config is app-level
Labels: `design-feedback`, `integration`, `upstream-membcons-db`
`design/integration/architecture.md` ("When a new provider integration is built…") prescribes three standard table types per provider schema, including "a `provider_configs` table for per-organization configuration". As built (9g, `dynamic-provider-config`, 2026-07-22), that prescription is wrong on both axes: integration config scope is **app-level** (single Stripe account, no Connect topology — the per-org model was never implemented by any integration), and per-provider config tables don't exist at all — the Stripe one was dropped after sitting unused for the project's entire history. The as-built model: non-secret runtime config lives in one core-owned `core.integration_config_overrides` table layered over the environment via `ConfigSpec()` declarations; secrets are env-only. The design doc's standard provider-schema table types should reduce to entity mappings + provider-specific object tables.
### `tier_reduction_policy` (Decision 125) is design-only — `clamp` is de-facto/emergent, not configurable
Labels: `design-feedback`, `entitlements`, `billing`, `upstream-membcons-db`
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.
### Subscription scheduled-change + commitment schema implemented from design-only model (upstream sync)
Labels: `design-feedback`, `billing`, `upstream-membcons-db`
`plan-switch-mechanics` (2026-05-30) implemented, in member-console migration `00015_subscription_scheduled_changes.sql`, the schema the design team had added to `design/billing/model.md` but which existed nowhere in code: `billing.subscription_scheduled_changes` (Decision 123) and the `subscriptions` commitment columns `commitment_end` / `commitment_renewal` / `early_termination_policy` + the `commitment_fields_coherent` CHECK (Decision 126). The `block`/`allow` dispatch composes onto the scheduled-change firing path exactly as the model describes (`block``effective_trigger='term_boundary'`). Confirmed sound.
Two parts of the canonical model were **carried but left inert**, because their supporting tables don't yet exist in code — flagging for upstream coherence:
- **`credit_disposition`** on `subscription_scheduled_changes` (Decision 124) is implemented as a nullable column but nothing reads it: there is no credit-ledger table and no `billing_accounts.default_credit_disposition` column in the code schema. Decision 124's ledger/cash routing has no landing zone yet.
- **`invoice_line_items.line_type`** does not exist as a column in the code schema at all (the table has `amount`/`description`/`quantity` but no `line_type`), so the design's `+= 'early_termination_fee'` enum value (Decision 126 `fee`) can't be added until the column itself is introduced. This is the schema-side reason the `fee` branch is deferred (see the `early_termination_policy='fee'` issue above).
Upstream ask: when Decisions 124 and the `fee` branch are scheduled, land the credit-ledger table (+ `default_credit_disposition`) and the `invoice_line_items.line_type` column together, so the already-present `credit_disposition`/commitment columns become live rather than inert.
### Boolean entitlement materialization is code-defined, not design-documented
Labels: `design-feedback`, `entitlements`, `upstream-membcons-db`
`design/entitlements/model.md` defines `entitlement_set_rules.rule_type ∈ {boolean, limit, quota, credit}` but only describes materialization for numeric rules. The `boolean-entitlement-materialization` change (2026-07-17) added `core.boolean_entitlements` — pool-scoped rows recomputed by the same materializer as numeric entitlements, `granted` = OR across active provisions carrying the rule, rows retained with `granted = FALSE` on lapse (absence = never conferred), no contributions/usage side tables (provenance answerable by joining active provisions). Consumers: Discourse forum access (9e `discourse_posting`), 10d custom domains. Upstream ask: document boolean materialization semantics in `design/entitlements/model.md` alongside the numeric materialization section — the OR-aggregation rule, the lapse-row-retention convention, and that `quota`/`credit` remain unmaterialized.
### Cross-module queries are undocumented
Labels: `design-feedback`
See [2026-03-26 entitlement sets log](log/2026-03-26-entitlement-sets.md) for discussion on raw SQL vs shared interfaces vs service-layer orchestration.
### Use `<meter>` for quotas, not for limits
Labels: `design-feedback`, `frontend`
`<meter>` is the right element for showing usage against a quota (e.g. 3 out of 17 sites used). It should not be used to display a limit value on its own — e.g. showing "17 sites included" as a bar makes less psychological sense than plain text. Use `<meter>` only where there is a current usage value to compare against a maximum.
### Org type CRUD when non-personal types arrive
Labels: `design-feedback`, `organization`, `ux`
The `organization.org_types` table supports more than the seeded `'personal'` row — schema includes `display_name`, `description`, `is_active`, `default_product_id`, and `default_plan_ladder_id` — but the operator UI deliberately exposes no create/edit affordances. This is the right call today (operator misuse risk; only consumer is the personal-org auto-provisioning flow), but when non-personal org types arrive (a real "organization" type beyond the per-person workspace) the IA needs to grow:
- A create/edit surface for org types (with cascade visibility — changing `default_product_id` does not retroactively backfill; see "Auto-provisioning does not backfill existing orgs when `default_product_id` changes" above).
- A way for org-creation flows (whatever introduces a non-personal org) to pick the org type, instead of the implicit `'personal'` default in current code.
- Disambiguation in URL/IA: `/operator/organizations/...` lists actual orgs; `/operator/org-types/...` lists the type schema.
Until that work is scheduled, M7 should leave the schema alone and not surface CRUD. Discovered during M7 phase 7a (2026-05-08).
### Resource keys carry no shape discriminator — rule authoring cannot adapt to the key
Labels: `design-feedback`, `entitlements`, `ux`, `upstream-membcons-db`
`design/entitlements/model.md` §resource_keys defines the table as a pure shared-namespace registry (key, display_name, description, unit) — the entitlement taxonomy's types (`boolean`/`limit`/`quota`/`credit`, Decisions 98105) live only on `entitlement_set_rules.rule_type`. But in practice every key has exactly one coherent shape: `discourse_posting` is only ever conferred by a boolean rule (its consumers read `boolean_entitlements`), `fedwiki_sites` only ever by a numeric rule (consumers read `numeric_entitlements`). Nothing machine-readable records that shape — `unit` is free text (`'flag'` vs `'site'`) that no code reads — so the rule-authoring form cannot adapt to the selected key, and the schema cannot reject a mismatched rule: authoring a `limit` rule on `discourse_posting` materializes a numeric entitlement nothing consumes while the boolean grant that actually gates forum access never happens. Silent misconfiguration, no error at any layer.
Downstream fix landed 2026-07-21 (OpenSpec change `entitlement-rule-authoring`): `kind ∈ {boolean, numeric}` on `core.resource_keys` (core migration `00008` + discourse stream `00002`; populated where rows are created — same as `display_name`/`unit`; no manifest change), with a key-first adaptive rule form deriving `rule_type` from the selected key's kind. The upstream ask below remains open.
Upstream ask: consider adding a shape/kind column to `design/entitlements/model.md` §resource_keys so the shared-namespace table also anchors which rule types may target a key (`boolean` keys ↔ `boolean` rules; `numeric` keys ↔ `limit`/`quota` rules; `credit` rules reference no key and need no kind). With the column in the model, rule/key compatibility could be enforced in-schema (trigger or companion CHECK pattern) rather than by operator care — same fail-closed posture as the existing `chk_entitlement_set_rules_type`.