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

114 KiB
Raw Blame History

Issues

Tracked items structured for eventual migration to Gitea issues. Resolved/closed items are archived in 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 (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 — 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.

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. 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: 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.)

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.sqlORDER 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 unreachablebilling.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.tplwikiDomains 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. 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).

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 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); 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 Creates, 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 buildPlansDatamember_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 pendingsynced (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; 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.outboxcore.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 classifiedproduct_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 schemaentitlement_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 (blockeffective_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 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.