# Issues Tracked items structured for eventual migration to Gitea issues. ## Open ### Operator panel: heading hierarchy is broken (H1 → H6, no H2/H3/H4/H5) Labels: `bug`, `a11y`, `frontend` Operator pages jump straight from H1 ("Operator") to H6 for section titles and table headers, with one stray H5 ("Confirm" modal). Violates WCAG 1.3.1 / 2.4.6. Discovered in `docs/operator-ux-walkthrough-evidence/landing/`; cross-cutting across the operator surface. Candidate for M7-7c design-system foundation. ### Operator forms: inputs missing `autocomplete` attribute Labels: `bug`, `a11y`, `frontend` Chrome flagged 3 inputs at the operator landing without `autocomplete`. Violates WCAG 1.3.5. Cross-cutting across operator forms. Candidate for M7-7c. ### Operator template has at least one inline event-handler attribute Labels: `bug`, `security`, `frontend` Observed during Phase A v1 walkthrough — strict CSP would reject it unless an exception is configured. Worth grepping templates for `on*=` attributes and lifting handlers out. Candidate for M7-7c. ### Operator 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. ### 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 M10 (Integration architecture) or earlier if the friction recurs. ### Operator entitlement-set rules: no UI to add boolean rules Labels: `bug`, `frontend` `partials/operator/entitlement-sets/{id}/rules` exposes only the "Add Limit Rule" form (resource key + value + stacking + per-unit). The schema and seeded data include boolean rules (`demo.feature-x`), but there's no form path to create one. Discovered in Phase A v2. Candidate for M7-7b/7c. ### Operator revoke-and-transition: empty product name in confirm body Labels: `bug`, `frontend`, `copy` The per-org Enrollment "Revoke" button's confirm modal interpolates `{{ .ProductName }}` into the body text. When the grant target is an entitlement set (not a product), `ProductName` is empty and the modal reads *"Revoke for this organization? The pool returns to its org-type default and a downgrade or end transition is recorded."* (two spaces, dangling preposition). Fix: either fall back to the set/grant name, or template the entire phrase conditionally. Discovered in Phase A v2. Candidate for M7-7e action-confirm pattern. ### Operator 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 plan-ladder Tiers: "no products to add" alert is misleading when ladder is full Labels: `bug`, `frontend`, `copy` With all plan-typed products already in the ladder, the alert reads *"No available plan products to add. Products must be published with no product type assigned."* The first sentence is true (no candidates remain) but the second sentence advises a remedy that does not apply (existing products are correctly typed; they're already on the ladder). Copy bug. Candidate for M7-7e. ### 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. ### Member-console reads `products.product_type` directly instead of `billing.product_kinds` view Labels: `tech-debt`, `correctness`, `backend` Per upstream `membcons-db` Doc 35 (*Product Kind Taxonomy*), `billing.product_kinds` is the single authoritative read path for "what kind is this product?" — it derives `'plan'` structurally from `plan_ladder_tiers` membership and delegates the three labeled kinds (`addon`, `usage`, `one_time`) to `products.product_type`. The view's `WHEN plan` branch is evaluated first; reading `product_type` directly **misses the structural `plan` derivation** because plans now carry `product_type = NULL`. The member-console code reads `product_type` directly in many places (e.g. `operator_plan_ladders.go` lines 223/431/469/487 use `product_type IS NULL` / `IS NOT NULL` as plan-vs-non-plan discriminator). This works *today* because the application enforces the structural invariant (a non-plan product never has tier rows), but it duplicates the view's discrimination logic and is structurally fragile: any future kind that develops its own structural derivation (Doc 35 §5 sketches `one_time` via `prices.recurring_interval`, `usage` via `prices.usage_type`, `addon` via a future relational construct) will silently break direct `product_type` readers while the view stays correct. Fix: audit reads of `products.product_type` in Go and SQL; route product-kind discrimination through `billing.product_kinds` everywhere the application asks "what kind is this?". Writes that *declare* a non-plan kind continue to set `product_type` (per Doc 35 §7 reading guide). Related: this is the underlying schema-side reason the two grant-issuance code paths (`IssueGrant` for plans via ladder; `CreateGrant` for non-plan via labeled kind) exist — the M7-7b IA decision keeps both paths and labels the issuance forms by intent; this issue tracks the read-discipline cleanup independently. ### 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. ### Container image should not run as root Labels: `security`, `infrastructure` ### Better configuration handling Labels: `dx` Validate config at boot with meaningful error messages. ### Temporal schedule management on redeploy Labels: `operations` Old schedules not cleaned up when config changes. ### Session/CSRF secret generation and rotation strategy Labels: `security` ### Auth setup review Labels: `security`, `auth` Remove Keycloak-specific code, backchannel logout, session timeout, rate limiting. ### `/login` state-overwrite race on parallel requests Labels: `auth`, `bug` The OIDC login handler (`internal/auth/auth.go` `LoginHandler`) generates a fresh state/nonce/code-verifier on every call and unconditionally writes them to the session. When two requests hit `/login` concurrently in the same session — e.g. an unauthenticated page load that fans out into a `/favicon.ico` fetch, both bouncing through the auth middleware's redirect-to-`/login` — the second call overwrites the first's state before the user finishes authenticating at the IdP. The IdP then returns the *first* request's state, but the session holds the *second* request's, producing "State mismatch" 400s on `/callback`. Symptom recurs whenever a new asset path slips out from under the public-paths allowlist (this is at least the second time we've hit it). Long-term fix: make `/login` idempotent — if an unconsumed state exists and is recent (e.g. < 5 min old), reuse it instead of clobbering; clear it on `/callback` success or expiration. Defends against the favicon case AND multi-tab login attempts. Short-term mitigation already shipped: added `/favicon.ico` to the public-paths allowlist. ### Serve HTMX assets locally instead of from CDN Labels: `security`, `frontend` Include SRI hashes. ### Custom error pages Labels: `frontend` ### 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 with Temporal auth error. Second attempt works. Fix: add Temporal healthcheck to compose or retry logic to client connection. ### 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. ### ~~Keycloak seed ID pinning assumption is fragile~~ (RESOLVED — see OpenSpec change `2026-05-11-keycloak-id-pinning-fix`) Labels: `bug`, `fedwiki`, `testing`, `resolved` **Resolved**: Keycloak 26.x silently drops the `id` field on `POST /admin/realms/{realm}/users`. The fix swaps user creation in `seed-keycloak.sh` to use `POST /admin/realms/{realm}/partialImport`, which preserves the pinned id. Verified empirically: a partialImport with `id=f0000099-...` round-trips through `GET /users?username=...` with an exact id match. See OpenSpec change `2026-05-11-keycloak-id-pinning-fix` for the rewrite and rationale. Historical context (kept for reference): `test/seed/keycloak/seed-keycloak.sh` pinned user IDs (e.g. `a0000001-...` for Alice) so that FedWiki seed data (`owner.json`) could reference them deterministically. Keycloak did not honor the requested IDs on `POST /users` — proof at the time: `gnu.localtest.me/status/owner.json` had `"id": "e0249a3c-..."` instead of the pinned `a0000001-...`. Downstream consumers that benefit from the fix: - FedWiki `owner.json` references (original report). - Member-console demo seeder (OpenSpec change `2026-05-10-member-console-demo-seeder`) — its current "do not seed person rows" workaround can be revisited in a follow-up change to re-add `seedPersons` with deterministic UUIDs. ### Role extraction doesn't check resource_access Labels: `bug`, `auth` `extractRoles` in auth.go doesn't check `resource_access..roles`. Investigate best pattern for IDP-agnostic role mapping. ### 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. ### Directory structure for integrations Labels: `design-feedback` If more integrations arrive beyond FedWiki, consider `internal/integrations/fedwiki/`. Not urgent — database schema namespace provides separation. ### Migration orchestration mechanics Labels: `design-feedback` Per-module migrations need a boot sequence that collects from each module's embedded FS in dependency order. ### 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. ### provider_configs should not be per-organization Labels: `design-feedback` The integration architecture models `provider_configs` as per-organization configuration. Member-console runs a single Stripe account for the cooperative — there's no multi-org Stripe Connect topology. `provider_configs` should be a single-row app-level config (or env-based), not org-scoped. Discovered during Stripe integration planning (2026-04-02). ### Use `` for quotas, not for limits Labels: `design-feedback`, `frontend` `` 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 `` only where there is a current usage value to compare against a maximum. ### FedWiki-only integration assumption pervades UI and data patterns Labels: `design-feedback`, `architecture` Many UI templates (e.g. `fedwiki_sites.html`), handler names (`FedWikiHandler`, `FedWikiPartialsHandler`), and entitlement display logic assume FedWiki is the only integration. Resource keys (`sites`, `storage_mb`) are FedWiki-specific but rendered without integration context. Before adding a second integration, review: template structure (per-integration partials vs generic), handler registration patterns, entitlement display (resource keys need human-friendly labels and integration attribution), and the dashboard layout (currently one "FedWiki Sites" card — won't scale). Discovered during M5 phase 5c exploration (2026-04-11). ### Integration / extension 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. ### 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). ### 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). ### ~~Operator panel tabs are not HTMX-idiomatic: stale state and lost position on reload~~ ✓ Resolved Labels: `bug`, `frontend`, `ux` Each tab pane in the operator panel uses `hx-trigger="revealed"` to load its partial once on first reveal. After that, content is cached in the DOM and never refreshed — so a change made in one tab (e.g. creating a new entitlement set) is invisible in another tab (e.g. the Products form's entitlement-set dropdown) until the whole page is reloaded. Worse, a page reload always resets to the first tab (Bootstrap JS default), losing the operator's position. Root causes: 1. `hx-trigger="revealed"` fires only on first reveal — no cross-tab invalidation mechanism exists. 2. Tab state lives entirely in Bootstrap JS memory, not in the URL, so it cannot survive a reload. HTMX-idiomatic fixes to consider: - **URL-based tab state**: add `hx-push-url` (or `hx-replace-url`) on each tab button so the active tab is reflected in the URL fragment or a query param; on load, scroll/activate the matching tab. - **Cross-tab refresh via HTMX events**: change trigger to `revealed, ` so that a mutation in one tab can fire a named event (`htmx:trigger`) that causes dependent tabs to re-fetch their partial. - **Polling or out-of-band swap alternative**: for low-frequency mutations, an OOB swap (`hx-swap-oob`) from the mutating partial can push updated data into sibling containers without a full tab reload. ### ~~Plan concept needs depth evaluation before M6~~ ✓ Resolved Labels: `design-feedback`, `billing` `product_type = 'plan'` was a label with no behavioral depth — the system treated plans identically to other product types. M5 phase 5c intentionally deferred first-class plan treatment (one-active-plan constraint, upgrade/downgrade rules, plan comparison logic). Discovered during M5 phase 5c exploration (2026-04-11). Resolved by design commit `732197a` (latest design import): `product_type='plan'` is now structurally enforced via `plan_ladders`, `plan_ladder_tiers`, and the `product_type='plan' ↔ plan_ladder_id IS NOT NULL` CHECK; mutual exclusion per (pool, ladder) is enforced by the GiST exclusion constraint on `entitlements.pool_provision_ladders`; upgrade/downgrade semantics are expressed through `rank`. Remaining work (transition primitive, dormant status, transition audit, operator UI) is scheduled as the new Milestone 6 "Plan Management Foundation" — see `status/milestones.md`. Follow-up design feedback items: "Dormant provision status for supersession" and "provision_transitions table for plan audit history" below. ### ~~`pool_provisions.status` needs a `dormant` value for superseded provisions~~ ✓ Superseded Labels: `design-feedback`, `entitlements`, `billing` The ladder-based mutual-exclusion model in the latest design commit (`732197a`) enforces at most one active provision per (pool, ladder) via a GiST exclusion constraint, but does not specify what happens to a pre-existing provision when a higher-ranked provision activates. Original proposal was to add a `dormant` status on `pool_provisions` so a superseded provision could reversibly sleep and wake up when the superseder ends. The design team's review (2026-04-18) agreed with the placement argument but refined in two ways: (1) dormancy is not universal — a superseded trial subscription should `end` rather than sleep, while a superseded baseline grant should dormant; (2) the GiST exclusion predicate must be audited to confirm `dormant` is treated as vacant. Accommodating (1) required a new `pool_provisions.supersession_behavior` column plus per-source policy encoded in the transition primitive. On reconsideration (2026-04-18), this approach was abandoned because the accreting complexity — new status value, new column, per-source policy, GiST predicate audit, canonicalization rules against `subscription_changes` — exceeded the value over a simpler alternative. Superseded by the "end-and-re-apply on reversal" approach below. ### Supersession via end-and-re-apply (no `dormant` status needed) Labels: `design-feedback`, `entitlements`, `billing` After reviewing the per-source-dormancy complexity that the design team's feedback surfaced, the member-console team concluded that a simpler mechanism without a new provision status is preferable. Approach: - On supersession (upgrade), the existing provision and its ladder row transition to `status='ended'`. No new statuses. A new subscription-backed (or higher-ranked) provision and ladder row are created alongside. - On reversal (downgrade), the top provision and its ladder row end. The transition primitive then invokes a new pool-scoped `ReapplyDefaultsForPool(ctx, tx, pool_id)` operation that reuses the M5a-era auto-provisioning logic: look up the owning org's `org_types.default_product_id`, call `entitlements.CreateGrantInTx(...)` with `grant_reason='default'` to issue a fresh grant + provision + ladder attachment. Why this is simpler than the `dormant` approach: - No new `pool_provisions.status` value and no new columns. - No per-source policy table (trial-vs-baseline distinction disappears because *everything* ends on supersession; reversal re-applies the policy from `org_types` rather than reversing a stored state). - No GiST predicate audit burden — `ended` is already excluded from the constraint whatever the predicate shape. - No plan-stacking semantics pressure — materialization sees at most one active provision per ladder per pool, which is exactly what the GiST constraint already enforces. - Reuses existing `entitlements.CreateGrantInTx` (already extracted from M5a for in-transaction use). Required member-console work (scheduled as M6b): - A new `ReapplyDefaultsForPool(ctx, tx, pool_id)` primitive wrapping `CreateGrantInTx`. Looks up org via pool → org_id, reads `org_types.default_product_id`, issues the grant. ~20 lines around existing logic. - The transition primitive delegates to this on any downgrade that leaves the ladder empty. - Audit row in `pool_provision_transitions` records the transition; see enumeration below for the `transition_type` selection rules. `ReapplyDefaultsForPool` contract (explicit): - **If `org_types.default_product_id IS NULL`**: the primitive records a single `pool_provision_transitions` row with `transition_type='end'`, `to_rank=NULL`, and returns. No grant, provision, or ladder row is created. The pool is left legitimately off-ladder. The downgrade is still observable in audit; the re-application is a no-op by policy, not a silent swallow. - **If `default_product_id` is set**: call `entitlements.CreateGrantInTx(...)` with that product, `grant_reason='default'`. Record a `pool_provision_transitions` row with `transition_type='downgrade'` (or `'initiate'` if the pool had no prior rank) from the superseding rank to the re-applied rank. - Caller (the transition primitive) records the end-of-superseding-provision transition separately; `ReapplyDefaultsForPool` only records the re-application event itself. Audit semantics: - A pool that cycles Public → Standard → Public over time accumulates both ended provisions *and* fresh grant rows. Each grant row carries its own `valid_from`, `granted_by_person_id` (NULL for system), `grant_reason`, and `quantity` — they are the per-issuance audit record and intentionally distinct from their antecedents. - "What tier was this pool on at time T?" remains reconstructible by joining `pool_provisions` with `pool_provision_ladders` on the active window containing T; "who and why at time T?" by joining the chronological `pool_provision_transitions` record. Trade-offs — accepted deliberately: - **Grant-row accumulation.** `CreateGrantInTx` unconditionally creates a new grant row (see `internal/entitlements/grants.go:91`). An org cycling Public ↔ Standard five times produces five Public-tier default grant rows in addition to the subscription-derived grants. We considered reusing an earlier matching grant (looking up `(org_id, product_id, grant_reason='default')` and creating only a new provision + ladder row against it) and rejected it for three reasons: (1) `CreateGrantInTx` is the single call-site for all grant creation — subscription renewals flow through the same path and also create fresh rows, so special-casing defaults would fragment the code path; (2) each grant is a discrete issuance event with its own metadata, which is the audit shape we want, not a bug to optimize away; (3) operators observe accumulation naturally via the M6e enrollment/transition audit UI and can reason about cycle counts from it. Accepting accumulation is the deliberate choice, not a fallout of call-site convenience. - **Provision-row accumulation.** Likewise, a new provision row per downgrade cycle (vs. the single reversible row under `dormant`). For an org that upgrades/downgrades five times, five extra ended provisions. Same audit-value argument as for grants. - **Re-applying reads current `org_types.default_product_id`**, not the original grant's product, which means an org that held a custom default grant would be re-applied from the org-type default on downgrade rather than from the original custom grant. Noted explicitly; custom per-org defaults are not currently a supported concept. Conceptual model reinforced: - Grants are durable catalog entries ("this org is entitled to this product under this policy"); provisions are current materializations of those catalog entries. When conditions change, re-materialize from the catalog. - No new conceptual primitive is introduced; the system stays within the language it already speaks. Reconsidered on 2026-04-18 after design-team review surfaced the accreting complexity of per-source dormancy. ### New `entitlements.pool_provision_transitions` table for plan transition audit history Labels: `design-feedback`, `entitlements` The GLOSSARY update in design commit `732197a` frames `billing.subscription_changes` as "the general audit log for all subscription lifecycle events; not specific to plan transitions," leaving plan-level transitions (grant lifecycle events, grant-to-subscription supersession, repeated tier changes over an org's lifetime) without a dedicated audit record. Scenarios the existing tables cannot fully reconstruct: - An org cycles Public → Standard → Public → Standard over time. `pool_provisions.{activated_at, ended_at}` and `pool_provision_ladders.{activated_at, ended_at}` record per-row activation windows, but reconstructing the *chronological enrollment trajectory* (with actor attribution and reasons) requires an event log. - A trial grant at rank 1 issued by an operator, later superseded by a paid subscription, is a pure entitlements-lifecycle event and leaves no `subscription_changes` row. - Operator audit UI ("why is Alice's org on Standard right now?") needs a chronological, actor-attributed record that spans grants, subscriptions, and purchases uniformly. Proposed: add `entitlements.pool_provision_transitions` (renamed from `provision_transitions` per the design team's prefix-convention note to match `pool_provision_ladders`): ```sql CREATE TABLE entitlements.pool_provision_transitions ( transition_id UUID PRIMARY KEY, pool_id UUID NOT NULL REFERENCES entitlements.resource_pools(pool_id), provision_id UUID NOT NULL REFERENCES entitlements.pool_provisions(provision_id), plan_ladder_id UUID REFERENCES billing.plan_ladders(plan_ladder_id), from_rank INTEGER, to_rank INTEGER, transition_type VARCHAR(50) NOT NULL, actor_type VARCHAR(50) NOT NULL, actor_id UUID, reason TEXT, effective_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CHECK (actor_type != 'operator' OR actor_id IS NOT NULL) ); ``` Field semantics: - `plan_ladder_id` NULL for off-ladder transitions (e.g., add-on grant lifecycle). - `from_rank` NULL for initial attach; `to_rank` NULL for detach/end. - `transition_type` is strictly scoped to ladder-position changes: `{initiate, upgrade, downgrade, end}`. Suspension/resumption lifecycle stays with `subscription_changes` plus `pool_provisions.status`; it is intentionally not recorded here. - `actor_type` ∈ {`operator`, `system`, `webhook`}. - `actor_id` is required when `actor_type = 'operator'` (enforced by CHECK); NULL-allowed for system and webhook actors today, with room to populate it if webhook authentication adds actor attribution later. Canonicalization rule (to be added to GLOSSARY alongside the existing `subscription_changes` entry): - `pool_provision_transitions` is canonical for **plan-position history of a pool**: what tier a pool held, when, who changed it, why. - `subscription_changes` is canonical for **commercial mutations of a subscription**: status transitions (trialing, past_due, canceled), period boundaries, amount changes. - Subscription-driven ladder attachments are recorded in both tables — intentionally — because they answer different questions. An operator audit UI should query `pool_provision_transitions` for enrollment history and `subscription_changes` for billing lifecycle; neither is a substitute for the other. Audit module framing: - This table is designed as a *view-shaped projection* of a future generic `audit.log`. Its columns map directly to a standard audit shape: `{resource_type='pool_provision', resource_id=provision_id, actor_type, actor_id, action=transition_type, occurred_at=effective_at, recorded_at=created_at, payload={pool_id, plan_ladder_id, from_rank, to_rank, reason}}`. - Consequence: when the generic `audit` module graduates from the backlog, absorption is mechanical — a UNION ALL view across `pool_provision_transitions` (and any peer specialized logs) yields the generic log without semantic rewriting. This table can also be deprecated into a view over `audit.log` at that point if desired. Scope and boundaries: - Lives in `entitlements` because transitions are a pool/provision-level concern; the cross-module reference to `billing.plan_ladders` follows the same direction already established by `products.entitlement_set_id` and `pool_provisions.subscription_id`. - Complements, does not replace, `billing.subscription_changes`. Alternatives considered and rejected: - Deriving transition history from timestamps on `pool_provisions` and `pool_provision_ladders`: works when provisions are long-lived single rows, but does not capture actor attribution or reason, and quickly loses readability when a pool accumulates multiple ended provisions across upgrade/downgrade cycles. - Extending `billing.subscription_changes` to be a generic `provision_changes` table: overloads an existing stable table, conflicts with its scope-statement in the GLOSSARY, and pulls subscription-scoped schema into the entitlements module's primary-key graph. Discovered during M6 planning exploration (2026-04-18); refined after design-team review (2026-04-18) to add the `actor_type='operator' ⇒ actor_id IS NOT NULL` CHECK, the prefix-convention rename, the canonicalization rule for GLOSSARY, the explicit ladder-position-only scope for `transition_type`, and the view-shaped contract for the future `audit` module. The simplified `transition_type` enum (`initiate | upgrade | downgrade | end`) also reflects the supersession approach change above (end-and-re-apply instead of `dormant`), which removed the need for `supersede` and `reactivate` as distinct types. ### Auto-provisioning does not backfill existing orgs when `default_product_id` changes Labels: `design-feedback`, `entitlements`, `organization` Auto-provisioning today runs only on first login during org creation (see `internal/provisioning/provisioning.go:163-183`). When an operator configures or changes `org_types.default_product_id` after deployment, pre-existing orgs of that type are unaffected — their pools retain whatever grants (or lack thereof) they had at creation time. This is acceptable in the narrow M5a scope (new deployments configure default product once before users arrive) but becomes a concrete gap in M6: - Supersession via end-and-re-apply (see entry above) assumes `org_types.default_product_id` is always populated when a pool downgrades off the ladder. If the operator changes the default mid-deployment, existing orgs that downgrade later get the new default, but orgs that never upgraded never receive any grant adjustment. - Operator UI in M6 needs to communicate this reality and offer an explicit remediation path. Proposed member-console-level additions (M6d, operator auto-provisioning config UI): - When an operator changes `org_types.default_product_id`, show a warning: "This applies to newly created orgs only. Existing orgs of this type are unchanged. Click 'Backfill existing orgs' to apply the new default retroactively." - A "Backfill existing orgs" operator action: for each existing org of the type whose default pool does not currently have an active grant-sourced provision on the ladder, invoke the `ReapplyDefaultsForPool(ctx, tx, pool_id)` primitive. The operation is idempotent with respect to pools already holding an active baseline provision. - Backfill is recorded in `pool_provision_transitions` with `actor_type='operator'`, `transition_type='initiate'`, and a reason indicating the retroactive application. Design-layer implication: no schema change is required to support backfill — the operation is a loop over existing pools invoking a primitive that M6b adds. However, the design should explicitly acknowledge that auto-provisioning policy changes are *not automatically retroactive*, and that the retroactive pathway is an operator-driven action rather than a system trigger. Discovered during M6 planning exploration (2026-04-18).