1882 lines
231 KiB
Markdown
1882 lines
231 KiB
Markdown
# Issues
|
||
|
||
Tracked items structured for eventual migration to Gitea issues.
|
||
Resolved/closed items are archived in [archive/issues-resolved.md](archive/issues-resolved.md).
|
||
|
||
## Cross-cutting audits
|
||
|
||
### 2026-09-11 10d Slice 3 production walk — 10 findings, 4 fix-now, plus the cutover gaps
|
||
Labels: `audit`, `ux`, `correctness`, `operations`, `M10`
|
||
|
||
**Logged 2026-09-11** from the maintainer's first walk of the production deployment at console.wiki.cafe (10d Slice 3, gate G5). Each finding was checked against code and, where it is a count, against the production database over SSH. All ten belong to M10; the split below is the proposed triage, not yet confirmed by the maintainer.
|
||
|
||
**Fix now (one OpenSpec change, launch-blocking or trivially cheap) — all four shipped 2026-09-12 as `slice3-walk-fixes` (archived), which also paged the operator FedWiki sites list the new lint rule caught:**
|
||
|
||
- **~~FedWiki site count and quota gate read a counter the backfill never wrote (BUG, launch-relevant)~~ — RESOLVED 2026-09-12 (`slice3-walk-fixes`; deployed to production 2026-09-12 as image 2026-09-12T06-19Z).** The member sites card shows "1 of 64 active sites used" above a list of many more. `count` in `internal/integrations/fedwiki/web/partials.go:273` is replaced by `core.numeric_entitlement_usage.current_usage`, which only `AtomicIncrementUsage` bumps on console-driven creates; the backfill, boot adoption and farm sync insert `fedwiki.sites` rows without touching it. Production: 101 active site rows, one workspace holding 64. Consequence beyond the label: `canCreate = currentUsage < resourceLimit` (`partials.go:272`, `api.go:460`), so every backfilled member is over-quota-blind and can keep creating sites. Fix: derive usage for countable resources from `CountActiveSitesByWorkspace` under the existing per-workspace advisory lock, or have every row-inserting path maintain the counter; the first is the smaller surface. Either way the production counters need a one-time repair.
|
||
- **~~People tile caption counts the system person (BUG)~~ — RESOLVED 2026-09-12 (`slice3-walk-fixes`; deployed to production 2026-09-12 as image 2026-09-12T06-19Z).** Tile says 13, caption says "14 joined in the last 30 days". `CountActivePersons` is corrected by subtracting the reserved system person (`operator_overview.go:120-133`); `CountPersonsJoinedLast30Days` (`internal/identity/queries/persons.sql:54`) is not, and the system person is created at first boot so it always lands inside the window. Fix: apply the same exclusion. Related: backfilled persons carry the import date as `created_at`, so the caption reads as "everyone joined this month" for the whole first month of a migrated deployment.
|
||
- **~~Operator Domains page renders every claim with no pagination~~ — RESOLVED 2026-09-12 (`slice3-walk-fixes`; deployed to production 2026-09-12 as image 2026-09-12T06-19Z).** `GetDomainsPage` (`operator_domains.go:349`) calls `loadDomainsPageData` with no page state and the template has no list controls. `operator-list-scale` enumerates the governed lists (organizations, grants, people, four billing views) and domains was never added when Slice 2 shipped, so no spec or lint refused it. This is why exceptions survive: the convention is an enumerated list, not a property of "a table that grows with the deployment". Fix: add domains to the governed set (spec delta) and wire `operator_list_controls`; add an anatomy-lint rule that flags a body table without list controls unless allowlisted, so the next runtime list cannot ship unpaged. **Residual after `slice3-walk-fixes` (2026-09-12):** the rule checks per template, so a second table beside a governed list (Domains placements and history, the secondary tables on the grants, organizations, people and products pages) escapes it; the follow-up is a proximity rule that wants the controls or the marker within a few lines above each table. The rule did catch the operator FedWiki sites list on its first run, which is now paged too.
|
||
- **~~"Multiply by purchased quantity" on rules that reach members through grants~~ — RESOLVED 2026-09-12 (`slice3-walk-fixes`; deployed to production 2026-09-12 as image 2026-09-12T06-19Z).** Label and help in `operator_entitlement_set_forms.go:224-227`. Grant quantity multiplies the limit exactly the same way (`materialize.go`, `value = base × quantity`; the backfill's grandfathering relies on it), so the copy is wrong for the grant path, not only awkward. Fix: quantity-neutral label ("Per unit") and help ("Multiplied by the quantity purchased or granted").
|
||
|
||
**Design, one exploration (products, entitlement sets, taxonomy):** the maintainer's five product-model observations are one problem seen from five screens and should be researched together before any spec, per the eradication rule.
|
||
|
||
- Product creation forces a side-quest to create an entitlement set first; the maintainer cannot name a case where a set is authored in isolation from a product. Question: is a set a first-class object or a product's rules?
|
||
- Product detail names its entitlement set with no way to see the rules or reach them.
|
||
- Editing an entitlement set to add a boolean rule (custom domains) confused the operator; the text above the form is not enough.
|
||
- Lifecycle "Published" with Public off is the state of every grant-only product; the vocabulary reads as "for sale".
|
||
- `display_category` feels like dead weight; it is the residue of Doc 41 dissolving `kind`, and it has 42 code references and one badge.
|
||
|
||
**Design, one exploration (domains and FedWiki):**
|
||
|
||
- The custom-domain chooser is a poor experience; the maintainer suspects domains and FedWiki capabilities need to be conveyed as decoupled. Follows 10d; the registry already separates claims from placements, the UI does not.
|
||
|
||
**Cutover gaps found the same day (not findings against code):** Stripe secrets absent (G2), no backfill report on record (G3), persons 14 against a planned ~26 and two sites left unplaced by adoption (G4), sync disabled (G6), Caddy on-demand ask still answered by the filesystem answerer (G7), old stack removed before soak (G8).
|
||
|
||
### 2026-09-09 security audit, second run (10l) — 5 confirmed, 2 notes
|
||
Labels: `security`, `audit`
|
||
|
||
**Logged 2026-09-09** from the second outside-model run against the remediated tree (0b28a9d); every item below was verified live on the test stack, one recorded verdict per item. Fix as one OpenSpec change.
|
||
|
||
- **Database DSN with password logged at boot** (High/Low). `internal/db/database.go:93` and `:111` log the full DSN at Info on every DB-touching command; `internal/config/validate.go:40` echoes a malformed DSN into its error, which start logs. `db-dsn` was never marked secret in the config spec, so the existing masking never applied; every other secret checked is not logged. Fix: mark the key secret; log host, database and user only.
|
||
- **No session rotation at the OIDC callback** (Medium/High). `CallbackHandler` (`internal/auth/auth.go:487-750`) binds the identity without `RenewToken`; the cookie is byte-identical before and after sign-in. An attacker who starts a login, plants that state-matched cookie, and gets the victim to complete sign-in owns the session. Planting needs an on-path attacker on plaintext HTTP or host compromise (host-only, HttpOnly cookie), which is what keeps it Medium. Fix: renew the token after the state, nonce and PKCE checks; extend the spec's rotation requirement to the callback.
|
||
- **Anonymous requests persist a seven-day session each** (Medium/Trivial). `bounceToLogin` stores the return path and `LoginHandler` stores state/nonce/verifier, so every bare `GET /` or `GET /login` writes a store key with the full lifetime; 200 requests in 0.09 s added 201 keys, no throttling, and the store has no memory cap. Under `noeviction` sign-ins fail first; under LRU, real sessions are evicted. Fix: no store write until there is something to keep, a short pre-auth lifetime, and documented `maxmemory-policy` and reverse-proxy rate-limit guidance.
|
||
- **GET `/logout-callback` destroys a live session** (Low/Low). The handler calls `Destroy` unconditionally on a public GET; cross-origin protection skips GETs and Lax cookies ride top-level navigations. Reproduced from a foreign origin with a bogus `state`. `logout-ends-session` tested only the no-session and stale-cookie cases. Fix: an unguessable short-lived logout state issued at sign-out and required at the callback; the provider-initiated GET must keep working.
|
||
- **FedWiki delete, archive and status-change are an existence oracle** (Low/Low). `internal/integrations/fedwiki/web/api.go:357-362` and `partials.go:710-715,806-809` answer "Site not found" for a nonexistent site and "You do not own this site" for another tenant's; restore and keep-active (`partials.go:909,954`) already collapse both. The run-1 position covers only the custom-domain claim path. Fix: answer "Site not found" for a foreign site in the three handlers.
|
||
|
||
Notes, no fix required: three operator screens (person detail, lookup candidates, enrollment member list) show the email without the unverified badge the persons list already renders; a sign-in that yields no refresh token silently falls back to the week-long identity snapshot with no log line. Closed: operator role read from four claim locations (normative in `oidc-login`, needs an identity-provider misconfiguration against the setup guide); Discourse webhook replay (standing position, reconcile re-derives state); the five-minute revocation bound (verified live: 403 past the interval).
|
||
|
||
### 2026-08-22 first-contact UX re-walk (10g) — 19 findings, maintainer-triaged fix-all
|
||
Labels: `audit`, `ux`, `first-contact`
|
||
|
||
The 10g re-walk ran per [first-contact-ux-process.md](../docs/first-contact-ux-process.md) (v1):
|
||
five naive browser/docs walkthroughs, two outside models (Kimi K3, DeepSeek),
|
||
48-screen dual-state evidence sweep, eight model-card honesty audits, three
|
||
independent heuristic reviews, and a code-verification pass that reclassified
|
||
three of four headline mysteries as environment artifacts before they could
|
||
mislead. The walk was synthesised into 19 deduplicated findings
|
||
UX-1…UX-19 (four severity-4). **Maintainer triage
|
||
2026-08-22: everything ships inside M10** (no won't-fix bucket exercised;
|
||
one reframe — the IdP realm name stays untouched, the app instead gets a
|
||
configurable deployment name applied to both surfaces). Work is sliced into
|
||
five 10g changes; see the 10g row in [milestones.md](milestones.md).
|
||
|
||
### 2026-07-02 adversarial UX audit — 56 confirmed findings (16 high)
|
||
Labels: `audit`, `ux`, `bug`
|
||
|
||
An adversarial operator/member UX audit (8 code-auditor agents over every operator and member flow; findings adversarially verified and deduped by a skeptic judge — 70 raw → 56 confirmed / 3 rejected) landed **56 confirmed findings: 16 high, 21 medium, 19 low**, dominated by lying copy, silent breakage, and dead ends. Headline highs: trial-days are collected, stored, and displayed but **never honored** anywhere in the billing chain; revoking an addon/usage/one-time grant **ends the org's plan attachment** while the addon keeps delivering; upgrading after a scheduled cancel **keeps the cancellation armed** (member pays for a switch, then loses the subscription at period end); checkout has no already-subscribed guard, so a stale tab creates a **second concurrent Stripe subscription**; a second entitlement rule for the same resource key turns the set into a **poison pill** that aborts all future materialization; the org-type backfill **mass-downgrades** orgs not exactly at the default rank-0 tier behind confirm copy claiming attachments are unchanged; and the ladder/set **Active toggles are complete no-ops** — inactive ladders still sell and provision.
|
||
|
||
Note the audit ran against the tree **before** that day's fixes (live-poll readiness, ladder append-at-end, db-error translation, multi-price), so each finding needs triage; the highs should be triaged into milestone phases (10e hardening / 10g first-contact UX, or dedicated changes).
|
||
|
||
**2026-07-03 update: remediated (10h, committed).** All 56 findings executed per that day's triage: 43 quick-fixes, 4 de-advertisements, the custom-domain interim hide (real remedy 10d), and 7 interim guards for structural-defers (17, 18, 19, 24, 25, 50; 56 verified as-is). The 7 structural *real* fixes still gate on 10i model cards — the deferred remainder is inventoried in the next issue. Verified: build/vet/lint clean, full DB-backed suite green, and the operator-walkthrough browser e2e suite green against the live stack. Two adjacent defects found and fixed during verification: the request-timeout middleware crashed the whole process on any request outliving its deadline mid-write (concurrent header-map write; replaced with `http.TimeoutHandler`), and the per-package test DB helpers assembled a truncated migration source list that mis-numbered fedwiki's goose versions against an app-migrated database (canonicalized in `internal/migrate.Sources()`).
|
||
|
||
### Deferred-remediation debt from the 2026-07-02 audit — 11 postponed items
|
||
Labels: `audit`, `debt`, `tracking`
|
||
|
||
10h resolved the audit's *symptoms*; these items were deliberately postponed and each stays open until its real remedy lands. Four are **de-advertised affordances** (hidden or re-scoped instead of built — each needs either the real build or a conscious decision to drop the feature), and seven are **interim guards** whose real fixes gate on the 10i model cards for their root-cause model. The custom-domain hide is *not* debt in this sense — it is scheduled work tracked as milestone phase 10d.
|
||
|
||
**De-advertised — hidden/re-scoped until built (or consciously dropped):**
|
||
|
||
1. **Trial period days** (`payments-billing`) — the price form no longer offers trial days; the value was collected, stored, and displayed but never honored anywhere in the billing chain. Real work: build trials in the payments model (Stripe `trial_period_days` through checkout, fulfillment, and entitlement timing) — or drop the column.
|
||
2. **'Replace' stacking policy** (`entitlements-materialization`) — removed from the rule form's stacking dropdown and rejected server-side; it contributed 0 to materialized limits. Real work: define replace semantics in the materialization model, or retire the enum value. **Update 2026-08-22:** authoring is now additive-only (`schema-hardening`), so no non-additive rule of any kind can be created; the semantics question moved into "Stacking policies need a design exploration".
|
||
3. **Entitlement-set Active toggle** (`entitlements-materialization`) — re-scoped to its real, enforced meaning ("Show in operator pickers"); it never affected delivery. Real work: set-retirement semantics — what happens to products selling, and pools provisioned from, a retired set.
|
||
4. **Ladder Active toggle** (`plan-transitions`) — fully hidden 2026-07-03 (edit-form toggle, list column, and topology badge removed; `UpdatePlanLadder` preserves the stored value). It was a complete no-op: inactive ladders still sold and provisioned. Real work: ladder retirement — an inactive ladder must stop selling to members and provisioning new orgs; re-surface the control only then.
|
||
|
||
**Interim guards — real fixes gate on 10i model cards.** *(2026-08-21: the cards exist (`docs/models/`), so every gate below is settled — each open item now names the card that carries its root-cause model. Items 8 and 9 were already resolved 2026-07-11.)*
|
||
|
||
5. **Synchronous workflow waits** (`fedwiki-lifecycle`) — guard: a 6s wait budget then an honest "still working" response + list refresh (plus the `http.TimeoutHandler` rewrite). Real work: a pending-state lifecycle — persist in-flight site operations and render them as first-class states instead of blocking requests on Temporal. *Card gate: the provider-resource lifecycle sits adjacent to the provider-integration card (per-instance lifecycle dimension); the pending-state design is unblocked.*
|
||
6. **Entitlement lapse hides live sites** (`fedwiki-lifecycle`) — guard: transient lookup failures render a try-again notice and sites stay listed; genuine no-entitlement shows a warning above the still-listed sites. Real work: lapse wind-down UX (what members see and can do when entitlements lapse) in the lifecycle model. *Card gate: the entitlements card pins the lapse mechanics (boolean lapse-to-false, provision suspension vs end); the UX design is unblocked.*
|
||
7. **Rule deletion never re-materializes** (`entitlements-materialization`) — guard: the delete-rule confirm now states honestly that existing pools keep their current limits. Real work: re-materialization semantics for rule changes (when do existing pools re-evaluate?). *Card gate: the entitlements card pins materialization's transactional contract, and its ledger entry ("Rule changes never reach existing pools") is the same item sharpened — design the fan-out there.*
|
||
8. **Backfill vs. enrolled orgs** (`plan-transitions`) — guard: backfill is initiate-only (pools with any active plan attachment are counted but never transitioned; the NULL-default end-to-baseline path is disabled). Real work: reapply semantics — what "backfill" should mean for orgs already on a plan, including deliberate mass moves off a plan type. **2026-07-11 resolved** by `org-type-default-change-flow`: the standalone backfill dissolved into a select → preview → commit flow whose classification gives every population a decided disposition — plan-less pools initiate, outgoing-default holders are explicitly grandfathered (operator-attributed `legacy` grants, `transfer`-superseded) or migrated (end + floor-guarded re-apply), other-source positions are never touched, and clearing the default to NULL is a defined end-to-baseline commit. The interim guard comment and `SkippedActive` punt are deleted; the restoration vacancy guard is broadened to floor semantics (any live plan position blocks it) across revoke/expiry/cancellation.
|
||
9. **Non-plan grant revoke** (`product-kind`) — guard: grants whose product sits on no ladder revoke via `RevokeGrantAndRematerialize` instead of `Transition(End)` (which ended the org's *plan*). Real work: the product-kind model — route all kind discrimination through `billing.product_kinds` (see the read-discipline issue below) and give each kind its own revoke path. **2026-07-11 resolved structurally** by `doc41-conferral-uniformity` (upstream Doc 41): revocation is decree-first + `core.end_conferral` resolved by source — "whatever's on the ladder" is no longer expressible, and the interim guard and `RevokeGrantAndRematerialize` are deleted.
|
||
10. **Workspace status semantics** (`workspace-identity`) — guard: badge color maps from status and `SwitchWorkspace` refuses non-active workspaces. Real work: define the workspace lifecycle (what suspended/archived mean, who transitions them, member-facing behavior). *Card gate: the identity card pins the as-built truth (free-text status, switch guard as sole reader) and its ledger carries the unchecked-status gap; the lifecycle design is unblocked.*
|
||
11. **Member self-serve workspace creation** (`workspace-identity`) — guard: the Create Workspace affordance stays hidden behind the existing count>1 gate (hidden, not lying). Real work: decide the creation policy in the workspace-identity model, then relax the gate in `server.go`. *Card gate: the identity card pins workspace creation as-built (default-pool attachment, the swallowed-lookup trap in the pools ledger); the policy decision is unblocked.*
|
||
|
||
## Entitlements & plan transitions
|
||
|
||
### Transition primitive: multi-axis & departed-ladder gaps (transition-primitive audit, 2026-06-24)
|
||
|
||
**2026-07-11: both gaps closed by `doc41-conferral-uniformity`.** Gap 1's named fix direction ("give End an explicit scope, not newest-pool-wide") landed as `core.end_conferral` resolving strictly by source arc, DB-enforced via the per-source live-uniqueness indexes; gap 2 landed as the reconcile diff (desired-by-product vs live provisions; departed products are ended via `end_conferral`). The `Transition` primitive itself is retired behind the enclosed `core.confer` function family.
|
||
|
||
**Surfaced 2026-06-24** by a deliberate read-only audit of `entitlements.Transition` and its callers, run after fixing the two `reapplyDefaultsForPool`-delegation bugs (`transition-end-reapply-narrowing`, `transition-downgrade-honors-target`). The primitive and the Stripe reconcile path were built for today's **single-axis, 2-rung, evergreen** catalog; two latent gaps appear only once the **multi-axis** model (`member-ladder-aware-catalog`) or multi-item subscriptions go live. **None has a live trigger today.** Cross-verified against code; bundled here since they share a root (per-ladder/per-axis scoping) and a single consumer (multi-axis rollout). Four other candidate gaps were checked and **verified clear** (see bottom). (A third audit finding — the `Extend` spec self-contradiction — was resolved by `transition-extend-replace-semantics`.)
|
||
|
||
1. **`Transition(End)` ends an arbitrary attachment on multi-axis pools.** `resolveCurrentAttachment` for an End target returns `rows[0]` of `GetActiveAttachmentsByPool` (`internal/entitlements/queries/pool_provision_ladders.sql` — `ORDER BY activated_at DESC`, no axis filter), and `Transition` ends only that one (`transitions.go:176-183`, `:305-313`). The End target carries no subscription/provision/axis identifier. `endSubscriptionEntitlement` (`internal/fulfillment/reconcile.go:410-435`) computes `hasActive` for a *specific* subscription's provision but then calls `Transition{End:true}` with only `poolID` — so on a pool with >1 active attachment it can end the **wrong** axis (the most-recently-activated one). Grant-expiry (`internal/workflows/entitlements/activities.go:95`) and operator revoke pass bare `End:true` too. Latent (live pools have ≤1 attachment, so `rows[0]` is unambiguous). **Fix direction:** give `End` an explicit attachment/subscription scope (end the attachment for *this* provision/axis), not "newest pool-wide." High-severity blocker for multi-axis rollout.
|
||
|
||
2. **Reconcile converges *present* subscription items but never ends *departed* ladders.** `reconcileItems` (`internal/fulfillment/reconcile.go:266`) loops only `sub.Items.Data` (create/update); nothing ends entitlements for an item/ladder no longer on the subscription. Whole-subscription cancel is handled (`endSubscriptionEntitlement`), but partial removal is not. Three manifestations, one root cause: (a) a removed item that was the **sole occupant of a ladder** orphans its active attachment; (b) a **cross-ladder** paid→paid switch (new price tiers into a different ladder) classifies as `initiate` on the new ladder while the old ladder's attachment is never ended → two active attachments; (c) stale `billing.subscription_items` rows accrue (cosmetic). Masked today because live subscriptions are single-item/single-ladder and a same-ladder price swap is an in-place modify that supersedes correctly. **Fix direction:** reconcile should diff present-vs-prior ladders and end attachments for departed ones (pairs with #1's scoped-End). Tracks against `member-ladder-aware-catalog`.
|
||
|
||
**Verified clear during the same audit (recorded so they aren't re-investigated):** (i) the classification switch's `default` "same-rank, different-product" branch is **unreachable** — `billing.plan_ladder_tiers` has `UNIQUE (plan_ladder_id, rank)` (`00012_plan_ladders.sql:32`), so two products can't share a rank on one ladder; it's defensive dead code. (ii) `Transition(End)` with no current attachment correctly drops to baseline / reapplies the default, and the new end-at-default no-remint guard is correctly gated on `prior != nil`. (iii) A cross-ladder *non-end* target leaving both ladders active is **intended** (multi-axis = one active plan per axis), not a leak — it only matters if a cross-ladder *replace/switch* semantics is ever wanted (bundles, service migration). (iv) Reconcile is **idempotent** under double-fire (`pg_advisory_xact_lock`, same-target no-op, drift guards).
|
||
|
||
### Doc 41 rollout notes (doc41-conferral-uniformity, 2026-07-11)
|
||
Labels: `entitlements`, `billing`, `upstream-membcons-db`, `ops`
|
||
|
||
Three notes from implementing upstream Doc 41 (Decisions 134–139):
|
||
|
||
1. **Purchase arc is dormant.** `core.confer` implements the purchase source per Doc 41 (exclusive arc, live-uniqueness index on `purchase_id`), but member-console has no purchase-record flow — `pool_provisions.purchase_id` remains a loose slot with no purchases table and no Go caller. When one-time purchases ship, they get a caller of the same primitive, not a new pathway.
|
||
2. **Billing residue is a named upstream open question** (Doc 41 §11.2/§12): a grant superseding a subscription-held position correctly ends the position, but the subscription keeps billing for a tier the pool no longer holds. Doc 41 deliberately does not reach into billing; downstream mitigation is the issuance-form advisory warning the operator before conferring over a subscription-sourced position. Revisit when the design team logs the follow-up decision.
|
||
3. **Rollback is snapshot-based.** Migrations 00004–00006 carry mechanical Down sections (views, rename, functions, grants), but the data normalizations (reason buckets, set-direct repoint, provision `product_id` backfill) are one-way; the rollback unit for a bad window is the pre-migration snapshot plus the previous binary. The old binary cannot run against the migrated schema — its direct position-table DML fails loudly (enclosure), which is the intended fail-closed posture.
|
||
|
||
### Design gap: grants.status lifecycle vs operational delivery is undocumented
|
||
Labels: `design-feedback`, `documentation`, `upstream-membcons-db`
|
||
|
||
`design/entitlements/model.md` defines `grants.status ∈ {active, expired, revoked}` and `pool_provisions.status ∈ {active, suspended, ended}` independently, including the "Pool Provision Status" table at §pool_provisions that maps grant lifecycle events to provision lifecycle outcomes. What it never states is the **converse invariant**: a grant whose provision is ended by a `Transition` or `Extend` stays `grants.status='active'` indefinitely — `grants.status` only changes through explicit revocation or time-based expiry. The grants table is a ledger; operational delivery lives one layer down on the provision and ladder-attachment status fields, and the GiST exclusion constraint on `pool_provision_ladders` guarantees at-most-one active provision per `(pool, ladder)`.
|
||
|
||
This silence cost real bugs in `member-console`: both the operator per-org composite and the member-facing entitlements Sources panel were filtering grants by `status='active'` and treating the result as "currently delivering," producing visible UI lies (5+ "active" Public Plan rows for a pool the schema guarantees has exactly one active provision). Fixed downstream by adding `ListGrantsWithDeliveryByOrgID` (LEFT JOIN with derived `delivery_state`) and `ListDeliveringGrantsByOrgID` (INNER JOIN on active provisions), and documenting the rule in `docs/operator-ux-conventions.md` §9a.
|
||
|
||
Upstream ask: add a short paragraph to `design/entitlements/model.md` §grants (or §pool_provisions) explicitly naming the lifecycle-vs-delivery split — something like *"`grants.status` reflects only formal lifecycle events (revoke / expire). Asking 'which grants currently deliver entitlements to a pool?' requires joining to `pool_provisions` with `status='active'`, not filtering on `grants.status`. A grant remains `active` after its provision is ended by `Transition` or `Extend`; this is intentional and preserves the audit trail."* The companion spec (`openspec/specs/entitlements/spec.md` §Grants) likewise has nothing on this invariant and would benefit from a one-paragraph requirement codifying it.
|
||
|
||
**2026-07-11 update:** the downstream half closes when `doc41-conferral-uniformity` archives — its `entitlements` delta codifies grants-as-ledger (delivery answered from provisions) as a spec requirement. The upstream half is committed by Doc 41 §7.3 (documentation sync incl. `extends_grant_id` and the status-vocabulary correction); keep this open until the `model.md` paragraph actually lands in the design repo.
|
||
|
||
### Per-org disposition overrides in default-change / reorder / removal previews
|
||
Labels: `enhancement`, `plan-transitions`, `operator-ux`
|
||
|
||
The consequence previews for org-type default changes, tier reorder, and tier removal all apply one disposition to the whole outgoing-default population: every affected org is either grandfathered/kept or migrated. Mixed dispositions — grandfather some orgs, migrate the rest — were deferred from `org-type-default-change-flow` (archived 2026-07-12, see its Open Questions). If wanted later: the commit payload grows a per-org override list and the preview grows per-row controls (the count-first disclosure lists already render one row per org), with no change to the enactment shapes — `enactOrgDisposition` already operates per org. Deferred 2026-07-11; promoted here from the archived change so it stays findable.
|
||
|
||
### MaterializePoolEntitlements never lowers a key's limit when its last active rule goes
|
||
Labels: `bug`, `entitlements`
|
||
|
||
**Logged 2026-09-14** from the `entitlement-set-changes` design research (every candidate design failed the same stress finding; verified by reading the function). `MaterializePoolEntitlements` (`internal/entitlements/materialize.go:16-160`) builds `contributionsByResource` only from resource keys that at least one active provision's set still carries a rule for, calls `UpdateNumericEntitlementLimit` only inside the loop over that map, and zeroes limits only for provisions whose status is `ended`. So when the rule that fed a key is deactivated or deleted while its provision stays active, the key vanishes from the map, the loop never visits it, and `numeric_entitlements.resource_limit` keeps its old value with its stale `numeric_entitlement_contributions` rows intact. Nothing exposes it today because the rule delete path hard-deletes the row and never re-materializes anything ("rule changes apply at the next conferral"), and the incident's path (a rule added to a rule-less set) works because the key appears in the map. It is the prerequisite for any change that re-materializes pools after a rule change: without it, removing a rule lowers no limit while the preview says it does. Upstream's semantics for the repair (batch 2, answer 5): prune the contribution for the missing rule, recompute the limit from surviving contributions, a key with none goes to limit 0 with the entitlement and its usage row retained, usage untouched; a boolean key with no active rule flips `is_enabled` false and keeps the row. Fix inside `entitlement-set-changes` as its first task, with a test that removes a set's only rule for a key and asserts limit 0 and no surviving contribution.
|
||
|
||
### Pending recompute obligations carry no visible age
|
||
Labels: `entitlements`, `operator-ux`
|
||
|
||
**Logged 2026-09-15** from the `entitlement-set-changes` design's risks. The set page's transient line `3 pools not recomputed.` and the History cell `412 of 4,812 recomputed.` count pending obligations without saying how long they have been pending, so a drain that finished a second ago and one that stalled at the last deploy read the same until attempts are exhausted and the failed line appears. The data exists: `core.entitlement_set_change_obligations.created_at` is the age (doc-47 §3.2, recorded in the companion under the resolved Issue 37). A follow-up can carry the oldest pending row's age on the transient line or in the History cell without a new column; whether that is a fact the operator needs on the page or a number for the log is the open question.
|
||
|
||
### Reduction policies from several sets on one pool: the sweep folds them, nothing else does, and the preview can contradict the sweep
|
||
Labels: `entitlements`, `correctness`, `design-gap`
|
||
|
||
**Logged 2026-09-18** (maintainer, during the rule-change UX rethink: "what do we do when reduction policies get combined? ... one entitlement set says one thing and then another says another thing and they both are applied to one pool"). Half considered. The only reader that combines is `GetGoverningReductionPolicy` (`internal/entitlements/queries/entitlement_set_rules.sql:22-39`): over the active limit rules that fund a pool's key through its active provisions, `force_reduce` wins, then `clamp`, else `MIN()` of the rest, so `block` beats `defer`. Its sole caller is the FedWiki quota sweep (`internal/integrations/fedwiki/workflows/reconcile.go:133`). The fold is stated in the query's comment and nowhere else: `fedwiki-sites` (`:57`) and `plan-downgrade` (`:69,90`) say "the governing rule" in the singular, and the archived `entitlement-set-changes` design (A14, A18) says "the sweep reads the rule's policy" without naming what happens with two rules.
|
||
|
||
Three consequences. (1) The rule-change preview discloses the **proposed rule's** policy alone (`internal/entitlements/rule_change.go:321-323`, `consequenceFor` at `:408`), so for a pool funded by two sets it can say "The limit applies now. Usage above it is kept." while the sweep, reading the fold, parks the excess because the other set says `force_reduce`. (2) The effects ledger stamps the **changed rule's** policy on each effect row (design.md:194, Decision 147), not the policy that governed the pool, so History records a decision the sweep may not have taken. (3) Today only `force_reduce` changes behaviour: `block` and `defer` have "no boundary at a rule commit" (`entitlement-set-management` spec `:268`) and the console has behaved as `clamp` on downgrades since v14 (design A18, doc-47 §13 item 13), so the four-option select offers two choices that do the same thing as `clamp`.
|
||
|
||
**Decided 2026-09-18 (maintainer): strongest wins**, the fold the current query already computes, chosen over the alternative (the primary provision's rule governs), because a stronger promise to the member's provider cannot be weakened by a second product they also hold. What follows, owed by the rule-change redesign change, which rebuilds the preview anyway: a spec requirement stating the fold; the preview's disclosure and consequence sentence computed per pool from the governing policy; the effect row stamping the governing policy beside the rule's; and the control offering only the values that do something until `block` and `defer` are wired. The as-built definition of the four values, their strength and the fold now lives in `docs/models/entitlements.md`, "Reduction policy"; the combination rule is drift to raise upstream as a `membcons-db` Stage-1 issue once the console's spec states it.
|
||
|
||
**Built 2026-09-18** by `staged-rule-changes`: the fold in Go (`governingPolicy`, `internal/entitlements/materialize.go`) and the query state the same explicit order and share a test; every effect row carries `governing_policy` beside the rule's own (migration 19); the tray offers only Clamp and Force reduce, keeping a stored `block` or `defer` as its own option. The per-pool disclosure did not return to the surface (the maintainer removed the per-change summaries); the batch dry run carries the governing policy for the review-details link a later change adds. Open here: the upstream Stage-1 issue.
|
||
|
||
## Billing, Stripe & purchasability
|
||
|
||
**Resolved 2026-09-15 by the `entitlement-set-changes` change, under Decision 143 (a rule change propagates to every pool carrying the set), as the materializer repair the change's task group 1 delivered.** `MaterializePoolEntitlements` now computes the whole fold before it writes (`computePoolFold`), so a key's state is derived from the active rules alone; `applyPoolFold` zeroes a numeric key and lapses a boolean key whose last active rule went, and the same fold is what `DryRunPoolEntitlements` shows the preview. Tests `TestMaterializeLastRuleDeactivatedZeroesLimit` and `TestBooleanLastRuleDeactivatedLapses` in `internal/entitlements/materialize_test.go` pin both directions.
|
||
|
||
### Checkout is headed by a business name the console never introduces
|
||
Labels: `billing`, `ux`, `operations`, `M10`
|
||
|
||
**Filed 2026-09-20**, launch-relevant. The Subscribe control hands off to Stripe Checkout with a bare redirect (`BillingCheckoutHandler`, `internal/server/billing.go:185`), and the Checkout page is headed by the Stripe account's public business name, which Stripe fills from the account's legal name until the operator sets one. When that legal name differs from the deployment's brand, the first time a member reads it is above the card fields on a stripe.com page, with nothing on the console's own domain having said who charges the card. Both UX walkers reported the surprise independently against the test stack's sandbox account (M5 member-purchase walk, 2026-08; M1 first-boot walk, 2026-09).
|
||
|
||
Two layers, deployment first:
|
||
|
||
- Stripe Dashboard: set the public business name, icon and statement descriptor (Settings, Business details, Public details; Branding) on the live account and the sandbox. Nothing in the console tells the operator this matters or shows what Checkout will display.
|
||
- Console: the Stripe integration page (`operator_integration_stripe.html`) and the setup readiness check read the account back (`stripe.Account.Get`: `business_profile.name`, `settings.branding`, the statement descriptor) and show the name members will see, flagging an unset one the way the environment check flags a stale price; the member billing page states the merchant of record from the same read before the hand-off (one fact, "Charged by <name> through Stripe"), so the name is on the console's domain before it is on Stripe's.
|
||
|
||
### Product retirement and Stripe-mapping visibility in operator UI
|
||
Labels: `design-feedback`, `billing`, `ux`
|
||
The operator product UI today has no archive/delete affordance — products that go out of fashion accumulate. Two facts shape what the right answer is:
|
||
|
||
1. Local products are 1:1 mapped to Stripe products via `stripe.product_mappings(product_id, stripe_product_id, sync_status)`. Deleting a local product silently breaks that mapping; even if the operator is OK with the local row going away, the Stripe-side product (which Stripe never deletes — it archives) and the mapping row need a coherent story.
|
||
2. M6a already plans a `lifecycle_status` column on `billing.products` (`draft` / `published` / `retired`). That is the right primitive: published products are sellable; retired products cannot be granted to new orgs but existing grants survive; draft products are operator-visible only.
|
||
|
||
What 7b/7c should do:
|
||
- Surface the Stripe mapping in the product UI — operators need to see which local product maps to which Stripe product, and the sync status of that mapping. Today this relationship is invisible from the panel.
|
||
- Replace any future "delete product" affordance with a "retire product" action that flips `lifecycle_status` to `retired` (gated on whether any active grants exist; prevent retire if so, or offer a clear cascade preview).
|
||
- Same logic applies to entitlement sets that are referenced by products and to plan ladders that have orgs enrolled.
|
||
|
||
Discovered during M7 phase 7a (2026-05-08).
|
||
|
||
### Multi-currency money on the operator overview: largest bucket shown, no conversion
|
||
Labels: `billing`, `operator-ui`, `enhancement`
|
||
|
||
The overview's Monthly recurring headline and Open invoices outstanding caption (`overview-money-and-teams`, 2026-07-27) sum money per currency and display only the largest currency bucket, acknowledging the rest in the caption ("plus N more currencies"). That is honest but incomplete for a deployment that genuinely bills in several currencies: the headline understates total recurring value.
|
||
|
||
Doing better means converting, and converting needs infrastructure this codebase deliberately doesn't have yet: a rate source (ECB feed? manual operator-entered rates?), a staleness policy for those rates, and a display convention that doesn't present converted sums as exact ("~$3,100 equivalent"). Stripe-side reporting solves this with Stripe's own FX data — a `stripe` provider surface might be the cheaper path than a core rate table. Single-currency deployments (the expected norm) are unaffected either way.
|
||
|
||
Filed 2026-07-27 while replacing the subscriptions count tile with monthly-normalized recurring money.
|
||
|
||
### The environment check reads back products and prices, not customers
|
||
Labels: `billing`, `enhancement`, `low-priority`
|
||
|
||
**Filed 2026-09-20** by `stripe-environment-stamp` (design D3). The
|
||
check that runs after an API key change asks Stripe for every synced
|
||
product and price under the current key and marks the ones it cannot
|
||
find `stale`. Customer mappings are not read back. A customer mapping
|
||
that records the other environment is caught without a read (the
|
||
recorded `livemode` disagrees with the key, and checkout creates the
|
||
customer again), but a move between two sandboxes leaves both worlds
|
||
reporting test, so a customer id from the old sandbox stays `synced`
|
||
until that member's next checkout fails on `resource_missing` and the
|
||
console creates the customer again. Extending the check to
|
||
`stripe.customer_mappings` is one more loop over one more table in the
|
||
check activity (`internal/integrations/stripe/workflows`), once the
|
||
two-table run has been observed in production.
|
||
|
||
### Stripe environment stamp rollout notes (stripe-environment-stamp, 2026-09-20)
|
||
Labels: `billing`, `ops`
|
||
|
||
Two notes for the first wiki.cafe deploy after this change (design D8):
|
||
|
||
1. **The migrations add columns and no data.** Store migration
|
||
`00004_mapping_environment.sql` adds nullable `livemode` to the eight
|
||
`stripe.*_mappings` tables and `verified_at` to product and price
|
||
mappings; core migration `00020_webhook_event_environment.sql` adds
|
||
nullable `provider_environment` to `core.webhook_events`. Every
|
||
existing row stays NULL, which reads as unverified and blocks nothing:
|
||
readiness, checkout, reconcile and the billing views treat a NULL row
|
||
as the key's own.
|
||
2. **The first boot runs the check by itself.** Boot finds no
|
||
`stripe.environment_check` record in `core.instance_settings`, starts
|
||
`StripeEnvironmentCheckWorkflow`, and the check reads every synced
|
||
product and price back under the live key, one GET each, a few dozen
|
||
in total. They come back `Synced, live` and verified with no operator
|
||
step; the Stripe integration page's Environment check section shows
|
||
the result (`36 checked, 0 stale.`). If Temporal is unreachable at
|
||
boot the start is logged and the section offers **Check now**
|
||
to run it by hand. The projection rows (customers, subscriptions,
|
||
invoices, payments) stay NULL until their next webhook rewrites them.
|
||
|
||
## FedWiki integration
|
||
|
||
### Member-console has no UI to manage sites it doesn't own (operator panel read-only; ownerless sites unmanageable)
|
||
Labels: `enhancement`, `fedwiki`, `operator-ux`, `member-ux`, `affordance-gap`
|
||
|
||
**Noticed 2026-06-23**, while resolving the orphaned-farm-sites issue (the "B1 holding workspace" fix below).
|
||
|
||
Every site-mutation surface today is **member-side and ownership-scoped**: `archive` / `restore` / `keep-active` / hard-`DELETE` (`internal/server/fedwiki_partials.go`) all require `site.WorkspaceID == session.WorkspaceID`. The operator FedWiki Sites page (`/operator/fedwiki-sites`, `operator_pages.go:100`) is **read-only by M8c design** — a table over `ListAllSites` with external links and no forms. Consequences:
|
||
|
||
- **Operators cannot act on any site** — not their own org's, not a member's, not an ownerless one. No archive, restore, purge, or reassign from the operator surface.
|
||
- **Ownerless sites are unmanageable from the UI entirely.** Sites parked in the System / "Orphan Reconcile" holding workspace (farm sites with no member owner — e.g. `admin.localtest.me`, the farm root, leftover test sites) have no logged-in member, so the member-side handlers' ownership check can never match, and the operator page can't mutate. Freeing such a name still requires a direct FarmManager hard-purge, out-of-band.
|
||
- **No "claim" / "reassign owner" path** to move an orphaned or ownerless site into a real member's workspace.
|
||
|
||
**Fix direction:** give the operator FedWiki surface (or a dedicated admin tool) write affordances — at minimum purge (`?hard=true`) and archive/restore — guarded by operator role, plus a "claim/reassign to workspace" action for ownerless sites. This is the operator-side counterpart to the member lifecycle controls shipped by `fedwiki-lifecycle-states`, and the residual half of the orphaned-farm-sites issue (whose config/visibility half is now resolved). Pairs with the read-only→writable operator-panel work and the per-item MPA pages already tracked.
|
||
|
||
### Replace FedWiki sync polling with push-based (webhook) reconciliation
|
||
Labels: `enhancement`, `fedwiki`, `integration`, `real-time`
|
||
|
||
**Noticed 2026-06-23** while testing the `fedwiki-lifecycle-states` downgrade flow.
|
||
|
||
The FedWiki state projection and the `force_reduce` quota reconcile both ride the periodic **sync** workflow (`SyncFedWikiSitesWorkflow`, default `fedwiki-sync-interval`). That makes enforcement *eventually* consistent — a downgrade's read-only parking, an out-of-band farm status change, and the retention purge all wait for the next tick. For testing we drop the interval to `1m`, but the real fix is to stop polling and react to **farm change events**.
|
||
|
||
This is gated on the FarmManager **change-webhooks** capability, the fourth of the six capability requests sent upstream in 2026-06 (reversible read-only mode, explicit reversible lifecycle transitions, per-site storage usage, change webhooks, custom domain attach/detach, and rename or owner reassignment in place); `wiki-plugin-farmmanager` v0.4.1 shipped the first three and has not shipped webhooks. When it lands: FarmManager POSTs site created/status-changed/deleted (and usage-threshold) events to `integration.webhook_events` (the inbound table already exists and is idempotent), a processor projects them immediately, and the periodic sync degrades to a slow safety-net reconcile rather than the primary path. Also revisit the related deferred refinement noted on the resolved force_reduce issue: an *immediate* per-trigger reconcile from the downgrade/upgrade paths, independent of the sync tick.
|
||
|
||
### FedWiki HTTPS farm is a cross-stack singleton — needs a per-stack domain for true parallel isolation
|
||
Labels: `test-infra`, `fedwiki`, `https`, `enhancement`, `low-urgency`
|
||
|
||
**Discovered 2026-06-22** while adding the Caddy TLS layer for the `wiki-security-social` migration.
|
||
|
||
Browser access to FedWiki farm sites now goes through a Caddy TLS proxy (`wiki-security-social`/better-auth sets Secure cookies, which browsers only store over HTTPS). The proxy binds host port `443`, which is fixed rather than slot-allocated — so only one worktree stack can serve browser login at a time. A second stack's `caddy` container fails to bind `443` (the rest of that stack's services come up fine; only browser login is down).
|
||
|
||
**Why a slot-allocated HTTPS port isn't enough:** better-auth scopes its cookies to the shared `*.localtest.me` farm domain. Cookies are per-domain, not per-port (RFC 6265 — see the cookie note in `test/bootstrap-stack.sh` / `.env`), so two stacks on `*.localtest.me` clobber each other's session cookie jar regardless of port. The HTTP path is unaffected: member-console's farm API uses a bearer token over the slot-allocated `FEDWIKI_PORT` (no cookies), so provisioning stays isolated; only the browser/login surface is shared.
|
||
|
||
**Fix direction:** give each stack its own farm domain, e.g. `*.<slug>.localtest.me` (slug derived from `COMPOSE_PROJECT_NAME`/slot). Touches:
|
||
- `test/bootstrap-stack.sh` — derive + export the per-stack farm domain (and a slot-allocated `FEDWIKI_HTTPS_PORT`, which becomes safe once the domain isolates cookies).
|
||
- `test/seed/caddy/Caddyfile` — templated site address instead of the hardcoded `*.localtest.me`.
|
||
- `test/seed/fedwiki/config.json.tpl` — `wikiDomains` key + the rendered admin/owner entries.
|
||
- member-console — `fedwiki-allowed-domains`, `fedwiki-site-scheme`, and `buildSiteURL` (`internal/server/fedwiki.go` + `fedwiki_partials.go`), which currently emits no port.
|
||
- Keycloak `fedwiki` client — already `redirectUris: ["*"]`, so likely no change.
|
||
|
||
Caddy `tls internal` can mint per-stack wildcard certs, so the cert side is free.
|
||
|
||
**Severity:** low, and lower since 2026-08-01. `test-stack-integration-profiles` put the fedwiki chain and Caddy behind a `fedwiki` compose profile that is **off by default**, so the collision surface shrank from "every stack" to "stacks that opt into the profile" — a default `docker compose up -d` now binds nothing on `443`. The underlying singleton is unchanged: two stacks that both select `fedwiki` still contend, and the shared `*.localtest.me` cookie jar still defeats a slot-allocated port. Single-stack dev and CI are unaffected; this only bites when two worktrees need FedWiki **browser login** simultaneously. Documented as a known singleton in `test/AGENTS.md` and `test/README.md`.
|
||
|
||
**Scope:** test-infra only; no app-behaviour change beyond `buildSiteURL` learning an optional port. Natural home: whenever parallel-worktree FedWiki UI testing becomes a real need.
|
||
|
||
### FedWiki Sites operator tab empty under full seed
|
||
Labels: `bug`, `fedwiki`, `seed`
|
||
The compose seed provisions FedWiki fixtures at the FedWiki service but local `fedwiki.sites` rows are populated by integration workflows, not the seed. A "fully seeded" stack still shows an empty Sites tab. Related to "FedWiki sync does not populate DB from existing disk sites" (already filed) but distinct: that issue is about post-redeploy DB sync; this one is about first-run seed coverage. Candidate for M9 (Integration architecture) or earlier if the friction recurs.
|
||
|
||
### FedWiki sync does not populate DB from existing disk sites
|
||
Labels: `bug`, `fedwiki`
|
||
After a DB nuke and redeploy, the `fedwiki.sites` table is empty even though site directories exist on disk (e.g. `test/data/fedwiki/`). The sync workflow does not re-discover existing FedWiki sites from the filesystem to repopulate the database. This means both the operator panel (`ListAllSites`) and member views (`ListSitesByWorkspace`) show no sites.
|
||
|
||
## Domains registry
|
||
|
||
### Operator Domains page: Placements and History tables render every row, unpaged
|
||
Labels: `operator-ui`, `domains`, `operations`, `M10`
|
||
|
||
**Filed 2026-09-20.** It was only a residual inside the resolved "renders every claim with no pagination" bullet of the 2026-09-11 walk, in no roadmap or milestone. `slice3-walk-fixes` paged the live-claims table and left the other two tables on the page unpaged as a non-goal (D4, `operator_domains.go:115`): `loadDomainsPageData` calls `ListAllPlacements` and renders the full slice, and History does the same. Served names outnumber live claims on any deployment that places more than one name per claim, so the Placements section is the longest table on the page and the only one without controls; when the claims table fits one page the whole page reads as unpaged. The `table-without-list-controls` lint (`internal/lint/anatomy.go:366`) checks per template, so a second table beside a governed list never fires.
|
||
|
||
Fix: page both tables with `ParseListParamsNS` prefixes (the collision-free design D4 already reserved), a search over served name and organization for Placements, and the true total in each section header; then the proximity lint rule from the walk residual (controls or the marker within a few lines above each table) so the secondary tables on the grants, organizations, people and products pages get caught too.
|
||
|
||
### A released hosted name is instantly re-claimable — no tombstone
|
||
Labels: `enhancement`, `domains`, `security`, `design-gap`
|
||
|
||
**Noticed 2026-07-25**, reviewing what the abandonment ledger (`claim-lifecycle-hardening`, `c85ac6a`) does *not* cover.
|
||
|
||
The ledger meters names that are **abandoned before proving control**. It says nothing about names released *after* being verified and used. Releasing a claim requires zero placements (`ErrClaimHasPlacements`), so by the time a name is free its site is genuinely gone and no content is exposed — but everything *pointing at* the name survives: inbound links, other wikis' federation neighborhoods and page references, bookmarks, search results. The next holder inherits all of it, and the console will mint a TLS certificate for them at exactly that name, by design. That is impersonation and traffic inheritance, not content disclosure.
|
||
|
||
**Scope is narrower than it first looks: this is a `member`-claim problem only.** For `external` claims, re-claiming requires proving zone control by TXT, so DNS itself is the tombstone — an attacker cannot take `example.org` from its owner by claiming it here. The exposure is carved names under an operator shared root (`alice.<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.
|
||
|
||
### A refused ask leaves no trace of the name it refused
|
||
Labels: `operations`, `domains`
|
||
|
||
**Logged 2026-09-14** during the G8 soak. With Caddy's on-demand ask pointed at the console, the request log shows about one refused ask per second in bursts of two hundred a minute (5,299 asks in 90 minutes at 01:40 to 03:10 UTC, every one 404, p99 under 3 ms), which is the internet scanning a wildcard DNS name and was invisible while the filesystem answerer took it. The console's `request completed` line records the path and never the query string, the authorizer logs a refusal only at debug, and the fallback answerer logs nothing, so nothing on the host can say which names are asked without a packet capture. Twice tonight that was the question (is it a scan, or one stuck client retrying a deleted site?). Position: not a per-request Info log (a line per second of noise), but a rolling per-name counter the Domains page shows as "asks refused in the last hour" with the top names, and the same counter behind a log line once a minute when nonzero. Cheap, and it doubles as the signal for a member whose site is asked for before its placement exists.
|
||
|
||
## Operator panel — UX, IA & accessibility
|
||
|
||
### Low-contrast state text escaped the accessibility baseline because the demo fixture never renders that state
|
||
Labels: `a11y`, `frontend`, `ui-quality-gate`
|
||
|
||
**Logged 2026-09-12** from the maintainer's product walk on production. The Purchasability panel's "Not shown" catalog-visibility state rendered in `text-warning` (`#ffc107` on white, 1.63:1 against the 4.5:1 AA floor for small text); `operator_product_name_warning.html` used the same class. Both switched to `text-warning-emphasis` (7.99:1) the same day, uncommitted. Why the gate missed it: the Lighthouse baseline audits the rendered DOM of one fixture state per screen, and the demo product is on a ladder, so the panel showed "Shown" and the "Not shown" branch never existed in any capture (`docs/operator-a11y-baseline.md` records product detail at 100 with no failed audits). The 2026-09-01 shell fix removed the last `color-contrast` cells, so nothing in the ledger pointed at state branches. Two remedies, both open: (1) a fixture variant for an off-ladder product so the readiness panel's third state is captured and audited; (2) an anatomy-lint rule that refuses the low-contrast text utilities (`text-warning`, `text-info`, and `text-success` at 4.53:1 is marginal) as text colour outside badges, so the class cannot be typed again; only the `-emphasis` variants pass on the page ground. `text-danger` uses (nine) sit at 4.53:1, passing by 0.03.
|
||
|
||
### Purchasability says "Ready to grant" for an inactive private product that the grant form will not offer
|
||
Labels: `bug`, `frontend`, `correctness`
|
||
|
||
**Logged 2026-09-12** from the maintainer's product walk on production. A private product with Active unchecked shows the verdict "Ready to grant" while the Active checkbox's own help says an inactive product is not offered when new grants are created, and the enrollment grant form indeed lists only `ListActiveProducts`. Cause in `internal/server/product_readiness.go`: the panel folds Active into the "Public & active" visibility row, marks that whole row Not applicable for private products, and computes the verdict from the remaining rows, so Active never enters the private path. Fix: split the row into "Active" (applies to every product; unmet blocks both verdicts) and "Public" (catalog visibility; Not applicable for private products), and make the grant-readiness verdict require Active. The `product-management` spec's panel rows change with it, so this is an OpenSpec change; pair it with the uncommitted `text-warning-emphasis` contrast fix on the same panel.
|
||
|
||
### The console does not record which Stripe environment a synced object lives in; a key change leaves every mapping pointing at the old one while the pages describe the new
|
||
Labels: `design`, `billing`, `operations`
|
||
|
||
**Logged 2026-09-13** from the maintainer's question about the billing banner ("You create products in a sandbox and then once you are ready, you set the settings to the live account... the old products synced would remain in the member-console... I imagine the opposite is also true"). Confirmed. `stripe.product_mappings`, `price_mappings`, `customer_mappings` and `subscription_mappings` store the Stripe id and a `sync_status` and nothing about the environment the id was created in (`internal/integrations/stripe/store/migrations/00001_init.sql`; `provider_configs`, which carried an account id and a mode, was dropped unused in 00002). A sync is one `product.New` or `price.New` at creation (`workflows/outbox.go`) and nothing ever reads the id back, so a mapping is a pointer, and an id resolves only in the environment that created it (live, the legacy test mode, or one sandbox). After the key moves to another environment, in either direction: the readiness row reads "Synced, live" or "Synced, test" from the current key (`syncedPaymentDetail`), not from where the object is; the Sync control refuses with "This price is already synced to Stripe." because the mapping holds an id; a member checkout sends the old price id under the new key, Stripe answers `resource_missing`, and the member sees "failed to start checkout" (`internal/server/billing.go`); the fulfillment reconcile's `subscription.Get` fails the same way; and the billing banner's sentence "Figures on these pages are test data" describes the key, while the figures are ledger rows that webhooks wrote under whatever key was configured when they arrived, so after a live-to-sandbox move the sentence is false. Webhooks do not cross-talk (a signing secret belongs to one endpoint in one environment), but nothing compares an event's `livemode` with the key's mode, so a secret left behind fails signatures silently. Fix, in order of cost: (1) stamp `livemode` from the returned object on every mapping row at creation and treat a row whose flag disagrees with the key's mode as not synced: the readiness detail names the recorded mode, the Sync control creates again instead of refusing, checkout refuses before calling Stripe; this catches sandbox and live in both directions at no cost. (2) A read-back (`product.Get` under the current key; a restricted key that writes products can read them) that marks a `resource_missing` mapping stale, run from the Stripe integration page or at boot when the key changed; this is the only way to catch a sandbox-to-sandbox move. (3) The webhook handler refuses an event whose `livemode` disagrees with the key's mode. (4) The banner's provenance sentence goes unless stored events carry `livemode` and can vouch for it. Not in `slice3-followup-fixes`, which ships the derived mode and the "Synced, live" detail as they are; its own change.
|
||
|
||
### The add-rule form's Per unit checkbox is ignored; every rule authored through the form stores per_unit = false
|
||
|
||
**Logged 2026-09-12** from the maintainer's production walk ("'Per Unit' being checked (or unchecked) in the add rule form creates an entitlement set with Per unit = No... Basically there is no way of setting it to yes"). Reproduced with a handler test: a POST carrying `resource_per_unit=true` for `fedwiki_sites` stores `{Bool:false Valid:true}`. Every limit rule in production carries false (Wiki Cafe Public 1, Wiki Cafe Standard 16, Legacy Grant 64 pack 64, Transitional 64), all authored through the form. Cause: `entitlementSetRuleFormValues` (`internal/server/operator_entitlement_set_forms.go`) binds the checkbox with `Values.SetBool`, which records the raw string and presence only, and `CreateEntitlementSetRule` (`operator_entitlement_sets.go`) reads `values.Bool("resource_per_unit")`, which reads the typed map that only `Parse` fills; the two halves of `forms.Values` disagree. Present since `entitlement-rule-authoring` (2026-07-21). The existing test posted the checkbox only on the boolean key, where the handler ignores it by design, so it never asserted the stored flag. Sibling audit: every other typed read in `internal/server` (`Bool`, `Int`, `Time`, `UUID`; four call sites in the entitlement set, product and grant handlers) reads values returned by `ParseSide`/`ParseWith`; this is the only handler reading typed values off a bound `Values`. Fix: `SetBool` records the typed bool too (a library invariant: a bound checkbox answers `Bool` the way a parsed one does), plus a handler test that asserts the stored flag for a numeric key. Impact today is nil because every live grant has quantity 1; `materialize.go` multiplies by quantity only when the stored flag is true, so a quantity-2 grant of any of these sets would deliver the base value. Fix-now, in the Slice 3 follow-up change with the contrast and readiness fixes.
|
||
|
||
### Org-type default change against a rule-less set materialized every personal org's site limit to 0 (production incident, 2026-09-12 18:51 UTC)
|
||
|
||
**Logged 2026-09-12.** At 18:51:51 UTC the Personal org type's default ladder was set to Wiki Cafe Plans with the migrate disposition (ledger: 13 `end` transitions "org-type default change (personal): migrated off the outgoing default", 13 `initiate`). The handler (`operator_org_types.go`) ended the 13 default-sourced Transitional provisional grant provisions (the grants themselves stay `active` in the grants ledger), conferred Wiki Cafe Public (rank 0) as a grant-sourced provision on each pool and materialized. Wiki Cafe Public's entitlement set received its `fedwiki_sites` limit-1 rule 64 seconds later (18:52:55), and creating a rule never re-materializes the pools that carry the set (`ruleResourceKeyIsLiveBacking`'s comment states this for deletion; it holds for create and edit too). State as verified over `psql`: 13 pools with `fedwiki_sites` limit 0 and usage 1 to 64 (nine at 1, then 2, 12, 14, 64). `AtomicIncrementUsage` requires `current_usage < resource_limit`, so no personal org can create a site until its pool is re-materialized. Repair without code: conferring a grant re-materializes the pool (`conferral.go`), so issuing "Legacy Grant - FedWiki 64 pack" to each of the 13 orgs yields 1 + 64 = 65 per pool and is the grandfathering the plan's open question 2 asked about; the default-change preview offered the grandfather disposition for exactly this case and migrate was chosen. Gaps: (1) the default-change and tier add/remove previews confer a rank-0 product whose set has no active rules without a word; refuse or warn. (2) Creating, editing or deleting a rule on a set that live provisions carry must re-materialize those pools, or the page must state that the change does not reach them (ties to "Should entitlement sets be immutable?"). (3) No operator action recomputes a pool's entitlements. (4) The organization composite and the FedWiki operator page show usage without the materialized limit, so a 0 limit is invisible until a member's site creation fails. Fix-now candidate: (2) or (3); (1) and (4) for the rethink. Repaired by the maintainer at 19:59 UTC without code: the default was removed (ending every default provision) and set again to Wiki Cafe Plans, so the 13 pools were re-materialized with the rule present. Verified: limit 1 on all 13; nine orgs at 1 of 1, four over it (2, 12, 14 and 64 sites). Those four still need the 64-pack grant (or whatever the legacy decision is) before they can create anything; the nine are at their limit.
|
||
|
||
**Gaps 1, 2 and 3 resolved 2026-09-15 by the `entitlement-set-changes` change.** (1) Decision 148 puts `active_rule_count` on `core.product_shape`, and the org-type default change preview, the tier add and removal forms, the tier reorder message and Issue grant all read it and render `<product> provides nothing: its entitlement set <set> has no active rule.` with the set linked; the commit stays enabled, because the maintainer's pick (M1) is warn everywhere and refuse nowhere. (2) Decisions 143 and 145: `core.commit_rule_change` is the only write path to `core.entitlement_set_rules` and writes the rule, its act row and one obligation per carrying pool in one transaction; at or below the measured cap of 250 pools the commit materializes every carrying pool in that transaction, above it the drain settles the rest within seconds, and every commit is preceded by a dry-run preview of the pools it reaches. (3) Decision 145 writes no recompute act and the console offers no recompute control: the drain is automatic and the one residual control, `Retry`, only requeues obligations whose attempts are exhausted. Gap 4 stays open.
|
||
|
||
### Free rank-0 rung reads "Included / Subscribe / Not available for purchase yet" on the member catalog
|
||
|
||
**Logged 2026-09-12** (maintainer: "Not honest."). `member_products.go` routes every non-current tier to a move: an org holding nothing on the ladder gets `Relation = available`, `MoveLabel = Subscribe`, `MoveKind = checkout`, and a tier with no price is not purchasable, so the control renders disabled with "Not available for purchase yet". A priceless rank-0 rung is never purchased: the default mechanism confers it, so for an org holding nothing on the ladder it is the org's plan and the card should read as current; for an org on a paid rung it is the cancel target, which the `Rank == 0` branch already handles. Fix: a `NoPriceTier` at rank 0 never gets a checkout move; when the org holds no rung on the ladder the card reads as the current plan without a control. Spec: `member-product-discovery`. For the Slice 3 follow-up change.
|
||
|
||
### "Public" names the catalog flag badly; "Listed" says what it does
|
||
|
||
**Logged 2026-09-12** (maintainer: "public is a bad name... Maybe purchasable might be a better name"). The flag's meaning, per the product form's own help, is "sold in the catalog" versus "only issued by an operator as a grant". "Purchasable" fits paid rungs only: a free rank-0 rung is in the catalog and never purchased, and Purchasable is already the readiness verdict for public + active + priced + synced. "Listed" / "Unlisted" names the one fact the flag records, does not collide with product names such as "Wiki Cafe Public", and has a widely understood precedent. Proposed copy: label "Listed", help "Shown in the member catalog."; readiness row "Listed & active"; the private detail "Unlisted products are issued as grants." The column stays `is_public` (`product-catalog` names it; renaming is spec and migration churn with no behavior change). With the products-list Status rethink and the vocabulary entries.
|
||
|
||
### Per-unit multiplicability may belong on the product, not on each rule (thought to retrospect)
|
||
|
||
**Logged 2026-09-12** (maintainer: "Maybe that multiplicability ought to be in the product instead of the entitlement set... we may have to go back at some point and retrospectively investigate on our previous conversations every time this concept has surfaced"). Not a design; a retrospective comes first. Seed list (grep for per-unit, `resource_per_unit`, multiply by quantity): archived changes `2026-03-25-grant-based-fedwiki-access` (first appearance, design and entitlements delta), `2026-03-26-entitlement-sets`, `2026-07-11-doc41-conferral-uniformity`, `2026-07-21-entitlement-rule-authoring`, `2026-09-03-acceptance-fixes`, `2026-09-06-dense-widths`, `2026-09-12-slice3-walk-fixes`; explorations `ux-walks-2026-08`, `ux-walks-2026-09` (four files), `forms-audit-2026-09` (FA-23); two `status/log` entries; two `status/archive` files. The retrospective must weigh that since 2026-07-21 the form has silently stored false (entry above), so part of the friction observed with the concept was the bug, not the concept.
|
||
|
||
### The member Create site button hand-rolls its disabled state, so nothing tells the person it is disabled or why
|
||
|
||
**Logged 2026-09-12** (maintainer, over quota on production: "This Create site button style when it is disabled does not work... It isn't obvious that it is disabled"). `internal/integrations/fedwiki/templates/fedwiki_sites.html` renders `<button ... disabled title="Site limit reached">` directly. Bootstrap's `.btn:disabled` sets `pointer-events: none`, so the `title` never shows on hover and no cursor change reaches the pointer; there is no `aria-describedby`, no visible reason, and the 65% opacity of an outline button is the only cue. This is the hand-rolled shape `form-conventions` "Disabled controls carry their reason in the DOM" and `docs/design-system.md` §3 forbid, and the anatomy lint has no rule for a `disabled` button carrying a `title` outside the `disabledControl` part. Fix: render the button through the part's Visible variant with the reason ("Site limit reached", or the usage line itself as the described-by target so no copy is added); give the part's wrapper `cursor: not-allowed` in both variants (the tooltip variant already has a focusable wrapper; the Visible variant needs one, because the cursor cannot be set on an element with `pointer-events: none`), which is the deny cursor Carbon, Material, USWDS and Atlassian all use on disabled buttons; add the lint rule. The usage line reads "12 of 1 active sites used" when over the limit, which is honest and reads as impossible; the over-limit state gets its own sentence with the count and the limit (maintainer 2026-09-12: "When someone is over their limit, the page should say so in its own sentence, with the count and the limit"); candidate: "12 active sites, 1 allowed." For the Slice 3 follow-up change.
|
||
|
||
### Delegated subtrees: a member claim cannot hand a subdomain to another workspace
|
||
|
||
**Logged 2026-09-12** from the Slice 3 cutover. `podding.wiki.cafe` is an active member claim held by admin's organization; `robert.podding.wiki.cafe` is a real farm site whose `fedwiki.sites` row belongs to rob's organization. The registry places no site inside another workspace's `member` or `external` claim (`fedwiki-sites` "the sync SHALL NOT create any placement" inside such a claim), so the site has no placement, and after the G7 cutover the console's ask refuses it unless the legacy fallback answers. The maintainer: "sometimes people want to delegate subdomains to others... Yes, you own the tree, but you can delegate ownership... this is yet another issue we have to resolve and probably will require some proper design work." What exists: claims carved from operator roots (`ClaimCarved`, `GrandfatherCarvedClaim` in `internal/domains/registry.go`); what does not: a delegation act by which the holder of a claim carves a child claim for another workspace and keeps the rest of the tree. Design needed before code: the consenting act and its revocation, what the delegate may do under it (sites, deeper delegation, external DNS), how the ask and the sync treat a delegated child, and how the org composite and the domains page show it. Interim: set `domains-ask-fallback-url` to the legacy answerer at G7 as the runbook already planned, so a registry miss keeps serving (`robert.podding.wiki.cafe` stays served and stays listed in rob's dashboard through its `fedwiki.sites` row); a name the FQDN shape check refuses (an underscore, as in `triage_mh.wiki.cafe`) never reaches the fallback.
|
||
|
||
### The backfill named persons by username and froze that into their organizations' names
|
||
|
||
**Logged 2026-09-13.** The legacy backfill auto-provisioned each legacy owner through the same path first sign-in uses, passing the Keycloak username as the display name, so twelve of the thirteen persons read as their username ("3wordchant", "marick", "mike_hales") while the realm holds first and last names for every one of them; the one person who signed in reads "Christian Galo" because sign-in writes the token's name. The personal organization's name is derived from the display name at creation ("marick's Organization") and nothing ever rewrites it, so those twelve organizations stay named after usernames after their owners sign in. Two things: a one-off rename of the twelve persons and their organizations from the realm's names (proposed to the maintainer 2026-09-13), and a design question for the org model: whether a personal organization's name should follow its owner's display name (a derived name, not a stored one) until the owner renames it. The backfill tool itself is never-merge; the fix for future imports is to pass the realm's name.
|
||
|
||
### Service hostnames live on the farm only so the on-demand TLS ask says yes
|
||
|
||
**Logged 2026-09-13** (maintainer: "they merely exist so that Caddy allows them to be routed"). Every app in the swarm carries `caddy.tls.on_demand`, so Caddy consults the ask endpoint for console.wiki.cafe, forum.wiki.cafe, matrix, meet, registry, temporal and the rest, and the filesystem answerer says yes because a farm directory of that name exists. When the console becomes the answerer (G7) those names must be servable placements in the registry; the only path today is the FedWiki sync importing them as system-tenant sites with operator placements, which records a service hostname as a wiki site. The registry needs a first-class operator placement for a name that is not a site (provider other than `fedwiki`, no `fedwiki.sites` row), authored on the Domains page, so the farm directories and the filesystem answerer can go. Until then the ask fallback is load-bearing.
|
||
|
||
### Resources by holder: the organization composite shows no domains or sites, and resource pages cannot be asked "which belong to this organization"
|
||
Labels: `ia`, `frontend`, `design`
|
||
|
||
**Logged 2026-09-12** from the maintainer during the Slice 3 production walk. There is no way to query the list of domains and ask which are owned by a specific organization, and the organization composite (`/operator/organizations/{orgID}`) has nowhere that shows the actual resources the organization holds. The maintainer's read: this should be a question you can ask in both places, on the organization composite and on the page that controls the resource (Domains; FedWiki sites under the FedWiki integration page; whatever other resources arrive), and they do not yet know how it would land, in particular how FedWiki resources would be listed under FedWiki's own page. Interim, as of `slice3-walk-fixes`: the Domains list and the operator FedWiki sites list both take a search token that matches the owning organization's name, which answers the question loosely but is neither a holder filter (a name substring is not an identity) nor a composite section. Candidate shape, unexplored: a "Resources" region on the composite, one row per resource kind with count and link into the resource page pre-filtered by holder; and a holder facet or `org` query parameter on each governed resource list, reserved under `operator-panel-navigation`'s query-parameter rule. Design exploration before any spec, per the eradication rule.
|
||
|
||
### Residual SPA patterns: products and entitlement sets lack per-item pages
|
||
Labels: `tech-debt`, `operator-ux`, `ia`, `mpa`
|
||
|
||
**Noticed 2026-06-23.** Post-M7 the operator panel is meant to be MPA with addressable per-item pages (e.g. `/operator/organizations/{orgID}`). But `/operator/products` and `/operator/entitlement-sets` are list pages whose **detail/management lives in partial-swap + modal flows** (`/partials/operator/products/{id}/edit`, `…/prices`; `/partials/operator/entitlement-sets/{id}/edit`, `…/rules`) rather than dedicated pages. There is no `/operator/products/{id}` or `/operator/entitlement-sets/{id}` — so a product/set isn't linkable, the back button doesn't work, and the surfaces still behave like the old SPA.
|
||
|
||
**Fix direction:** give products and entitlement sets first-class per-item MPA pages (prices, edit, rules rendered inline on the item page), consistent with the per-org composite view. Candidate for an operator-IA follow-up. Related: the M7 IA work that moved enrollment/grants to per-org pages.
|
||
|
||
### Operator URL/route/code naming drift
|
||
Labels: `bug`, `frontend`, `dx`
|
||
The visible tab label, the `?tab=` URL parameter, the route slug, and the partial filename disagree. Examples: tab label "People" → `?tab=people` → route/partial `users`; tab label "Organizations" → `?tab=orgs` (but `?tab=organizations` does NOT switch tabs — `operator-tabs.js` only handles the short slug). Bookmarkability fails silently when a user infers the long name from the tab label. Discovered in Phase A v1 + reconfirmed in v2. Candidate for M7-7b/7d. **IA decision (2026-05-12, OpenSpec change `operator-ia`):** canonical route slugs are now declared in [`docs/operator-ia.md`](../docs/operator-ia.md#route-hierarchy). The `?tab=` short-slug shape disappears with M7-7d's MPA rewrite, which consumes the IA's route hierarchy as input — leave open until 7d closes it. **Update 2026-07-22 (fresh-eyes IA audit remediation):** the FedWiki instance of this drift is fixed — the operator surface moved to `/operator/integrations/fedwiki` (manifest, ia-position `integration:integrations:fedwiki`, legacy `/operator/fedwiki-sites` 301-redirects), so slug/URL/ia-position now agree; the capability/spec name `fedwiki-sites` and `resource_key` stay as-is.
|
||
|
||
### Operator product edit: `lifecycle_status` not exposed
|
||
Labels: `bug`, `frontend`
|
||
The schema added `lifecycle_status` (`draft`/`published`/`retired`) per M6a, and 7a.1 explicitly called out that retire-vs-delete is the right archive primitive. The product edit form (`/partials/operator/products/{id}/edit`) shows an `Active` checkbox but no lifecycle-state transitions. Operators cannot retire a product through the UI today. Discovered in Phase A v2. Candidate for M7-7b/7c (covered by existing "Product retirement and Stripe-mapping visibility in operator UI" issue — leave that as the umbrella).
|
||
|
||
### Operator grant-issuance: two non-overlapping surfaces with no cross-link
|
||
Labels: `bug`, `frontend`, `ia`
|
||
The global Grants tab and the per-org Enrollment "Issue Grant" form expose different products: global lets the operator pick any product (or an entitlement set); per-org Enrollment limits the Product dropdown to plan-typed products attached to the ladder. The two surfaces have no cross-link or "looking for X? try Y" guidance, and the operator must already know the distinction to choose correctly. Discovered in Phase A v2. Candidate for M7-7b IA. **IA decision (2026-05-12, OpenSpec change `operator-ia`):** both issuance forms move to the per-org composite view at `/operator/organizations/{orgID}`, labeled by intent ("Grant a plan" → `IssueGrant`; "Grant a non-plan product" → `CreateGrant`). The two code paths are kept (they align with the structural-vs-labeled product kind split per `membcons-db` Doc 35); only the *surface* is consolidated. Global Grants becomes read-only. 7d implements.
|
||
|
||
### Two grant-revoke paths are non-equivalent and indistinguishable in UI
|
||
Labels: `bug`, `frontend`, `correctness`
|
||
Global Grants tab "Revoke" → `POST /grants/{id}/revoke` (simple; body: *"entitlements will be recalculated"*). Per-org Enrollment "Revoke" → `POST /grants/{id}/revoke-and-transition` (composite; body: *"pool returns to its org-type default and a downgrade or end transition is recorded"*). The operator has no UI hint that these are non-equivalent — silent-correctness risk. The 2026-05 operator-UX research flagged it as the "two revoke paths" 7a.1 finding; this entry is the issue-tracker pointer so 7b can scope consolidation work. Candidate for M7-7b. **IA decision (2026-05-12, OpenSpec change `operator-ia`):** the per-org composite view at `/operator/organizations/{orgID}` is the sole UI entry point for grant revocation, using the composite revoke-and-transition behavior; the global Grants Revoke affordance is removed. (The broader IA also moves *all* grant action affordances — issue/extend/revoke — to the per-org view; the global Grants surface becomes read-only browse.) Implementation lands with M7-7d's MPA rewrite — leave open until 7d closes it.
|
||
|
||
### Person page: the memberships table is bare where the boxing rule boxes it
|
||
Labels: `ux`, `frontend`, `design-system`
|
||
|
||
**Filed 2026-09-06** at the archive of `overview-consistency`. The page anatomy now states the boxing rule the pages follow (design D6 of that change; spec `page-anatomy` "Sections are boxed by the page's kind"): a rail-entry page renders its lists bare, a record page and a surface root box each section under its header. The person page (`operator_person_detail.html`) is a record page that boxes its facts and renders its Memberships table bare, the one page off the rule. Fix: the table and its empty state inside `card > card-body` under the section header, like the composite's Members; its render test re-pinned; the capture's `operator-person-detail` re-accepted.
|
||
|
||
### Date cells are small in four tables and body size in eleven
|
||
Labels: `ux`, `frontend`, `design-system`, `low-priority`
|
||
|
||
**Filed 2026-09-06** as finding F5c of the overview consistency audit. Date cells render at body size in eleven record tables and inside `<small>` in four: the organization composite's grants (Activated, Ended), members (Joined) and tier changes (When), and the person page's memberships (Joined). The anatomy is silent on cell sizes. Either write the rule (body size, `text-nowrap`, as the majority and the overview's feed do) into `page-anatomy` and design-system §6 and drop the four `<small>` wrappers, or record why a ledger's dates are small. Nothing depends on it; take it with the next composite change.
|
||
|
||
### A fourth at-a-glance tile waits for the issues system
|
||
Labels: `enhancement`, `operator-overview`, `deferred`
|
||
|
||
**Deferred 2026-09-06** by the maintainer. Asked what the highest-value fourth daily read would be, the orchestrator weighed Past due (subscriptions whose collection failed, one more filter on the query that feeds the Monthly recurring caption, linking to the subscriptions list's Past due facet) and Expiring grants (operator-issued grants ending within seven days, one new count query). The maintainer: "Let's just wait until we have the issues system... I feel like that'd be a better fourth tile." So the row stays at three (`operator-panel-navigation`, the headline row reserved for the daily reads) until an issues system exists and its open count can be the fourth readout in a linked card, the idiom the tiles now share (`page-anatomy` "A readout is a part", "A linked card is marked by a glyph and the row's hover").
|
||
|
||
### Scheduled grants: no way to queue a grant for when the current one ends
|
||
Labels: `enhancement`, `enrollment`, `deferred`
|
||
|
||
**Raised 2026-09-06** by the maintainer: "there is currently no way for you to schedule a grant in the future or create some sort of, like, backup grant. So, for example, let's say the organization is in demo plan three, but you want to put them in demo plan two when their demo plan three ends." Today Valid until ends a grant and the org-type default restores; nothing can be queued behind a live grant. `core.grants` already carries `valid_from` (the conferral insert writes it), so the schema may need nothing; the work is a start-time on Issue grant, the conferral honouring a future start, a scheduled activation like the expiry sweep, and the ledgers showing a queued grant. Not for `dense-widths`; no milestone yet.
|
||
|
||
### 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.)
|
||
|
||
### Audit: one query per row in list and ledger builders
|
||
Labels: `perf`, `tech-debt`, `audit`
|
||
|
||
**Raised 2026-09-06** by the maintainer on the purchasability-status sheet, after the products list's per-row entitlement-set name lookup was folded into a batch read: "can you add to the issues an audit to see if we have similar patterns that we could fix." The pattern: a page's row builder calls a `Get...ByID` (or a per-product list query) inside its `range` over the page's rows, so a page of fifty rows issues fifty-odd reads where one `... = ANY($1)` batch would do. Two are already gone (`purchasability-status`: the products list's per-product readiness reads and its set-name lookup, both now four batch queries per page). Known remaining instances: the composite's Tier changes builder resolves `GetPersonByID` once per operator-actor row and `GetPlanLadderByID` per ladder (memoized per ladder, not per person; `internal/server/operator_enrollment.go`, the transitions loop); the overview's activity feed resolves organization and person names per event; any list whose rows carry a name resolved from an ID (owner names on Organizations, product names on Grants, which already uses a batch for products).
|
||
|
||
**Method.** Grep every handler for a `Q.Get` or `Q.List...By` call inside a `for ... range` over rows; count queries per page with the Postgres statement log against the demo seed at page size 50; batch each with an `= ANY(sqlc.arg(ids)::uuid[])` query in the owning schema (the idiom `ListEntitlementSetRulesBySetIDs` and `ListEntitlementSetsByIDs` set) and a map in Go; keep the per-ID query where a single detail page uses it. One change, `batch-row-lookups`, with a test that pins the query count per page for each list it touches. Not a launch blocker at demo scale; it is the kind of debt that shows the day a deployment has a thousand people.
|
||
|
||
### 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 2026-05 by a network capture of the landing load during the operator-UX research, which recorded the same finding. The architectural cause is what M7-7d (SPA → MPA conversion) is here to retire. Tracked here so it survives the M7 phase scoping. **IA decision (2026-05-12, OpenSpec change `operator-ia`):** the IA in [`docs/operator-ia.md`](../docs/operator-ia.md) replaces the flat-tab SPA model with a three-layer route hierarchy and a curated landing surface — M7-7d's rewrite consumes that hierarchy as input. Leave open until 7d closes it.
|
||
|
||
### A refusal mapped to a field the form lacks renders no message
|
||
Labels: `ux`, `forms`, `enrollment`
|
||
|
||
**Raised 2026-09-07** while testing the Extend path. `grantConstraints` maps `chk_grants_default_iff_system_authored` and `chk_grants_reason_domain` to the field `reason`; the Extend form submits no `reason` field, so `renderExtendGrantRefusal` answered 422 with the page and an empty form-error slot, and the operator saw a silent refusal. The trigger that surfaced it (an extension inheriting the `default` reason) is gone since `ExtendGrantTx` substitutes manual, but the pattern stays: a field error whose field the active form does not carry vanishes.
|
||
|
||
**Position.** `applyWebErrors` (or the refusal renderers) should fall back to the form-level banner when the named field is not one of the form's declared fields, so a refusal always has one visible line. Small; touches `internal/server/operator_enrollment.go` and the forms library's error scoping. Proposed change: `orphan-field-errors`.
|
||
|
||
### Entitlement set rule change: the panel-and-preview interaction reads clunky; rethink it
|
||
Labels: `operator-ux`, `entitlements`, `rethink`
|
||
|
||
**Logged 2026-09-15** (maintainer, after walking the change on the test stack: "it looks a little clunky, it's not very intuitive, not very modern... I don't like how when you click edit you have to look at another element... I think we can make this a lot more obvious and I think we're failing here"). As shipped by `entitlement-set-changes` under its pick M3 (the Add rule panel plus a separate preview block): `Edit` and `Remove` on a rule row open the prefilled form or the preview in `#rule-change-panel` below the Rules table, away from the row the operator pressed; the preview renders under the table with the Note field and `Apply change` / `Discard`; a commit re-renders the whole operator body and the History section below it; on the mobile width the panel pushes the table down. The mechanics underneath are right and tested and stay: the dry-run preview, the enclosed commit, the History row, the drain and its Retry. The complaint is the interaction and the visual treatment, which follow the repo's existing panel idiom rather than the row. Candidates for the rethink, none chosen: expand the preview inline directly under the pressed row, so the consequence reads beside its cause and the row actions stay where they were; a drawer or a modal on the repo's confirm-action idiom; inline-editable Limit and Per unit cells with the preview as the confirmation step; the Reduction policy control shown only once the key is known to be numeric. What survives any redesign: every visible string in the change's design, no explanatory copy, the preview as a dry run and the commit as one enclosed act, the History table scrolling in its own box at the mobile width. Decision: not now; the maintainer archived the change as built on 2026-09-15 and named the rethink a separate piece of work. Added 2026-09-18 (maintainer: "some resource rules should set their own defaults rather than have defer be a coarse global default"): the rule form preselects `tier_reduction_policy` from the key's provider-declared `over_limit_behavior` (`park` or `reclaim` preselects Force reduce) instead of the model's `defer` for every key; the schema default stays the model's. Today the choice is moot for a `deny_new` key, because at a rule change every policy but `force_reduce` resolves to clamp and `block` and `defer` have no switch-time mechanics, so the control matters only where the provider can park or reclaim; the fields re-render per key already exists (`GET /partials/operator/entitlement-sets/rules/fields`).
|
||
|
||
**Built 2026-09-18** by `staged-rule-changes`, archived 2026-09-19 once the maintainer accepted the screens (`openspec/changes/archive/2026-09-19-staged-rule-changes`), awaiting the maintainer's commit: the Rules table is the form (in-cell editing, several rows at once), `Stage change` stages a delta, one tray under the table at its width carries the population line, one line per delta with Undo, the note and `Apply changes` / `Discard all`; a batch applies as one transaction with one act per rule. Reworked 2026-09-19 after the maintainer walked it: the reduction policy left the tray for the rule's own row (a column beside the limit, edited with it), and the rules are grouped by kind in two stacked tables, Limits and On/off rules, so no row carries a cell its kind has no value for (decisions D7 and D10 of that change's design). The three ride-along findings closed with it: focus is server-named on every swap (Limit after Edit, the tray after a stage, the row's Edit after Cancel); a boolean removal states no policy sentence because the tray states no disclosure at all; the governing policy is folded in Go and stamped on effects (see "Reduction policies from several sets on one pool").
|
||
|
||
### The `fedwiki_sites` resource key is owned by core migrations; move it to the FedWiki store stream in the pre-launch squash
|
||
Labels: `integration-architecture`, `migrations`, `fedwiki`, `pre-launch`
|
||
|
||
**Logged 2026-09-18** (maintainer, on reading that migration 00018 stamps `force_reduce` on existing `fedwiki_sites` rules: "A core migration naming an integration's resource key is a boundary crossing... let's actually undo all that"). The crossing is older than 00018. Core `00002_seed_resource_keys.sql` inserts the `fedwiki_sites` key row, which predates the integration split, and every later core touch follows from that seed: `00008_resource_key_kinds.sql` sets its kind, `00011_fedwiki_sites_display_name.sql` renames it, and `00018_entitlement_set_changes.sql` backfills its rules to `force_reduce` so the quota sweep keeps parking on upgrade. Discourse does not have the problem: `discourse_posting` is inserted by the Discourse store's own `00001_init.sql` and corrected by its own `00002`; core mentions Discourse only in comments. The domains module seeds `external_domain_claims` in its own stream. Resolution vehicle: the maintainer's pre-launch migration squash (one baseline per stream, ledgers re-baselined on the one existing deployment). In the squash the FedWiki store's baseline seeds `fedwiki_sites` with its kind and display name, core's seed keeps only platform keys, and the `force_reduce` backfill disappears rather than moves, because a fresh install has no pre-policy rules and wiki.cafe already carries the value in its data. Follow-through in the same change: rewrite the `fedwiki-sites` spec requirement that names "the migration that adds the column" as a key-ownership requirement, delete `internal/entitlements/migration18_test.go`, and grep tests, docs and the demo seed for migration numbers. An interim FedWiki-stream migration before the squash is not planned: it would be replaced by the squash and only matters if a release ships first.
|
||
|
||
### The screens harness cannot reach interaction states: the staged rule batch is reviewed from hand captures
|
||
Labels: `tooling`, `screens`, `operator-ux`
|
||
|
||
**Logged 2026-09-18** from `staged-rule-changes`. `make screens` captures each route in its generic states and has no hook to script an interaction first (`test/e2e/screens/screens_test.go`), so the Rules table's editing row, its staged rows, the tray with one and with several deltas, two editors open beside a staged change, and the after-apply state are captured by hand at 1280 and 390 into a sheet beside the change and reviewed by the same Sameness checklist (`docs/first-contact-ux-process.md`, definition of done, step 4). Until the harness can run a scripted sequence per screen (a list of acts against the page's declared form, replayed before the capture), every stateful surface repeats this by hand and its baseline never diffs.
|
||
|
||
### History's per-pool list does not scale
|
||
|
||
Filed 2026-09-19 while the maintainer walked the `staged-rule-changes` build. A History row's Pools cell carried a `show all N` disclosure naming each recomputed organization with its numbers, capped at 50 names, from the `entitlement-set-changes` change. The maintainer: "No summary like this in history... How is this gonna look/render with hundreds of recomputed pools. Drop this until we address this on its own issue." Dropped in `staged-rule-changes` (design D15; the `entitlement-set-history` delta): the cell states the two counts only, the effect-line query no longer runs per row, and the effect rows stay in the ledger. What replaces it needs its own design: a review of one change's effects that scales past a page of names (paged, searchable by organization, reached from the row rather than unfolded inside it), which is also where the review-details link the `staged-rule-changes` design defers (per-delta buckets, disclosures, the provider's consequence sentence, the governing policy per pool) would land.
|
||
|
||
## 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 of 2026-08-21; 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
|
||
|
||
### `SECURITY.md` publishes a placeholder disclosure address
|
||
Labels: `security`, `operations`
|
||
|
||
`SECURITY.md` names **security@wiki.cafe** as the preferred private channel and
|
||
marks it a placeholder in the same line, under an open `TODO(front-door)`
|
||
comment. Nobody has confirmed that the address exists or who reads it, so the
|
||
repository ships no working private channel and a reporter's first message can
|
||
go nowhere. Found 2026-09-07 in the README front-door review, where all three
|
||
outside models flagged it independently. Fix: confirm or replace the address
|
||
and drop the TODO, before the repository is public.
|
||
|
||
### ~~Session/CSRF secret generation and rotation strategy~~ — RESOLVED 2026-09-08
|
||
Labels: `security`
|
||
|
||
**Resolved 2026-09-08 by the `csrf-standard-library` change: the key no longer exists.** Moving CSRF protection to `net/http.CrossOriginProtection` (audit decision D1) removed the token entirely, and with it `csrf-secret` and `csrf-secret-file` — validation, both CLI flags, the file-backed secret entry, the embedded and test config keys, and the docs line. Sessions never had a signing secret of their own: `scs` with the Valkey store issues random server-side session ids. So there is nothing left to generate or rotate, and the remaining work this issue tracked (zero-downtime dual-key rotation, a keyring-shaped config, generation guidance) is moot rather than deferred.
|
||
|
||
Prior narrowing, 2026-07-31 by `hardening-polish` (10e): `docs/hosting.md` documented the hard-cutover rotation procedure and its blast radius.
|
||
|
||
### Auth setup review
|
||
Labels: `security`, `auth`
|
||
Remove Keycloak-specific code, backchannel logout, session timeout, rate limiting.
|
||
|
||
Progress 2026-09-08 (`logout-ends-session`): the logout and revocation URLs are the provider's discovered `end_session_endpoint` and `revocation_endpoint`; the hard-coded Keycloak logout path is gone. Registration still uses Keycloak's `/protocol/openid-connect/registrations`, which has no standard equivalent.
|
||
|
||
Progress 2026-09-09 (`identity-refresh-interval`): a session's identity and roles are re-derived from the provider every five minutes via the refresh token, and a session the provider has ended is ended here within that bound. This is the provider-neutral answer to "session timeout"; back-channel logout remains open as an optional, faster path for the logout events it covers.
|
||
|
||
Progress 2026-09-09 (`logout-ends-session` D6): sign-out is a POST from the account menu's button, so a link on another origin cannot end a session; `validReturnTo` refuses backslashes.
|
||
|
||
### 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.
|
||
|
||
## Licensing
|
||
|
||
member-console is dual-licensed as of 2026-09-06 (commit `88db730`): verbatim
|
||
AGPLv3 in `LICENSE`, the `AGPL-3.0-only OR LicenseRef-Commercial` identifier on
|
||
every hand-written source file `scripts/spdx-headers.sh` covers (Go, SQL
|
||
migrations and seeds, the Go templates, the first-party assets; extended from
|
||
Go alone on 2026-09-08), `COMMERCIAL.md` for the commercial door, `NOTICE` for
|
||
third-party attribution. These are the follow-ups that adoption deliberately
|
||
left open.
|
||
Background, templates, and the case law behind each: `membcons-db`
|
||
`documents/reference-agplv3-dual-licensing.md`.
|
||
|
||
### ~~Contributor License Agreement: no text, no signing workflow, no lawyer's read of the grant~~ — RESOLVED 2026-09-08
|
||
Labels: `licensing`, `legal`, `tracking`
|
||
|
||
**Resolved 2026-09-08.** `CLA.md` v1.0 is the Apache ICLA v2.2 adapted through five decisions taken with the maintainer (outbound terms with Harmony's promise-back; copyright grant to the maintainer alone, patent grant still reaching recipients; transferable with multi-tier sublicensing; Wisconsin governing law; moral rights covered by definition plus a never-assert fallback). Signing is the `CLA-SIGNATORIES.md` pull-request procedure, checked by the maintainer at merge; no bot, so the forge question is moot. `CONTRIBUTING.md` points at both. Still deferred: a Corporate CLA if a company ever contributes (§4 offers permission or waiver only), and a severability clause if counsel wants one.
|
||
|
||
`CONTRIBUTING.md` states that a CLA is required and what it grants, but the
|
||
agreement does not exist: there is no text, no signing workflow, and nobody
|
||
qualified has read the relicensing grant. Today that costs nothing, because
|
||
every commit in the repository is the copyright holder's and the commercial
|
||
offer in `COMMERCIAL.md` is therefore unobstructed.
|
||
|
||
**It stops being free the moment a first outside contribution is merged.** A
|
||
copyrightable patch taken under plain AGPL makes its author a co-owner of the
|
||
combined work, and the commercial license can no longer be granted over the
|
||
whole of it without that author's sign-off. A Developer Certificate of Origin
|
||
does not substitute: it attests provenance and conveys no relicensing right.
|
||
This is a tripwire to arm before the repository is publicized, not a
|
||
prerequisite to the licensing already in place, and it is the one step in the
|
||
sequence that cannot be taken retroactively.
|
||
|
||
Work: pick the instrument (Project Harmony's CLA with the outbound option that
|
||
permits commercial and proprietary relicensing, or an Apache-style ICLA whose
|
||
sublicense right reaches the same result); have an IP lawyer read the
|
||
relicensing and patent grants specifically; publish the text as `CLA.md`; wire
|
||
the signing check into whichever forge the repository lives on. CLA Assistant
|
||
assumes GitHub, so the Gitea migration on [milestones.md](milestones.md)
|
||
decides whether that service is available or the check has to be manual. Then
|
||
point `CONTRIBUTING.md` at the result and drop its "not published yet"
|
||
paragraph. Reference §2 (why a CLA and not a DCO) and §5 (the template
|
||
comparison).
|
||
|
||
### First commercial license: draft when a buyer appears, not before
|
||
Labels: `licensing`, `legal`, `deferred`
|
||
|
||
`COMMERCIAL.md` takes the "contact us" posture deliberately: no published terms,
|
||
no price list, maximum negotiating flexibility, nothing for a competitor to
|
||
anchor against. The cost of that choice is that there is no agreement to send
|
||
when someone does make contact.
|
||
|
||
Deferred on purpose. Drafting terms before a counterparty exists guesses what
|
||
the market wants and then locks the guess in; the first deal is what teaches the
|
||
terms. Spend the lawyer's hours at that point, on a template meant to be reused.
|
||
Questions worth putting to them specifically: how to word the
|
||
escape-from-copyleft grant; perpetual or subscription; indemnity and warranty
|
||
scope; and whether the grant is worded so that a party using the software
|
||
without complying with the AGPL owes the commercial fee, which is the damages
|
||
theory *Artifex v. Hancom* rests on. Reference §3 and §10.
|
||
|
||
### REUSE adoption is sequenced after CI; until then the stamper covers hand-written source
|
||
Labels: `licensing`, `tooling`, `low-priority`
|
||
|
||
**Decided 2026-09-08.** REUSE (reuse.software, FSFE) is three rules: every
|
||
file states holder and license, in a header or in a bulk `REUSE.toml`
|
||
declaration; every license named lives as `LICENSES/<id>.txt`; `reuse lint`
|
||
checks both. One `pipx install reuse`, an afternoon of setup, no learning
|
||
curve. What it adds over the stamper is a check that fails on a bare new file
|
||
and `reuse spdx`, an SPDX bill of materials, both of which a commercial
|
||
buyer's counsel recognizes. The check only earns its keep if something runs
|
||
it, and this repository has no CI (lint in CI withdrawn 2026-09-01; CI is its
|
||
own exploration). Adopting it now means maintaining a linter nothing runs.
|
||
|
||
**Sequence:** `scripts/spdx-headers.sh` now, extended on 2026-09-08 from Go
|
||
to SQL migrations and seeds, the Go templates under `internal/` (the
|
||
`{{- /* */ -}}` form, so nothing is sent to browsers and the rendered output
|
||
is byte-identical), and the first-party JavaScript and CSS; `make
|
||
spdx-headers` runs it and `make sqlc-generate` re-runs it. REUSE when CI
|
||
lands with the Gitea migration: `reuse lint` becomes a one-line CI step, the
|
||
`REUSE.toml` written then is the one that would be written today, and it
|
||
closes the `LicenseRef-Commercial.txt` item below at the same time.
|
||
|
||
**Two exclusions the stamper records in its own header.** The sqlc query files
|
||
(`*/queries/*.sql`): sqlc copies every comment preceding the first `-- name:`
|
||
line into the generated Go as that query's doc comment and folds the blank
|
||
line into the query string, verified by regenerating on 2026-09-08 (55
|
||
generated methods gained a bogus doc comment); a header there pollutes the
|
||
generated code, the generated `.sql.go` beside each query file carries the
|
||
header instead, and `REUSE.toml` covers the query files by glob when adopted.
|
||
The vendored browser assets, which keep their own licenses per `NOTICE`.
|
||
|
||
**Still open, closed by adoption:** under the SPDX/REUSE convention a
|
||
`LicenseRef-` operand resolves to `LICENSES/<ref>.txt`. That file does not
|
||
exist, so a REUSE run would report the reference as dangling. Nothing is
|
||
broken: the identifier is a signal that a commercial option exists rather
|
||
than a self-executing grant, and the commercial license is a private contract
|
||
nobody could self-serve from a repository file. `COMMERCIAL.md` carries the
|
||
human-readable side; the file, when added, points at it rather than restating
|
||
it. Reference §5.
|
||
|
||
## Infrastructure, operations & testing
|
||
|
||
### ~~TestPlanLaddersWalkthrough flakes at MustWaitLoad~~ — RESOLVED 2026-08-23 (root-caused and fixed same day)
|
||
Labels: `test-infra`, `flaky`, `e2e`
|
||
|
||
**Found during `ux-honest-surfaces` verification (reproduced identically at pristine HEAD, so not a regression), root-caused, fixed.** Mechanism: `MustNavigate` returns at navigation *commit*; a bare `page.MustWaitLoad()` immediately after evaluates rod's JS helper through a cached execution-context object from the *previous* document; during the document swap Chrome rejects the stale reference with `-32000 "Object reference chain is too long"`, which rod v0.116.2 does not classify as retryable (it retries only the exact string `"Cannot find context with specified id"` — `rod/page_eval.go:130`, `lib/cdp/error.go:26`) → panic. Other interleavings of the same race surface as `context deadline exceeded`. The race was always present (10j hit it once and blamed a dead dev server); a fast empty-DB dev stack made the window land reliably. Fix: removed the racy construct at both of the suite's only two call sites — plan_ladders_test.go drops the redundant `MustWaitLoad` (the following bounded `MustElement` is the stronger load signal and re-resolves contexts fresh per attempt), lookup_test.go wraps its recovery-path settle in a tolerated `rod.Try` with a timeout. Verified: 3 consecutive isolated greens + full walkthrough package green (62s, matching the 10j baseline). Latent residue for a future dep bump: rod's retry allowlist not recognizing modern Chrome's stale-reference message — worth rechecking on the next rod upgrade.
|
||
|
||
### Temporal schedule management on redeploy
|
||
Labels: `operations`
|
||
Old schedules not cleaned up when config changes.
|
||
|
||
Resolved (2026-07-11) for the FedWiki sync schedule's redeploy args-refresh half
|
||
by the `fedwiki-sync-schedule-resilience` change: `EnsureSyncSchedule` now
|
||
classifies `Describe` errors correctly (only Temporal `NotFound` routes to
|
||
`Create`; any other error is surfaced, not masked as absence), so a transient
|
||
describe failure no longer skips the `Update` that refreshes stale schedule args.
|
||
See that change's proposal.md Impact list.
|
||
|
||
### Billing sweep schedule shares the FedWiki describe-error misclassification
|
||
Labels: `operations`, `tech-debt`
|
||
`internal/workflows/billing/schedule.go`'s `EnsureSweepSchedule` was **not**
|
||
audited or fixed by `fedwiki-sync-schedule-resilience` (explicitly out of that
|
||
change's scope). Confirmed by inspection it shares the same pre-fix shape: its
|
||
`if _, err := handle.Describe(ctx); err == nil { ...update... }` else
|
||
unconditionally `Create`s, so any non-`NotFound` `Describe` error is misread as
|
||
"schedule absent" and routed to `Create` — the exact bug fixed in
|
||
`EnsureSyncSchedule` (task 2.4). Unlike the FedWiki case it carries no baked-in
|
||
workspace UUID, so there is no FK-violation follow-on, but a transient describe
|
||
failure would still fail the create-after-describe and skip the sweep schedule's
|
||
args refresh. Follow-up debt: apply the same `NotFound`-only routing +
|
||
create-race fallback.
|
||
|
||
### Database backup before migrations
|
||
Labels: `operations`
|
||
|
||
### Add middleware tests
|
||
Labels: `testing`
|
||
CSRF, logging, compression, recovery, request ID, timeout, secure headers, CORS.
|
||
|
||
### HTMX handler file structure cleanup
|
||
Labels: `refactor`
|
||
|
||
### Temporal auth race on first boot
|
||
Labels: `bug`, `operations`
|
||
After fresh `docker compose up -d`, first `member-console start` fails to reach Temporal. Second attempt works. **Live cold-boot testing (2026-07-01) found the dominant cause is server-side, not the JWKS/JWT race hypothesized from code reading:** the `temporal` auto-setup container races its own database — it runs schema setup before `temporal-db` accepts connections, fails (`no usable database connection found`), and exits, so nothing listens on the Temporal port and `start` gets `connection refused`. The `temporal` service used short-form `depends_on: [temporal-db]` (waits for container start, not readiness) and had no `keycloak` dependency. Two-part fix (in the `temporal-first-boot-retry` change, M12b): (1) compose ordering — gate `temporal` on `temporal-db` + `keycloak` `service_healthy` (+ a `temporal-db` healthcheck) so Temporal comes up; (2) a bounded client-side connect retry in member-console so it rides out Temporal's transient warm-up (observed: a `connection reset by peer`) and real-deployment Temporal/IdP restarts. Verified live: fresh boot → first `start` connects after one retry and fully boots.
|
||
|
||
### Migration orchestration mechanics
|
||
Labels: `design-feedback`
|
||
Per-module migrations need a boot sequence that collects from each module's embedded FS in dependency order.
|
||
|
||
### ~~Makefile `sqlc-generate` target is stale~~ — RESOLVED 2026-08-22 (`model-doc-rot`, 10j)
|
||
Labels: `tooling`, `resolved`
|
||
The target now iterates the nine directories that carry a `sqlc.yaml`, failing on first error, matching `docs/database-management.md`. Fixing it immediately paid off: the first run surfaced that `internal/integration`'s generated code had never been regenerated after migration 00010 added `suspended_at`/`retired_at` to `core.providers` (the broken target had hidden the drift); the regeneration is folded into this change, and a second run is a clean no-op. (Found 2026-08-22 during `purchase-path-blockers`.)
|
||
|
||
### Shared trigger function ownership
|
||
Labels: `design-feedback`
|
||
`update_updated_at_column()` is used by all modules. Probably belongs in a shared migration or the `db` package.
|
||
|
||
**Update 2026-08-01** (`test-db-isolation`): checked against the
|
||
cluster-global question and cleared. Functions are database-scoped, not
|
||
cluster-global like roles, so each database in a cluster gets its own copy
|
||
and `public.update_updated_at_column()` never blocked a second database's
|
||
migration. The same holds for every `ALTER FUNCTION … OWNER TO` and `GRANT`
|
||
in the streams. This stays open purely as the code-organization question it
|
||
started as.
|
||
|
||
### Database vs app layer: where enforcement lives is ad hoc
|
||
Labels: `architecture`, `conventions`, `design`
|
||
|
||
Observed 2026-08-21 while writing the product-catalog model card (10i): some load-bearing rules are schema-enforced (CHECK constraints, partial unique indexes, conferral reading only its view), while equally load-bearing rules are app-layer-only (publication gating on catalog queries, `display_category` behavioral neutrality, the composite purchasability gate). Which layer a new rule lands in has been ad hoc — whatever the implementing session reached for that day. Wanted: a documented convention for when a rule belongs in the schema versus the app layer (candidate axes: cross-surface consistency, whether violation corrupts data or merely misleads the UI, migration cost, testability). The model cards' **[db]**/**[app]** invariant tags now surface the de-facto split per model; the convention would make the split deliberate. Candidate home: a developer doc under `docs/`, referenced from the model-catalog index.
|
||
|
||
## Deferred to future milestones (metering, commitments, bundles)
|
||
|
||
### Storage compliance check at downgrade presupposes storage metering (unbuilt) — deferred to M13
|
||
Labels: `enhancement`, `entitlements`, `billing`, `metering`, `deferred`, `blocked`
|
||
|
||
The M8c milestone named a **storage compliance check** as new downgrade work (Standard → Public storage reduction). The 8c scoping pass (2026-05-31) found it presupposes storage **metering**, which is entirely unbuilt: no `storage_bytes` numeric-entitlement is seeded, there is no usage tracking, and no enforcement consumer exists. The `storage_bytes` resource key is recognized in the data model / `design/entitlements/`, but `design/entitlements/README.md` calls storage metering "future metering infrastructure." The "16 MB" / "256 MB" tier figures are documented but never enforced in code.
|
||
|
||
A "compliance check" needs a measured usage value to compare against the new limit. Storage is measurable now: `wiki-plugin-farmmanager` v0.4.1 reports each site's storage bytes and last-modified time, and `fedwiki.sites` records both (`storage_bytes`, `last_modified_at`). Network egress is not, because the upstream project deferred it. What is still missing is the entitlements half — no `storage_bytes` limit is seeded, nothing sums the per-site figures per pool, and no consumer enforces one. This is a metering build, not a downgrade-UX build, so it belongs with network-egress metering in **M13 (Network / Storage Metering & Flexible Service)**, which builds on the M9 integration contract's namespaced resource keys.
|
||
|
||
**Scope when scheduled (M13):** seed a `storage_bytes` resource limit per tier, meter per-site/per-pool storage usage (`usage_events` or a periodic measure), then a downgrade-time check that surfaces over-quota state. Until then, 8c's downgrade does not gate on storage. Split out 2026-05-31 during the 8c scoping pass. Related: the `force_reduce`/site-selection split-out above, and the integration-contract-before-metering swap (2026-05-31) that put M9 ahead of the metering milestone (now M13).
|
||
|
||
### Pooled metered resources across arbitrary provider sets — scalar `resource_keys.provider` can't express subset scoping (model rework, deferred to M13)
|
||
Labels: `design-feedback`, `architecture`, `entitlements`, `metering`, `deferred`
|
||
|
||
The M9 contract gives `entitlements.resource_keys` a single nullable `provider` column encoding **fact-source cardinality** (`provider='fedwiki'` = usage from one provider; `provider IS NULL` = pooled across all providers, or platform-native — see the platform-owned-metered analysis, 2026-06-07). A scalar column expresses exactly two scopes: **one** provider, or **all/none**. It cannot express an **arbitrary subset** — e.g. a single storage quota pooled across `{FedWiki, NextCloud}` while Discourse meters separately, or a platform pool with a per-provider sub-cap nested inside it. Per-provider and pool-across-everything are just the endpoints (set size 1, set = all); the general case is "this resource is metered over *this set* of providers."
|
||
|
||
**Why it needs model rework, not a column tweak:** the scalar can't carry a set. Supporting arbitrary scopes needs a grouping construct — a junction (`resource_key` ↔ N `providers`), or a named "metering scope" / "resource group" entity that a key and its contributing providers attach to, with usage rolling up over the group's members. That also interacts with the limit side (a pooled limit on the group vs. per-provider sub-limits, meter enforcing the tighter — the "nested quotas" option already flagged as available-if-wanted).
|
||
|
||
**Deferred — not designing now.** Flagged so M13's metering model either accounts for arbitrary-set scoping or consciously punts to a flat scalar for v1. The pooled-vs-per-provider call already handed to M13/product generalizes to "*which set* of providers shares this quota." Noted 2026-06-07 during the M9 9a extension-model exploration. Related: the storage-compliance / storage-metering split-out above; "Integration architecture (and operator IA placement)" and "FedWiki-only integration assumption pervades UI and data patterns" below; the dotted-display-form retirement (bare key = machine id, `provider` column = grouping, `display_name` = UI label) settled in the same exploration.
|
||
|
||
### Commitment `early_termination_policy='fee'` branch unimplemented (block/allow built; fee deferred)
|
||
Labels: `enhancement`, `billing`, `deferred`
|
||
|
||
Decision 126 (`design/billing/model.md`) models minimum-term commitments on `billing.subscriptions` (`commitment_end`, `commitment_renewal`, `early_termination_policy ∈ {block, fee, allow}`) and dispatches a mid-term downgrade/cancellation by policy: `allow` proceeds immediately, `block` defers the change to `commitment_end` via a `subscription_scheduled_changes` row (`effective_trigger='term_boundary'`), and `fee` proceeds immediately **but emits an `early_termination_fee` invoice line item** (`invoice_line_items.line_type += 'early_termination_fee'`, carried so the patronage pipeline attributes it correctly).
|
||
|
||
`plan-switch-mechanics` (M8 workstream F) implements **`block` and `allow`** — they ride the scheduled-change firing path that change already builds, so they were nearly free. It does **not** implement **`fee`**: emitting the `early_termination_fee` line item and wiring its patronage/credit attribution is genuinely new economic machinery, out of scope there. Until it lands, a `fee`-policy committed downgrade/cancel is **refused with a reason** at the endpoint and rendered **disabled-with-reason** in the member catalog (never proceeds without emitting the fee it cannot yet emit).
|
||
|
||
**Severity:** none today — the live deployment is entirely **evergreen** (`commitment_end IS NULL` on every subscription), so no subscription carries a `fee` policy and nothing exercises this path. It becomes real only once an operator configures a `fee`-policy commitment.
|
||
|
||
**Scope of change:** a later billing change — emit the `early_termination_fee` line item on the next invoice when a `fee`-policy commitment is broken mid-term, attribute it through the patronage pipeline, then flip the endpoint/catalog from refuse/disabled to active. Depends on the patronage-attribution path for the new `line_type`. Split out 2026-05-30 during `plan-switch-mechanics` design (correcting an earlier mischaracterization that treated all committed moves as a flat refusal — real `block` defers, it does not refuse).
|
||
|
||
### Member catalog does not pre-label committed moves (`block`/`fee`) — post-click enforcement only
|
||
Labels: `enhancement`, `frontend`, `billing`, `deferred`
|
||
|
||
The member catalog (`internal/server/member_products.go` `buildPlansData` → `member_plans.html`) renders paid switch/cancel controls **generically enabled**. It does not resolve each enrolled ladder's backing subscription to read `commitment_end` / `early_termination_policy` and reflect the policy *on the control*, as the `member-product-discovery` spec scenario "Committed move reflects the early-termination policy" describes: `allow` = active immediate, `block` = active **labeled** "takes effect at commitment end", `fee` = disabled-with-reason.
|
||
|
||
Policy is instead enforced by the **endpoints after the click**: a `block` downgrade/cancel defers to `term_boundary` (the member sees it scheduled for the boundary date), and a `fee` move is refused (`ErrCommitmentFee` → error banner). So enforcement is correct and tested (`plan-switch-mechanics`, 2026-05-30); only the *pre-click labeling* is missing.
|
||
|
||
**Why deferred:** the live deployment is entirely **evergreen** (no subscription carries a commitment), so the `block`/`fee` label branches would never render — the labeling is dead code today and would cost an extra per-enrolled-ladder subscription + commitment lookup in `buildPlansData`. Correctness holds regardless because the endpoints gate the action. The only gap: a committed member would see a normal-looking control and learn the policy only *after* clicking, rather than up front.
|
||
|
||
**Scope:** thread the backing subscription's commitment (`commitment_end` + `early_termination_policy`) into the per-(ladder, tier) view model, then branch the control's label/enabled state. Pairs naturally with the deferred `fee` branch and with operator-side commitment configuration — none of which exist yet. See the `early_termination_policy='fee'` issue above and the "Minimum-term commitments" backlog item in `status/milestones.md`.
|
||
|
||
### Member plan bundles — cross-ladder SKUs & exclusivity messaging (deferred, blocked)
|
||
Labels: `enhancement`, `frontend`, `billing`, `deferred`, `blocked`
|
||
A **bundle** is a single product that occupies rungs on **multiple ladders at once** (cross-axis) — e.g. a SKU covering hosting-Standard *and* support-Basic together — as opposed to a normal tier, which sits on one axis. Member-facing work (`member-plan-bundles` capability, named-but-deferred in the `member-ladder-aware-catalog` proposal) would present: what a bundle includes, **cross-ladder exclusivity messaging** ("subscribing replaces your standalone hosting / support"), and disabled-with-reason on the standalone tiers a held bundle blocks. **Blocked:** no bundle concept exists in code, and there is no operator-side bundle configuration — so there is nothing for the member UI to present yet. Phase 2 depends on operator bundle support landing first; expect `member-plan-bundles` to become its own change paired with an operator-side counterpart. Tracked here so it survives archival of the `member-ladder-aware-catalog` proposal. Split out 2026-05-24 during that change's design review. **Update 2026-05-30:** workstream D shipped as `operator-topology-legibility`, scoped to **legibility only** — it makes the existing M:N (a product as a tier in several ladders) legible but deliberately does **not** introduce the bundle domain. So both halves remain unbuilt: the operator bundle-configuration counterpart is still a separate future change, and this member half stays blocked on it.
|
||
|
||
### Add-on multi-purchase semantics & checkout
|
||
Labels: `enhancement`, `frontend`, `backend`, `billing`
|
||
Add-ons are off-ladder, stackable products a member may hold multiple times (unlike ladder-gated plans, which are mutually exclusive per axis). The `member-ladder-aware-catalog` change frames add-ons as a distinct stackable section but deliberately defers the *mechanics*: repeat purchase, quantity, and the checkout/provisioning path (one `pool_provision` per purchase, `chk_pool_provisions_source = 'purchase'`). Belongs to a later checkout-mechanics change alongside the paid plan-switch work (`plan-switch-mechanics`), not the catalog presentation change. Split out 2026-05-24 during `member-ladder-aware-catalog` design review.
|
||
|
||
## Model documentation
|
||
|
||
### Product-catalog card: drift & trap ledger (2026-08-21)
|
||
Labels: `documentation`, `debt`, `billing`, `bug`
|
||
|
||
The model cards keep their bodies free of drift bookkeeping (calibration-gate decision in the `model-cards` change); traps observed while writing each card are ledgered here with full references. From the product-catalog card:
|
||
|
||
- **~~The designed reference tells the pre-doc-41 story~~ — RESOLVED 2026-08-21.** Decisions 134–139 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 operator-UX audit finding).
|
||
- **`mixed` billing shape has no consumer.** `core.product_shape` reports `mixed` (recurring and one-time prices both active) but nothing blocks or blesses selling that way — tracked upstream, see the mixed-shape entry under Design feedback below.
|
||
- **`prices.trial_period_days` stored but honored nowhere** — already tracked under "Deferred-remediation debt" above; listed here only because the column sits in this model.
|
||
|
||
### Payments-billing card: drift & trap ledger (2026-08-21)
|
||
Labels: `documentation`, `debt`, `billing`, `bug`
|
||
|
||
From writing `docs/models/payments-billing.md` (code-verified evidence sheet + adversarial pass). Two entries are live bugs needing triage:
|
||
|
||
- **~~BUG: `core.webhook_events` partitions run out~~ — RESOLVED 2026-08-22 (`purchase-path-blockers`, 10j).** The promised creator now exists: `EnsureWebhookEventPartitions` (`internal/db/partitions.go`, current + 3 months ahead, mirrors the migration's naming/bounds) runs logged-not-fatal at boot in `cmd/start.go` and on a 24h Temporal schedule (`webhook-partition-ensure`, `internal/workflows/maintenance/`, knob `webhook-partition-ensure-interval`); DB-backed idempotency test proves insert-fails-before/succeeds-after. Live-verified: boot created `webhook_events_2026_11`. Card updated: payments-billing map. Original entry: The baseline migration creates only the current + next two monthly partitions and its comment promises "application boot or a scheduler" creates future ones — but no such code exists anywhere (repo-wide grep). Roughly three months after a deployment's initial migration, inserts hit a missing partition and **every webhook write fails**. Fix candidates: partition creation at boot in the migration/boot path, or a Temporal maintenance schedule shared with the Discourse webhook path. Launch-relevant: a fresh OSS deployment hits this silently at month three.
|
||
- **~~BUG: webhook receipt can silently lose events~~ — RESOLVED 2026-08-22 (`purchase-path-blockers`, 10j).** The receiver now answers 500 when the insert fails so Stripe redelivers, and 200 only once the event is recorded or deduplicated (`TestServeHTTP_InsertFailureReturns500`). Card updated: payments-billing invariant 14. Original entry: The receiver returned 200 to Stripe even when the `core.webhook_events` insert failed (`internal/integrations/stripe/web/webhook.go` logs and still answers 200), and Stripe only redelivers on non-2xx — so a transient DB failure at receipt drops the event permanently. Subscription events later self-heal via reconcile triggers; invoice/payment events do not.
|
||
- **The no-Stripe-ids-on-core rule has an undocumented exception.** `core.subscription_changes.stripe_event_id` is NOT NULL on a core table, and the reconciler writes attribution strings into it that are not always event ids ("eager:checkout-return", "fire:cancellation"). Either rename it to `attribution`/`reason` or move it provider-side.
|
||
- **~~`core.subscriptions.status` is bare TEXT with no CHECK~~ — RESOLVED 2026-08-22 (`schema-hardening`, 10j).** Migration 00010 adds a CHECK over Stripe's full closed vocabulary (eight values, including `incomplete_expired`, which the reconciler really receives). The designed vocabulary omits `incomplete_expired` — see the Design feedback note filed 2026-08-22.
|
||
- **Invoice/payment projection trusts the webhook payload.** The "never trust the payload" cardinal rule is scoped to subscriptions; invoice and payment rows are built straight from scrubbed payload fields — a deliberate, carded posture (display facts). **Update 2026-08-22 (`schema-hardening`, 10j): the unguarded int64→int32 casts within that projection are now checked** — out-of-range amounts fail the activity with a non-retryable error naming the event instead of storing a wrapped negative value. En route the executor found and fixed a live bug: `ProcessWebhookEvent` wrapped dispatch errors with `fmt.Errorf`, and the Temporal SDK judges retryability by the top-level error type without unwrapping, so every non-retryable error in the package was silently retryable; now unwrapped via `errors.As` (regression-tested).
|
||
- **~~The outbox customer path is dead code~~ — RESOLVED 2026-08-22 (`model-doc-rot`, 10j).** `EnsureStripeCustomer` deleted; `EnsureStripeCustomerPayload` kept (the outbox worker still unmarshals it, now commented with its consumer); the test that exercised only the deleted path removed, the live outbox-executor test kept.
|
||
- **~~`docs/stripe.md` claims mapping `sync_status` can become `dead_letter`~~ — RESOLVED 2026-08-22 (`model-doc-rot`, 10j).** Doc corrected: mappings go `pending` → `synced` (or `deleted`); dead-letter is an outbox status; a terminally failed sync leaves the mapping `pending` until the operator retry.
|
||
- **One-pending-scheduled-change has no index backing** — subscription-scoped supersession in the writers only; the design's item-scoped partial unique index doesn't exist. Already tracked under "Subscription scheduled-change + commitment schema implemented from design-only model" (Design feedback); listed here for the card's sake.
|
||
- **Commitments are read-complete, write-absent** — gating honors the columns; no code, query, or UI sets them, so production is all-evergreen and the block/fee branches are dead paths. Already tracked (commitment fee entry, deferred section).
|
||
- **`prices.trial_period_days` still stored-but-never-honored** — checkout builds no trial parameters. Already tracked (Deferred-remediation #1).
|
||
|
||
### Plan-ladders card: drift & trap ledger (2026-08-21)
|
||
Labels: `documentation`, `debt`, `plan-transitions`, `bug`
|
||
|
||
From writing `docs/models/plan-ladders-transitions.md` (code-verified evidence sheet). The headline is developer-doc rot: three living `docs/` pages describe machinery deleted by the doc-41 change — launch-facing, since these are the docs a new contributor reads:
|
||
|
||
- **~~`docs/plan-architecture.md`, `docs/grant-plan-safety.md`, and `docs/plan-management.md` all describe the dead Go `Transition` primitive~~ — RESOLVED 2026-08-22 (`model-doc-rot`, 10j).** plan-architecture and grant-plan-safety retired (near-total redundancy with the plan-ladders card; the 2026-04 incident narrative preserved at `status/archive/incident-2026-04-grant-provision-sync.md`; `docs/README.md` index and the `internal/entitlements/doc.go` package comment updated); plan-management corrected in place with its model description delegated to the card. Original entry: plan-architecture says "Transition is defined in `internal/entitlements/transitions.go`" (file deleted); grant-plan-safety's reference list names `transitions.go` and `RevokeGrantAndTransition` (neither exists); plan-management says Extend "invokes Transition with Extend=true" recording `transition_type='extend'` ('extend' is CHECK-rejected for new writes; extend records 'transfer' via grant lineage). As-built: the five SECURITY DEFINER conferral functions (`internal/db/migrations/00005_doc41_conferral_functions.sql`) fronted by `internal/entitlements/conferral.go`. All three docs need a doc-41 rewrite pass — M10-relevant.
|
||
- **~~`docs/plan-management.md` documents restore-as-downgrade; the code restores-as-initiate~~ — RESOLVED 2026-08-22 (`model-doc-rot`, 10j).** Corrected in the same plan-management pass: restoration is now documented as recording `initiate` via the floor-guarded reapply path.
|
||
- **~~`docs/grant-plan-safety.md`'s wrong-path heuristic is inverted post-doc-41~~ — RESOLVED 2026-08-22 (`model-doc-rot`, 10j).** Resolved by the doc's retirement; the card carries the correct as-built shape story.
|
||
- **The plan-transitions spec mandates a rank-shape CHECK that doesn't exist.** `openspec/specs/plan-transitions/spec.md` requires a CHECK binding each transition type to its rank pattern; migrations carry only the type-list and occupancy CHECKs — rank shape holds db-code via `core.confer`'s derivation. Either add the (small, NOT VALID) CHECK or amend the spec to accept db-code enforcement.
|
||
- **Rank contiguity is detection-only.** 0-based contiguous ranks are maintained by UI discipline and reported by the validation page (chosen 2026-07-23, commit `93bf698`); out-of-UI writers can still create gaps. Candidate: deferrable constraint or trigger (nontrivial — reorder's park-then-assign fights a non-deferrable UNIQUE).
|
||
|
||
### Entitlements card: drift & trap ledger (2026-08-21)
|
||
Labels: `documentation`, `debt`, `entitlements`, `bug`
|
||
|
||
From writing `docs/models/entitlements.md` (code-verified evidence sheet). Two functional gaps lead:
|
||
|
||
- **Rule changes never reach existing pools.** Design promises rule add/modify/deactivate re-evaluate affected pools; as built, `CreateEntitlementSetRule` and `DeleteEntitlementSetRule` (`internal/server/operator_entitlement_sets.go`) call no materializer, so limits and boolean grants stay stale until an unrelated conferral event happens to touch each pool — and the operator has no way to push a rule change at all. Needs a fan-out decision (synchronous over N pools vs queued) plus the UX; filed as its own question below the ledger entries in this section's history — treat this bullet as the tracking item. **Resolved 2026-09-15 by the `entitlement-set-changes` change under Decisions 143 and 145:** the rule write is enclosed in `core.commit_rule_change`, which records the act and one obligation per carrying pool; the commit materializes those pools in its own transaction up to the measured cap of 250 and a poller-driven drain settles the rest; the operator previews the consequence as a dry run before committing and reads the act in the set's History section and the organization's Entitlement changes trail.
|
||
- **~~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, clause by clause; its six new findings are upstream ratification input and are not repeated here. Member-console-side items:
|
||
|
||
- **~~`Integration.Slug()` and the manifest's slug are documented as "must match" but nothing verifies it~~ — RESOLVED 2026-08-22 (`schema-hardening`, 10j).** Boot now asserts the match per integration (`internal/integration/slug.go`, called from `cmd/start.go` before provider registration) and fails startup naming the integration and both strings.
|
||
- **~~Env-sourced enum config values are not enum-checked at startup~~ — RESOLVED 2026-08-22 (`schema-hardening`, 10j).** `ValidateStart` now validates every declared enum key's resolved value (unset passes), sharing one membership helper with the override path (`CoerceOverride`) so they cannot diverge; failures aggregate into the boot error report naming key, value, and allowed set.
|
||
- **Registry tables' home drifted from design:** doc-39 specified `billing.providers`; as-built they live in the core schema's squashed baseline. With v16 the designed model carries them as seam tables of the provider seam, so the conceptual-address convention (Decision 140) covers the homing. Substrate naming drift (`integration.outbox` → `core.outbox`) likewise stands as conceptual addressing.
|
||
- **Convergence expected as-built (Doc 44 ruling, 2026-08-21):** ratification deliberately kept the designed `core.providers` discipline — a CHECK on `status` and the `suspended_at`/`retired_at` timestamps — rather than absorbing the as-built free-text column. **Update 2026-08-22 (`schema-hardening`, 10j): the member-console half is done** — migration 00010 adds the status CHECK and both timestamps, closing the card-tracked drift. The §6.3 boot/lint declaration↔execution check and `Enqueue` validation against `provider_operations` likewise stay **normative** upstream — implementation debt deferred to the dispatch layer, not amended away.
|
||
- **Webhook dedup reference corrected upstream:** the designed global two-column UNIQUE was unimplementable on the partitioned table; the reference now documents handler-level `WHERE NOT EXISTS` dedup as the binding contract. Both as-built receivers (Stripe and Discourse) already conform — verified 2026-08-21 by grep; a conformance-report aside claiming Stripe retained the old shape was itself wrong and has been corrected with membcons-db.
|
||
- **`fedwiki_sites` is the documented historical exception** to own-your-keys (seeded by core's `00002` rather than FedWiki's stream); the authoring guide says to copy Discourse, not FedWiki. Fine as documented; listed so nobody "fixes" the guide against the exception.
|
||
- Already tracked, restated by the card: the payments-provider seam (Stripe's genus-only registration), the converged-set manifest gap, the free-form outbox `action_type` vocabulary, and the deferred declared-verb-to-activity binding check.
|
||
|
||
### Domains-registry card: drift & trap ledger (2026-08-21)
|
||
Labels: `documentation`, `debt`, `domains`
|
||
|
||
From writing `docs/models/domains-registry.md`. Two of the parked notes' traps are now CLOSED in code and recorded here so nobody re-fixes them: the own-root carve loophole (closed by `claim-expiry-and-carve-guards` D4's intent-based evaluation) and the caller-side entitlement gate (moved into `Registry.ClaimExternal` by `centralize-external-claim-gate`). Open items:
|
||
|
||
- **The schema looks like it constrains what only Go constrains.** `reversed_labels` is plain TEXT with no generated-column or trigger tie to `root_fqdn` (a row written outside the registry silently breaks every subtree scan), and nothing constrains placement-inside-claim containment. Candidate domains migration `00003` if accepted as a design decision.
|
||
- **Zero means three different things across the config layers.** Operator-set `domains-abandon-budget=0` maps to PolicyDisabled(-1) → ledger off; but a raw `Policy.AbandonBudget == 0` means "unconfigured" → default 3. A composition root passing a literal 0 straight through flips the ledger ON with the default budget. Also the hardening design's non-goal text ("approval-only falls out of budget 0") is backwards as shipped — 0 disables the ledger. Doc fix + consider a typed knob.
|
||
- **`/domains/ask` 200 is ambiguous while the strangler fallback is configured** — 200 means "servable placement OR the legacy answerer said yes". Migration-window caveat; retires with the fallback (10d Slice 3). Nothing dates that window.
|
||
- **~~`reconcileSite`'s doc comment lists six adoption outcomes; the code has seven arms plus an earlier exit~~ — RESOLVED 2026-08-22 (`model-doc-rot`, 10j).** The comment now enumerates the actual seven branches in code order (including the non-active-claim skip) and names `GrandfatherCarvedClaim` with its deliberate name-policy bypass.
|
||
- **~~Cancel doesn't probe for evidence~~ — RESOLVED 2026-08-22 (`schema-hardening`, 10j).** `MarkClaimCanceled` gained expiry's `@evidence` parameter, and the member cancel path now runs a fresh TXT probe before the write (3 attempts, ~2s each, ~5s budget; probe runs before the registry lock so DNS latency never holds the allocation transaction). Strict on exhaustion per maintainer decision: all-attempts-failed counts as no evidence. Note: the workflow's probe couldn't be imported (cycle); the record format is reimplemented locally with a drift-pin cross-reference, following the ledger test precedent.
|
||
- **Dead generated surface:** `SetPlacementServable` and `DeletePlacementByResource` have no non-test callers. Cheap cleanup.
|
||
- **Assumptions to keep visible:** budgets key on workspace_id with no Sybil bound beyond the operator-granted boolean (an operator attaching `external_domain_claims` to a free tier changes the abuse model); the ledger's ScopeLabels is a label count, deliberately PSL-free — the co.uk-style false positive is accepted, not solved. Hosted-label carving is unmetered by design (only the FedWiki numeric quota bounds it), and `name_rules` has no UI — lift the recorded non-goal or declare it seed-only.
|
||
- **Do-not-fix note:** `BudgetExceeded.RetryAt`'s doc comment is correct (the "oldest counted entry" is the budget-th newest overall); it reads wrong on a skim. The sibling Count doc pins the meaning.
|
||
|
||
### Identity card: drift & trap ledger (2026-08-21)
|
||
Labels: `documentation`, `debt`, `identity`, `bug`
|
||
|
||
From writing `docs/models/identity-organization-workspace.md`. The operational risk leads:
|
||
|
||
- **~~Rank-0 fragility can 500 every new-user login~~ — RESOLVED 2026-08-22 (`schema-hardening`, 10j, both guards per maintainer decision).** (a) Deleting a ladder's last tier is refused when the ladder is a live org-type default (narrower than the blanket guard the 2026-07-03 remediation rejected — emptying non-default ladders stays legal); (b) `AutoProvision` now completes a plan-less signup with an Error-level alarm naming the ladder when rank 0 is missing, instead of failing the login; the org is repairable via reapply-defaults once the ladder is fixed.
|
||
- **~~`docs/identity-provider-setup.md` contradicts the code, stale-safe direction~~ — RESOLVED 2026-08-22 (`model-doc-rot`, 10j).** The role-extraction section now documents the union of all four claim locations (deduplicated, order-stable) and the stale `resource_access` limitation callout is gone.
|
||
- **Database role permissions are dormant.** Seeded roles carry rich permission arrays; signup writes memberships and assignments; no server code reads any of it for authorization. Decide (with upstream doc-03) whether to enforce or remove; until then reviewers must not assume they gate anything.
|
||
- **Returning-login session binding is positional** — first owned org, then its first workspace; multi-org membership has no session story, and `org_members` rows in non-owned orgs never surface. Contract or placeholder? Needs the org-switcher decision before multi-org ships.
|
||
- **All lifecycle status columns on identity tables are unchecked free text** (users, persons, organizations, workspaces, org_members) — the vocabulary lives only in Go string comparisons. Same class as the subscriptions.status gap in the payments ledger.
|
||
- **Design far richer than schema (upstream divergence to sync):** invitations, service accounts, PATs, retention holds, person merges, and soft-delete lifecycles are all design-only; even the seeded permission vocabulary names service accounts that cannot exist. Record for upstream; settle when member-facing sharing is scheduled.
|
||
|
||
### Sub-models within the model cards (low priority)
|
||
Labels: `documentation`, `enhancement`, `low-priority`
|
||
|
||
Noted by the maintainer 2026-08-21 while reviewing the first card: the cards' invariants allude to smaller models with their own coherent rule-sets — purchasability and conferral shape are the product-catalog examples; more will surface as cards land. At some point, identify these sub-models explicitly (as named subsections or their own cards). Deliberately unscheduled.
|
||
|
||
## Design feedback (upstream design repo)
|
||
|
||
### design/ points into the console's exploration notebook, which a clone does not have
|
||
Labels: `design-feedback`, `docs`, `upstream-membcons-db`
|
||
|
||
Filed 2026-09-19 when the exploration directory became a local notebook git ignores (`status/MAINTAINING.md`, "Where things live"). Nine files under `design/` carry 23 pointers into it: `companion.md` (9), `documents/MANIFEST.md` (4), `documents/doc-45-identifier-policy.md` (3), `documents/issue-29-implementation-findings.md` (2), and one each in `data-model.md`, `documents/critique-of-member-console-identifiers.md`, `documents/doc-42-model-documentation-division.md`, `documents/doc-44-provider-contract-ratification.md` and `documents/reference-identity-profile-provenance.md`. They cite the identifiers (2026-08), doc39-conformance (2026-08), model-cards (2026-07) and operator-ux-audit (2026-07) notebooks. `design/` is synced from upstream and never edited here, so at the next sync each pointer should state its fact in its own words or cite the tracked home: `docs/identifiers.md`, `docs/models/provider-integration.md`, `docs/models/README.md`, or the archived change.
|
||
|
||
### Designed subscription-status vocabulary omits `incomplete_expired`
|
||
Labels: `design-feedback`, `billing`, `upstream-membcons-db`
|
||
|
||
Filed 2026-08-22 during `schema-hardening`. `design/data-model.md`'s subscriptions section lists a seven-value status state machine (incomplete, trialing, active, past_due, unpaid, paused, canceled) but omits `incomplete_expired`, a real Stripe subscription status the reconciler receives and stores (a Checkout session abandoned past its expiry window). Member-console's new `chk_subscriptions_status_valid` CHECK (migration 00010) uses Stripe's full eight-value closed vocabulary, so as-built is now stricter *and* more complete than designed. Upstream fix is one line: add `incomplete_expired` to the designed list. Until synced, this is the one place the as-built CHECK deliberately exceeds the designed text.
|
||
|
||
### Mixed billing shape semantics — RESOLVED upstream (Decision 138)
|
||
Labels: `design-feedback`, `billing`, `upstream-membcons-db`, `resolved`
|
||
|
||
**Resolved 2026-08-21.** Filed here as a doc-41 residual gap, but membcons-db corrected the reading: Doc 41 §6.4 adjudicates mixed co-occurrence as *legal and explicitly classified* — `product_shape` reports `billing_shape = 'mixed'` as a first-class answer, with a lifetime-license tier alongside a subscription as the canonical case — ratified under Decision 138 (`design/companion.md`, ratified 2026-08-21 with Decisions 134–139). No member-console work implied. Archive on next sweep.
|
||
|
||
### Cross-source supersession vs. continued billing (upstream Issue 31)
|
||
Labels: `design-feedback`, `billing`, `entitlements`, `upstream-membcons-db`
|
||
|
||
The doc-41 §11.2 residue that *did* survive ratification, logged upstream as Stage-1 Issue 31 per Doc 41 §12's own instruction: when an operator grant supersedes a subscription-sourced position, the subscription vehicle keeps billing — the member pays for a position a grant now covers. Upstream owns the normative answer; member-console consumes it when decided.
|
||
|
||
### Suspended-only pools have no baseline floor during dunning (design question)
|
||
Labels: `design-feedback`, `entitlements`, `billing`, `upstream-membcons-db`
|
||
|
||
A pool whose only plan position is suspended (past-due subscription) gets no default restoration: `ReapplyDefaultsIfVacant` counts suspended as occupied (correctly — the paid position must not be superseded by the free baseline), so the organization has no live plan delivery for the whole dunning window. Is that dark period intended, or should suspension trigger a temporary baseline? The guard's mechanics are documented in `internal/entitlements/reapply_defaults.go`; the dunning-period intent is not decided anywhere. Surfaced 2026-08-21 while writing the plan-ladders model card.
|
||
|
||
### Provider schemas: `provider_configs` prescription is per-org and as-built config is app-level
|
||
Labels: `design-feedback`, `integration`, `upstream-membcons-db`
|
||
|
||
`design/integration/architecture.md` ("When a new provider integration is built…") prescribes three standard table types per provider schema, including "a `provider_configs` table for per-organization configuration". As built (9g, `dynamic-provider-config`, 2026-07-22), that prescription is wrong on both axes: integration config scope is **app-level** (single Stripe account, no Connect topology — the per-org model was never implemented by any integration), and per-provider config tables don't exist at all — the Stripe one was dropped after sitting unused for the project's entire history. The as-built model: non-secret runtime config lives in one core-owned `core.integration_config_overrides` table layered over the environment via `ConfigSpec()` declarations; secrets are env-only. The design doc's standard provider-schema table types should reduce to entity mappings + provider-specific object tables.
|
||
|
||
### `tier_reduction_policy` (Decision 125) is design-only — `clamp` is de-facto/emergent, not configurable
|
||
Labels: `design-feedback`, `entitlements`, `billing`, `upstream-membcons-db`
|
||
|
||
Decision 125 (`design/companion.md`, `design/data-model.md`) models a `tier_reduction_policy ∈ {block, defer, clamp, force_reduce}` (default `defer`) on `entitlements.entitlement_set_rules`, enforced per-rule at the locus that knows allocation (`block` as a switch precondition, `defer` via a boundary scheduled change, `clamp`/`force_reduce` at materialization). **The column does not exist in the code schema** — `entitlement_set_rules` is `(rule_id, set_id, rule_type, resource_key, resource_value, resource_per_unit, stacking_policy, reset_period, credit_amount, credit_currency, description, is_active, …)`, no policy column (confirmed 2026-05-31).
|
||
|
||
So downgrade reconciliation is **not configurable today**; the live behavior is **`clamp` by emergence**: the FedWiki site-create guard checks `usage < limit` (`internal/workflows/fedwiki/activities.go`), so on a tier reduction the ceiling drops, existing usage is preserved, and new creation is blocked until back under cap. Verified live 2026-05-31 (Standard→Public: `sites` limit 16→1, 3 sites retained, member Create Site disabled "Site limit reached"). No `block` / `defer` / `force_reduce` path exists.
|
||
|
||
For the 8c MVP, `clamp` is adopted as the **documented de-facto policy** and needs no schema change. Making reduction policy **configurable** (the Decision 125 column + enforcement: materialization-time for `clamp`/`force_reduce`, switch-precondition for `block`, boundary scheduled-change for `defer`) is a later schema-sync — same family as the design-only `credit_disposition` / `invoice_line_items.line_type` columns (see "Subscription scheduled-change + commitment schema implemented from design-only model"). Discovered during 8c live verification 2026-05-31.
|
||
|
||
**Resolved 2026-09-15 by the `entitlement-set-changes` change under Decision 147.** Migration `00018_entitlement_set_changes.sql` adds `entitlement_set_rules.tier_reduction_policy` with the model's CHECK and default `defer`, migrates the existing `fedwiki_sites` rules to `force_reduce` so the FedWiki sweep keeps parking on this deployment, and adds `resource_keys.over_limit_behavior` and `over_limit_consequence`, stamped at boot from the provider manifest (`park` for `fedwiki_sites`, platform keys CHECKed to `deny_new`). The policy is operator-editable on the rule form and travels in the act's JSONB snapshots. At a rule change there is no switch to block and no boundary to defer to, so the preview resolves `block` and `defer` to `clamp` and says so (`FedWiki Sites: reduction policy defer becomes clamp.`, `The limit applies now. Usage above it is kept.`), while `force_reduce` exercises the key's over-limit behaviour and the sweep parks only under it (`GetGoverningReductionPolicy`). Still not built: the switch-time precondition for `block` and the boundary scheduled change for `defer` at a tier switch, which Decision 147 leaves to the switch's own locus.
|
||
|
||
### Subscription scheduled-change + commitment schema implemented from design-only model (upstream sync)
|
||
Labels: `design-feedback`, `billing`, `upstream-membcons-db`
|
||
|
||
`plan-switch-mechanics` (2026-05-30) implemented, in member-console migration `00015_subscription_scheduled_changes.sql`, the schema the design team had added to `design/billing/model.md` but which existed nowhere in code: `billing.subscription_scheduled_changes` (Decision 123) and the `subscriptions` commitment columns `commitment_end` / `commitment_renewal` / `early_termination_policy` + the `commitment_fields_coherent` CHECK (Decision 126). The `block`/`allow` dispatch composes onto the scheduled-change firing path exactly as the model describes (`block` → `effective_trigger='term_boundary'`). Confirmed sound.
|
||
|
||
Two parts of the canonical model were **carried but left inert**, because their supporting tables don't yet exist in code — flagging for upstream coherence:
|
||
- **`credit_disposition`** on `subscription_scheduled_changes` (Decision 124) is implemented as a nullable column but nothing reads it: there is no credit-ledger table and no `billing_accounts.default_credit_disposition` column in the code schema. Decision 124's ledger/cash routing has no landing zone yet.
|
||
- **`invoice_line_items.line_type`** does not exist as a column in the code schema at all (the table has `amount`/`description`/`quantity` but no `line_type`), so the design's `+= 'early_termination_fee'` enum value (Decision 126 `fee`) can't be added until the column itself is introduced. This is the schema-side reason the `fee` branch is deferred (see the `early_termination_policy='fee'` issue above).
|
||
|
||
Upstream ask: when Decisions 124 and the `fee` branch are scheduled, land the credit-ledger table (+ `default_credit_disposition`) and the `invoice_line_items.line_type` column together, so the already-present `credit_disposition`/commitment columns become live rather than inert.
|
||
|
||
### Boolean entitlement materialization is code-defined, not design-documented
|
||
Labels: `design-feedback`, `entitlements`, `upstream-membcons-db`
|
||
|
||
`design/entitlements/model.md` defines `entitlement_set_rules.rule_type ∈ {boolean, limit, quota, credit}` but only describes materialization for numeric rules. The `boolean-entitlement-materialization` change (2026-07-17) added `core.boolean_entitlements` — pool-scoped rows recomputed by the same materializer as numeric entitlements, `granted` = OR across active provisions carrying the rule, rows retained with `granted = FALSE` on lapse (absence = never conferred), no contributions/usage side tables (provenance answerable by joining active provisions). Consumers: Discourse forum access (9e `discourse_posting`), 10d custom domains. Upstream ask: document boolean materialization semantics in `design/entitlements/model.md` alongside the numeric materialization section — the OR-aggregation rule, the lapse-row-retention convention, and that `quota`/`credit` remain unmaterialized.
|
||
|
||
### Cross-module queries are undocumented
|
||
Labels: `design-feedback`
|
||
See [2026-03-26 entitlement sets log](log/2026-03-26-entitlement-sets.md) for discussion on raw SQL vs shared interfaces vs service-layer orchestration.
|
||
|
||
### Use `<meter>` for quotas, not for limits
|
||
Labels: `design-feedback`, `frontend`
|
||
`<meter>` is the right element for showing usage against a quota (e.g. 3 out of 17 sites used). It should not be used to display a limit value on its own — e.g. showing "17 sites included" as a bar makes less psychological sense than plain text. Use `<meter>` only where there is a current usage value to compare against a maximum.
|
||
|
||
### Org type CRUD when non-personal types arrive
|
||
Labels: `design-feedback`, `organization`, `ux`
|
||
The `organization.org_types` table supports more than the seeded `'personal'` row — schema includes `display_name`, `description`, `is_active`, `default_product_id`, and `default_plan_ladder_id` — but the operator UI deliberately exposes no create/edit affordances. This is the right call today (operator misuse risk; only consumer is the personal-org auto-provisioning flow), but when non-personal org types arrive (a real "organization" type beyond the per-person workspace) the IA needs to grow:
|
||
- A create/edit surface for org types (with cascade visibility — changing `default_product_id` does not retroactively backfill; see "Auto-provisioning does not backfill existing orgs when `default_product_id` changes" above).
|
||
- A way for org-creation flows (whatever introduces a non-personal org) to pick the org type, instead of the implicit `'personal'` default in current code.
|
||
- Disambiguation in URL/IA: `/operator/organizations/...` lists actual orgs; `/operator/org-types/...` lists the type schema.
|
||
|
||
Until that work is scheduled, M7 should leave the schema alone and not surface CRUD. Discovered during M7 phase 7a (2026-05-08).
|
||
|
||
### Resource keys carry no shape discriminator — rule authoring cannot adapt to the key
|
||
Labels: `design-feedback`, `entitlements`, `ux`, `upstream-membcons-db`
|
||
|
||
`design/entitlements/model.md` §resource_keys defines the table as a pure shared-namespace registry (key, display_name, description, unit) — the entitlement taxonomy's types (`boolean`/`limit`/`quota`/`credit`, Decisions 98–105) 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`.
|
||
|
||
### Person display name follows the IdP unconditionally; alternative 3 approved, awaiting upstream ratification
|
||
|
||
`design-feedback` `auth`. Decided 2026-08-29: the IdP's profile is an external
|
||
reference stored as received on `users` (`idp_*` columns); the person's
|
||
display name is a name owned here (`persons.display_name` with
|
||
`display_name_source`); the designed legal names stay person-owned. Upstream
|
||
carries the schema through Doc 33 as Issue 34 (`doc-46`, six decisions,
|
||
awaiting ratification). The console builds it as its own change when the
|
||
first person-owned surface exists (profile page, erasure path, or the
|
||
membership fields); nothing visible changes before then. The argument and
|
||
the person-name research behind it were worked out in the 2026-08
|
||
identifiers exploration.
|
||
|
||
### Personal organization naming is provider-derived and never resynced (upstream Issue 35)
|
||
|
||
`design-feedback`. `internal/provisioning/provisioning.go:78` names the
|
||
personal organization from the display name at signup, once, with no source
|
||
recorded: the persons conflation one level up, and ambiguous once a person
|
||
can adopt a display name. Three resolutions recorded upstream, none chosen;
|
||
trigger: Issue 34's ratification.
|
||
|
||
### Two questions to settle at Issue 34's ratification
|
||
|
||
`design-feedback`. (1) `idp_synced_at` has no consumer while the login is the
|
||
cache's only writer; drop it, and add it when a second writer exists (the
|
||
recommendation now carries "the provider profile is as of `last_login_at`; a
|
||
synchronisation failure is an audited event"). (2) Whether the layer-3 freeze
|
||
trigger earns its place: it guards re-identification by direct write, which
|
||
the CHECK cannot see, but only if leaving a terminal status is itself
|
||
forbidden (Issue 36).
|
||
|
||
### Terminal states are not enforced (upstream Issue 36)
|
||
|
||
`design-feedback` `security`. The soft-delete policy says irrevocable terminal
|
||
states never transition, and nothing enforces it upstream or here;
|
||
`UPDATE core.persons SET status = 'active' WHERE status = 'anonymized'`
|
||
would succeed. Dormant on the console (only `active` is ever written; no
|
||
erasure path), but Issue 34's erasure guarantee is incomplete until it
|
||
closes, so enforcement lands ahead of or with the console's Issue 34 work.
|
||
First candidate: `REVOKE UPDATE (status)` plus SECURITY DEFINER transition
|
||
functions, the Doc 41 conferral shape.
|
||
|
||
## Composite detail pages need a rearchitecture pass
|
||
|
||
`design-feedback`. Maintainer, 2026-08-31, on the group-6 sheet: the
|
||
product composite "feels disorganized and not well-thought out" even
|
||
after the anatomy sweep normalized its parts, and the organization
|
||
composite shares the shape. Deferred: rethink the two composites'
|
||
information architecture (what is a section, what belongs on a child
|
||
page, where forms live) as its own design round after the 10k sweep;
|
||
the anatomy parts stay, the arrangement is the question.
|
||
|
||
**Candidate inventory for the rethink round** (surveyed 2026-08-31 at
|
||
the maintainer's ask — "identify what else might benefit from a
|
||
rearchitecting/rethinking, as opposed to me stumbling upon them").
|
||
Criteria: arrangement predates the anatomy parts, the page mixes idioms
|
||
its siblings don't use, or one entity is split across surfaces. Named
|
||
by the maintainer already: the product composite, the organization
|
||
composite, the plan-ladders page (own entry below), the overview +
|
||
lookup (own entry below). Surveyed additions, each with the concrete
|
||
smell:
|
||
|
||
- **Entitlement-set composite** — shares the product composite's shape
|
||
(Details form + Add form + list on one URL), the create-flow split
|
||
(own entry below), and the pending immutability question; whatever
|
||
the composite round decides applies here too.
|
||
- **Org types** — the one card-grid surface in the operator panel:
|
||
forms live inside cards, the default-plan change flows through
|
||
preview/result alerts inline in the card; every sibling surface uses
|
||
table + detail page. Either the card idiom is right and underused, or
|
||
this page should converge.
|
||
- **Billing hub** — a wrapper page with four sibling views behind
|
||
pills, a detail page only invoices have, a "billing summary" that
|
||
also lives on the org composite, and a data-recency line unique to
|
||
it. The wrapper/view split and the summary duplication were assembled
|
||
incrementally, never designed as one surface. Maintainer, 2026-09-01:
|
||
the wrapper never names the sub-view in its header; fine for now, but
|
||
this round should make it better.
|
||
- **Grants** — a read-only ledger whose content overlaps the org
|
||
composite's grant history and whose actions were removed by the IA
|
||
round; whether a standalone browse page still earns its rail entry
|
||
(vs. a People-directory-style filtered listing) has not been asked.
|
||
- **Domains** — the densest operator page: three disclosure sections,
|
||
two tables, claim/evidence/release flows inline; reworked piecemeal
|
||
across audits. Works, but nobody has designed its reading order.
|
||
- **Member surface** (index, products, billing) — predates every
|
||
convention in this sweep; group 10 normalizes its parts, but its
|
||
arrangement (what a member sees first, how plans/pools/invoices
|
||
relate on screen) has never had a design round. Maintainer,
|
||
2026-08-31, on the group-10 sheet, specifically "Your entitlements":
|
||
"the hierarchy isn't very obvious... row one says 'Default' badge
|
||
without context... It is unclear that the demo widgets and feature x
|
||
are part of that plan", the Sources row "needs better design to make
|
||
more obvious what it is", and the region "could get really messy when
|
||
there are a lot of entitlements". The rethink should group
|
||
entitlements under the plan/pool that confers them instead of the
|
||
current flat chip-meter-chip stack.
|
||
- **Integrations** (settings + fedwiki/discourse surfaces) — the
|
||
registry/settings split and the per-provider pages grew from
|
||
different eras; group 9 normalizes parts only.
|
||
|
||
Not flagged: People directory + person detail, setup checklist,
|
||
validation page (all recent, single-idiom, no known complaints).
|
||
|
||
2026-09-01, from the 10k.4 sheet pass: the composite's Active/History
|
||
tabs and the list scaffold's facet pills present the same kind of data two
|
||
ways, and navigation pills (Billing sub-views) look identical to filter
|
||
pills. The maintainer wants a rule — views of one table vs filters within a
|
||
list vs navigation between lists — before either is changed; the tabs look
|
||
right in the composite, pills are more versatile elsewhere. Also to
|
||
research (maintainer, same day): responsive patterns some projects use for
|
||
this, a dropdown or select below a breakpoint with tabs above it, and an
|
||
overflow dropdown when the option set is too long for one row.
|
||
|
||
## Overview landing and the lookup need a rethink
|
||
|
||
`design-feedback`. Maintainer, 2026-08-31: the lookup's placeholder
|
||
undersold what it does (it resolves exact keys for organizations, plan
|
||
ladders, entitlement sets, and products, then people by name or email,
|
||
then organization names, with disambiguation lists). A first placeholder
|
||
fix ("Find by name, email, or key") missed the point — the maintainer's
|
||
critique was that it named the *fields* matched, not *what can be found*
|
||
("it doesn't say find what"); the placeholder now reads "Find a person,
|
||
an organization, or anything with a key". The landing page and the
|
||
lookup as a surface still deserve a design round of their own ("this is
|
||
another page we are gonna want to rethink down the road"), alongside the
|
||
composite rearchitecture issue above.
|
||
|
||
2026-09-01, maintainer, using the lookup: typing an invoice number ("0003")
|
||
under the placeholder "Find a person, an organization, or anything with a
|
||
key" answers "No person or organization matches 0003." Facts: the lookup
|
||
resolves exact keys (organizations, plan ladders, entitlement sets,
|
||
products; `operator.go:387-422`), then people by name or email, then
|
||
organization names; nothing in it touches billing, and invoices carry
|
||
numbers, not keys. The placeholder, the no-match sentence, and the code
|
||
name three different sets. Maintainer: commit to a set that encompasses
|
||
"anything" rather than adding nouns to the search bar one by one.
|
||
Position (orchestrator): the set is "every record that has a page of its
|
||
own, by the identifier its page shows" (name, email, key, number). Today
|
||
that is persons, organizations, products, entitlement sets, plan ladders,
|
||
and invoices; a record class joins the set when it gets a page, and a test
|
||
keyed on the screen manifest's detail routes can hold the lookup to it.
|
||
That makes "anything" honest without listing nouns: the placeholder says
|
||
what to type ("Find anything by its name, key, or number"), and the
|
||
no-match sentence, the one place a person needs the list, names the
|
||
classes, generated from the resolver registry so the two cannot disagree.
|
||
Invoice numbers are per billing account, so one number can match several
|
||
invoices; the existing disambiguation list handles it. "Key" leaves the
|
||
placeholder: people who have one type it and it works. Small enough for
|
||
the fix-before-close bucket if the maintainer wants it there. (Triaged
|
||
"now" on 2026-09-02.)
|
||
|
||
2026-09-02, maintainer, direction for the overview rethink: the Delivery
|
||
queue card should not be on the overview at all ("delivery queue is not
|
||
gonna mean much to non-technical operators"); what an operator wants is
|
||
one element telling them Stripe's status at a glance. The Integrations
|
||
card shrinks to one status line with a link to Integrations ("this can get
|
||
unwieldy if you have too many integrations"). The Open invoices,
|
||
Delivering grants, and Catalog products tiles go ("they feel not that
|
||
important"); People and Team organizations stay (confirmed 2026-09-02,
|
||
which also settles ACC-39 as won't-fix for now). The dead-letter table and
|
||
the operation identifiers it shows move with the queue to the Stripe page.
|
||
Executed in the `acceptance-fixes` change.
|
||
|
||
2026-09-02, maintainer, reviewing round 1: "still not satisfied" with the
|
||
System section; wants one card for all integrations including Stripe
|
||
(done in round 2, design D3: one Integrations card, a row per provider
|
||
with its Status, Stripe's row adding mode and queue, names linking to
|
||
provider pages), and an issues aggregator. The aggregator is the rethink
|
||
item: one "Needs attention" element collecting dead-lettered work,
|
||
unconfigured providers that published products depend on, incomplete
|
||
setup, and overdue invoices, each line linking to where it is fixed; its
|
||
empty state reads "All clear." (maintainer: "could be more positive";
|
||
alternative "Everything is running normally."). The money tile stays on
|
||
the overview ("Keep the money number there"). Also restored in round 2.
|
||
|
||
## Plan ladders page needs a rethink
|
||
|
||
`design-feedback`. Maintainer, 2026-08-31, on the group-8 sheet: the
|
||
plan-ladders page is "kind of a mess and probably needs to be rethought
|
||
too", even after the anatomy sweep normalized its parts (topology map
|
||
first, create form, ladder list, and four topology subsections on one
|
||
page). Add it to the rethink round with the composite rearchitecture and
|
||
overview/lookup issues above. Related: the older low-priority entry
|
||
"Operator plans surfaces — consolidate Plan Ladders + Plan Topology"
|
||
(2026-05-30), whose consolidation half has since happened; the grid
|
||
orientation and surface-shape questions it raises fold into this round.
|
||
|
||
2026-09-01, decided in the 10k.4 sheet pass (fix-before-close candidates,
|
||
not waiting for the rethink): the ladder list gets a section header and the
|
||
scaffold; rank direction becomes descending on both surfaces (rank 0 at the
|
||
bottom as the base, "top" dropped from the copy); the tiers table gets the
|
||
record-table treatment. Second round, same day: the all-clear health strip
|
||
("No structural issues detected." on the ladders page, "No structural
|
||
issues for this ladder." on the detail) renders nothing; it is noise with
|
||
nothing to act on, and issues should appear only when there are issues. The
|
||
validation page keeps its success alerts, where all-clear is the answer to
|
||
the question the page asks. Four tests assert the strings. 2026-09-02,
|
||
maintainer, two more for the same fix change: "4 in position" under a
|
||
ladder name in the topology grid (`operator_plan_topology.html:59`, the
|
||
count of pools positioned on the ladder) names no noun; the copy says
|
||
what is counted and stays terse ("4 organizations"). And neither the ladder
|
||
columns (`operator_plan_topology.html:42-45`) nor the tier rows should
|
||
offer a drag handle when there is only one of them.
|
||
|
||
2026-09-02, round 2 of `acceptance-fixes`: the rank direction above was
|
||
recorded backwards by the orchestrator; the maintainer's "flip" meant rank
|
||
0 first, ascending, the member catalog's order, on both surfaces (design
|
||
D8). The structural validation page is retired outright (the one invariant
|
||
it checked is impossible under the database's exclusion constraint; the
|
||
query stays as an unexposed maintenance check). Division of labour stated
|
||
on the grid: columns drag to order ladders, tiers are reordered on the
|
||
ladder page ("Open a ladder to reorder its tiers."). For the rethink: a
|
||
two-axis grid, where a tier drags within its column as well, needs the
|
||
grid rebuilt from a table into columns (a cell cannot be dragged across
|
||
table rows), and the rank rows are what make it a grid; the maintainer
|
||
asked whether rows should drag ("Rows are not draggable though?") and
|
||
took the division for now. 2026-09-03, maintainer, on the round-2
|
||
sheets: "Ladder detail probably needs a thorough rethink tbh." Same
|
||
round.
|
||
|
||
## Catalog create flows split one entity across two forms
|
||
|
||
**2026-09-04 update: resolved by `forms-library`.** Every catalog entity
|
||
this entry named (products, entitlement sets, plan ladders) is now one
|
||
declared `forms.FormSpec` (`operator.product`, `operator.entitlement-set`,
|
||
`operator.plan-ladder`; spec `form-library`) rendered on the create page
|
||
in unbound mode and again on the record's own Details section in
|
||
record-bound mode; the create page still asks for identity only (the D20
|
||
panel shape below is unchanged) and the record page still carries the
|
||
next step itself, but both pages now share one field list by construction
|
||
rather than two templates agreeing by hand, so the maintainer's "we fill
|
||
out a form here that carries some info... and then another form that
|
||
carries more info" complaint is now structurally impossible to reintroduce:
|
||
a field that differs between the two sides is a declared `Only` marker on
|
||
the one shared spec, checked by `internal/forms/invariants_test.go`, not a
|
||
second form. See `openspec/changes/archive/2026-09-05-forms-library/`,
|
||
which carries the decision table of the 2026-09-03 forms audit. The
|
||
2026-09-02 doubts below about whether a two-page split is right at all, and whether the toggleable-panel pattern
|
||
should be standardized, are unaffected by this and stay open for a
|
||
rethink.
|
||
|
||
`design-feedback`. Maintainer, 2026-08-31, on the entitlement-sets sheet:
|
||
creating a set collects name and description on the list page, then the
|
||
rest of the entity (picker visibility, rules) lives in different forms on
|
||
the detail page; "we fill out a form here that carries some info... and
|
||
then another form that carries more info". Products and plan ladders
|
||
share the shape. Rethink the create flows (one form, or create-then-land
|
||
on the detail with one Details form) with the composite-rearchitecture
|
||
round above.
|
||
|
||
2026-09-02, decided for `acceptance-fixes` round 2 (design D20), "good for
|
||
now": the list page's create form becomes a panel closed by default,
|
||
opened from the list section header's action; it asks for identity only;
|
||
success navigates to the new record's page with a "Created." notice, and
|
||
that page carries the next step itself (readiness panel, empty rules
|
||
section, empty tiers section); the sub-record forms (Create price, Add
|
||
tier, Add rule) become panels too. Two doubts from the maintainer for the
|
||
rethink: "I don't really see why we'd need to split the process over two
|
||
pages... maybe our toggleable form pattern is not a good thing to
|
||
standardize around... the drawer component in ShadCN for example is an
|
||
alternative"; and, separately, "part of the rethink is gonna have to
|
||
consider collapsing the whole 'create entitlement first' sequence of
|
||
steps to maybe something you do when creating a product, because I think
|
||
that will be more intuitive for a lot of people." The second connects to
|
||
the sets-without-products entry below: defining a set's rules inside
|
||
product creation needs no schema change under Doc 41 (the product is
|
||
created with a fresh set), so the rethink can take it on as pure UX.
|
||
|
||
## Console session outlives the Keycloak session; nothing re-validates
|
||
|
||
`bug`, `auth`. Two symptoms, one root, 2026-08-31. The console's own
|
||
session (Valkey, 7 days; internal/auth/auth.go) outlives every
|
||
Keycloak-side artifact: the ID token (5 minutes) and the SSO session
|
||
itself (realm defaults: 30-minute idle, 10-hour max; the test seed sets
|
||
none). Nothing re-validates the console session against Keycloak after
|
||
login. Symptom 1 (fixed 2026-08-31): logout replayed the stored,
|
||
long-expired ID token as id_token_hint and stranded the person on
|
||
Keycloak's "Invalid parameter" page; the hint is now attached only
|
||
while valid. Symptom 2 (open, maintainer: "sometimes clicking on
|
||
identity and access would drop you back into logged out Keycloak"):
|
||
hours after login the Keycloak SSO session is gone while the console
|
||
still says logged-in, so the account-console link lands on a Keycloak
|
||
login prompt. Fix directions, pick one deliberately: align the console
|
||
session lifetime with the SSO max; periodically re-validate via silent
|
||
re-auth (prompt=none) and treat failure as logged out; or store the
|
||
refresh token and let refresh failure end the console session. Belongs
|
||
to an auth-session design round, not a drive-by.
|
||
|
||
## Member-facing PDF invoices
|
||
|
||
`enhancement`, `billing`. Maintainer, 2026-08-31: "a future issue that
|
||
we need is for getting pdf invoices." The member billing view renders
|
||
history only; an invoice a member can download and forward to their
|
||
bookkeeper needs a PDF (or print-styled) rendering of the platform
|
||
invoice, or a pass-through to Stripe's hosted invoice PDF where a
|
||
Stripe mapping exists. Unscheduled.
|
||
|
||
## Invoice numbers leak deployment volume (German tank problem)
|
||
|
||
`design-feedback`, `billing`, `privacy`. Maintainer, 2026-08-31: "our
|
||
invoice numbering feature could basically leak how many invoices are
|
||
being produced by the deployment." True: the platform sequence is a
|
||
single global counter formatted %04d (AssignNextInvoiceNumber), so any
|
||
member holding two invoices can estimate deployment-wide volume the way
|
||
the Allies estimated tank production from serials (the German tank
|
||
problem). What the field does about it, researched 2026-08-31:
|
||
sequential, unique, gapless numbering is a tax-compliance requirement in
|
||
many jurisdictions (EU VAT practice; German rules are the strict
|
||
archetype), so random or hashed numbers are off the table; the accepted
|
||
mitigations keep sequences but shrink what one number reveals:
|
||
|
||
1. **Offset start** (begin at 1042, not 0001): hides absolute volume,
|
||
not the rate between two observed invoices. Cosmetic.
|
||
2. **Year-prefixed sequences** (2026-0001, reset yearly): a leak reveals
|
||
at most the current year's volume. Common default recommendation.
|
||
3. **Per-customer (per-billing-account) ranges**: each account gets its
|
||
own sequence (ACME-0007); a member learns only their own count.
|
||
Strongest privacy; German guidance explicitly allows multiple number
|
||
ranges as long as each is consistent and gapless.
|
||
|
||
Position: per-billing-account sequences fit this deployment model best
|
||
(each org already has its own billing account; invoice numbers stay
|
||
short and gapless within the range a member ever sees). Migration needs
|
||
care: numbers already issued are immutable (invoice-numbers D5), so a
|
||
new scheme applies from a cutover forward. Not scheduled; belongs with
|
||
the billing-hub rethink round.
|
||
|
||
## Screens: wall-clock-relative renderings tick daily
|
||
|
||
`bug`, `testing`, `low-priority`. The screens pipeline pins stored
|
||
timestamps (test/pin-timestamps.sql), but any column rendered RELATIVE
|
||
to now (the operator Domains age column: "241d" became "242d" overnight)
|
||
drifts by one character a day, so the sheet flags a changed row with no
|
||
design change behind it. Same-day captures stay byte-identical. Fix
|
||
direction: freeze the app clock for the screens stack (an env override
|
||
consulted by the age/relative formatters), or render pinned rows'
|
||
relative ages from a pinned "as of" instant.
|
||
|
||
## Comprehensive demo seed covering most entity states
|
||
|
||
`enhancement`, `demoseed`, `deferred`. Maintainer, 2026-08-31: "at some
|
||
point, we are gonna want to create a more thoroughly comprehensive seed
|
||
that accounts for every or most states of different entities... Maybe
|
||
down the line." The demo seed has been growing state coverage
|
||
reactively — the three invoice states and the canceling subscription
|
||
were added when sheets showed the states were undemonstrable, and the
|
||
empty Actor column (next entry) is the same gap. Down the line: derive
|
||
the seed's coverage from the badge map / state enums (every state an
|
||
operator surface can render should exist somewhere in the demo world)
|
||
instead of adding states one sheet-complaint at a time. Not scheduled.
|
||
|
||
Run 2 of the first-contact walk (2026-08-31)
|
||
exposed two concrete gaps the seed should close: no seeded product confers
|
||
`fedwiki_sites` (every product sits on `demo-baseline` → `demo.widgets`), so
|
||
no member can ever hold a site entitlement in the demo state and the member
|
||
site card always shows its no-entitlement branch; and three products are
|
||
inserted with no entitlement set (`demo-addon`, `demo-usage`,
|
||
`demo-onetime`), a state the console's own create form cannot produce. A
|
||
demo that wants to exercise delivery needs at least one `fedwiki_sites`
|
||
product on a ladder, one Stripe-mapped price, and one manual grant with an
|
||
actor.
|
||
## Demo seed: no activity event carries a person, so the overview's Actor column is all markers
|
||
|
||
`enhancement`, `demoseed`. Maintainer, 2026-08-29, on the group-5 sheet:
|
||
"actor is always empty in the demo seed?". The feed's Actor column
|
||
renders a person link when an event carries one (grants render
|
||
`GrantedByPersonID`); the demo seed provisions everything through the
|
||
system actor, so no seeded event ever has person attribution and the
|
||
demo overview shows only the unavailable marker. Same class as the
|
||
canceling-subscription visualization request: the seed should exercise
|
||
the state so the column demonstrates itself — e.g. issue one demo grant
|
||
attributed to the seeded operator. Small `internal/demoseed` touch.
|
||
While making that change, also give the seed one manual (non-default)
|
||
grant on an organization already in the demo data: the demo currently
|
||
holds only default grants, so the Revoke control never appears on the
|
||
sheet captures (round 2, review item 13).
|
||
|
||
## Should entitlement sets be immutable?
|
||
|
||
`system-design`. Maintainer, 2026-08-31, "future system design issue way
|
||
down the road": mutable sets mean a product's meaning changes under
|
||
existing grants at their next conferral; immutable sets (a new version,
|
||
re-pointing the product) would make conferral history self-describing.
|
||
Upstream data-model question, not console work.
|
||
|
||
2026-09-02, from the round-1 review of `acceptance-fixes`: the sets
|
||
surface offers no way to edit a rule (only add and delete), and the
|
||
maintainer asked whether a resource rule could ever be inactive ("when
|
||
would a resource rule not be active??"). Facts: `entitlement_set_rules.is_active`
|
||
exists and the rules query lists active rules only, but no control changes
|
||
it; the rules table's Active column is dropped in round 2 (design D21).
|
||
Whether rules are edited in place or replaced (which is the immutability
|
||
question above) is decided here, not in a fix change.
|
||
|
||
## Granting a set without a product: settled 2026-07-11, asked about again 2026-09-02
|
||
|
||
`system-design`. Maintainer, 2026-09-02, on the Visibility model: "very
|
||
early in the design of entitlement sets, there was a consideration for the
|
||
use of entitlement sets without products", relitigated "maybe in the past
|
||
two or three months"; asked for the conversation to be found. Found, in the
|
||
archive: change `2026-03-26-entitlement-sets` introduced
|
||
`grants.entitlement_set_id` so a grant could reference a set directly
|
||
("ad hoc conferral") or a product ("commercial conferral"), Decision 112;
|
||
`2026-03-29-ui-ux-alignment` designed two grant paths and two operator
|
||
forms on it ("select an entitlement set directly. No product
|
||
intermediary"); `2026-04-12-org-type-default-product` moved org-type
|
||
defaults from sets to products; and `2026-07-11-doc41-conferral-uniformity`
|
||
reversed the set path: `grants.entitlement_set_id` dropped, `product_id`
|
||
NOT NULL, "every grant must name a real product (Doc 41 §5.1; partial
|
||
reversal of Decision 112). Ad-hoc capability sets are granted via internal
|
||
wrap products", the two grant forms merged into one product-based form.
|
||
`docs/models/entitlements.md` invariant 4 states the result. So the
|
||
current model is products-only by a decision seven weeks old, and the
|
||
console's "Private" product (round 2's name for the wrap product) is that
|
||
decision's UI. The orchestrator's round-1 answer that "the schema allows a
|
||
grant sourced from a set alone" was wrong: it read the squashed init
|
||
migration and missed migration 00004's drop; corrected the same day.
|
||
|
||
What the maintainer wants kept for the rethink: whether the products-only
|
||
model should stay, or sets should be grantable again; and, from the
|
||
create-flow entry above, whether defining a set's rules could happen
|
||
inside product creation, which needs no schema change and may remove most
|
||
of the reason to want set-only grants. The maintainer had a Claude Code
|
||
session open in the `membcons` (model) repository to ask about the
|
||
history; the archive answers it without that.
|
||
|
||
## htmx 4 features not adopted in 10k.3 (candidates, not commitments)
|
||
|
||
`ui-infrastructure`. The 10k.3 migration (change `htmx-4`) took only the
|
||
breaking changes; htmx 4 also shipped features that could replace existing
|
||
patterns when their pages next get worked on:
|
||
|
||
- **Morph swaps** (built-in idiomorph): could preserve focus and scroll in
|
||
the polling regions (product readiness, domain claim status) instead of
|
||
`outerHTML` replacement.
|
||
- **`<hx-partial>` multi-target responses**: an alternative to the list
|
||
scaffold's `hx-select-oob` sync pattern with explicit per-region targeting.
|
||
- **Streaming extensions** (`hx-sse`, `hx-ws`, `hx-multipart`): if any
|
||
surface ever wants live updates (the operator overview's activity stream
|
||
is the obvious candidate).
|
||
- **`hx-preload`**: hover-preloading for the rail navigation.
|
||
|
||
Each is a feature adoption with UX consequences, not a cleanup; none blocks
|
||
anything today.
|
||
|
||
## Domains page: disclosure and columns (from the 10k.4 sheet pass)
|
||
|
||
`ui-quality`. Maintainer, 2026-09-01: the collapsed `<details>` for
|
||
"Effective claim policy" and "Claim history" was a lazy solution; the
|
||
domains rethink (already on the inventory) should design progressive
|
||
disclosure thoughtfully and consistently with the rest of the console.
|
||
Also for that round: the live-claims table has no list scaffold. (The
|
||
Evidence column, the "operator root" Actions-cell text, and the undefined
|
||
"No placements." wording were fixed in `acceptance-fixes` round 1, design
|
||
D15. The `<details>` toggles themselves were removed to plain sections in
|
||
round 3, an interim step; the progressive-disclosure design this entry
|
||
asks for is still undone.)
|
||
|
||
## Instance settings page: deferred past M10; the administrator role comes first
|
||
|
||
`design-feedback`. Maintainer, 2026-09-01, prompted by the integration
|
||
settings pages: a page "just for settings to manage member console
|
||
settings" for the whole deployment, "instance" as the word people know,
|
||
with the setup checklist linked from it. Then, same day, the prerequisite:
|
||
"operators are not necessarily administrators... some set of settings is
|
||
more like an administrator thing rather than an operator thing... we need
|
||
to flesh out that distinction somewhere, and maybe that even precedes
|
||
creating a settings page." Deferred to a later milestone by the
|
||
maintainer's call; administrator polish is not an M10 need.
|
||
|
||
Prerequisite. The console has one application-level role, `operator-member`,
|
||
extracted from the IdP token (`docs/identity-provider-setup.md` §"Role-Based
|
||
Access"); everything behind `/operator` is one audience. An administrator
|
||
is a second role (deployment-level: instance settings, integrations,
|
||
identity, maybe the setup checklist) distinct from the operator's daily
|
||
work (people, organizations, grants, billing, catalog). Deciding what each
|
||
role sees and does comes before the page, and lands next to the open
|
||
"IdP-agnostic role-mapping design" entry above, since both roles arrive
|
||
through the same token claims. The maintainer's aside that "configs will
|
||
need their own model card" is right under the single-descriptive-home rule:
|
||
once the overlay covers core keys, configuration is a domain (keys, layers,
|
||
sources, restart semantics) and `docs/environment-reference.md` is no
|
||
longer its whole description.
|
||
|
||
Facts. Configuration is the forty-odd viper keys registered in
|
||
`cmd/start.go` and documented in `docs/environment-reference.md`. The
|
||
DB-backed layer already exists, for integration keys only:
|
||
`internal/config/spec.go` declares each key (`ConfigKey`: name, default,
|
||
help, `Secret`), `internal/config/overlay.go` layers the operator-set rows
|
||
of `core.integration_config_overrides` (migration 00009) over viper at
|
||
boot with a recorded source per key (override > environment > default),
|
||
and the Stripe settings page (`operator_integration_settings.go`) edits
|
||
the rows and shows "pending restart" because overrides apply at the next
|
||
boot (`cmd/start.go:148-157` passes only `integrationConfigSpecs` through
|
||
`ApplyOverlay`). Maintainer's question, 2026-09-01: should core settings
|
||
be DB-backed, and would that stay compatible with viper and cobra? Yes on
|
||
both: viper stays the deploy-time layer (flags, env, yaml), the overrides
|
||
table is the runtime layer above it, and that precedence is implemented
|
||
and tested today; extending it is declaring core keys as `ConfigKey`s
|
||
with an overridable marker and passing them through the same overlay.
|
||
Secret keys stay excluded as now; keys read once at boot (port, DSNs,
|
||
Temporal) stay env-only; a handful of keys read on every request
|
||
(deployment name, support URL) can go live without a restart through a
|
||
small snapshot refreshed on write, which is the one new piece.
|
||
|
||
Shape when it comes (orchestrator, same day): a rail entry "Settings"
|
||
whose page is titled "Instance settings", the integration settings page
|
||
generalized to core keys on the existing overlay, visible to the
|
||
administrator role; inventory first, because a settings page with two
|
||
rows is a menu. The inventory splits the keys into operator-facing
|
||
(deployment name, support URL, the base URL used in links, the domains
|
||
policy knobs: claim window, abandon window and budget, pending cap, scope
|
||
labels, connect target) and infrastructure (DSNs, secrets, Temporal, port,
|
||
intervals), which stay env-only. The setup checklist is reachable from the
|
||
settings page and from the overview until it completes; its step order
|
||
changes so Integrations precedes Entitlement sets (decided the same day,
|
||
with the integration-readiness fix from the 10k.4 findings). Whether the
|
||
Integrations entry itself moves under Settings is the integrations
|
||
rethink's question, not decided here. Its own change. Candidate row for
|
||
that page (maintainer, 2026-09-02, "down the road"): disabling an
|
||
integration per instance, so a deployment can run without a provider it
|
||
does not offer.
|
||
|
||
2026-09-02, maintainer, on the orchestrator's proposed line "a value with
|
||
an environment variable is config and goes through the overlay; a value
|
||
without one is an instance setting": "this line seems kinda arbitrary.
|
||
Ideally we'd want all configs as instance settings, even if they are
|
||
display only because they are infra stuff." Direction for the page,
|
||
recorded: one Instance settings surface lists every configuration the
|
||
deployment has, the environment-sourced and infrastructure values shown
|
||
read-only with their source, the runtime-changeable ones editable; the two
|
||
backing stores (the boot-applied overlay in
|
||
`core.integration_config_overrides` and the runtime table below) are an
|
||
implementation split behind one surface, not a product line. What
|
||
`acceptance-fixes` round 2 seeds (design D28): `core.instance_settings`
|
||
(key, jsonb value, updated_at, updated_by; migration 00015) with a Go
|
||
registry of allowed keys and one key, `setup_banner_dismissed`, read on the
|
||
overview and written by the banner's close button. The page and the
|
||
administrator role stay deferred.
|
||
|
||
2026-09-03, maintainer, on the Domains page: "Effective claims policy
|
||
should eventually just be a link to the section of instance
|
||
settings/configs that is specifically about it. Also we should settle on
|
||
a name: either settings or configuration." Position: "Settings" is the
|
||
word in the UI (the rail entry, the page title "Instance settings", the
|
||
per-integration "Settings" action already in use); "configuration" stays
|
||
the word for the mechanism in docs and code (keys, environment, the
|
||
overlay). The Domains page's policy section becomes a link to that
|
||
settings section once the page exists; until then it stays a plain
|
||
section on the Domains page (the disclosure toggles were removed the same
|
||
day).
|
||
|
||
**2026-09-05, `typed-config-keys`:** the config seam every integration key
|
||
travels (`ConfigKey`, `Parse`, `ApplyOverlay`) is now typed, not just
|
||
present/absent: a value has a declared or inferred type (string, url,
|
||
duration, int, bool, list, enum) and one parser every path (save, boot
|
||
overlay, boot validation) shares. When the core keys above move onto
|
||
`ConfigKey` for this deferred page, that move is a declaration sweep
|
||
against an already-typed seam, not a second design.
|
||
|
||
## Lint in CI withdrawn from 10k.4; CI is its own exploration
|
||
|
||
`process`. Maintainer, 2026-09-01: "I'm not sure what the value of CI/CD
|
||
would be at the moment... I wanna explore that by itself." The
|
||
`ui-acceptance` first cut had added `.gitea/workflows/lint.yml` (build,
|
||
vet, `member-console lint` on push and pull request) and a "Lint runs in
|
||
CI" requirement on `ui-quality-gate`; both were removed the same day and
|
||
`docs/design-system.md` §6 reverted. Facts for the later exploration: the
|
||
forge is Gitea (`git.coopcloud.tech`), its Actions API answered for the
|
||
repository with zero workflows, runner availability was never verified,
|
||
and a Woodpecker instance already runs in the wiki.cafe infrastructure.
|
||
The lint gate stays local: `make lint`, the first step of `make test`.
|
||
|
||
## Integrations supply their own help copy on the surfaces they own
|
||
|
||
`design-feedback`. Maintainer, 2026-09-02, on the walk finding that
|
||
"FedWiki" appears on the member dashboard with no plain-language gloss
|
||
(ACC-15): the console should not be the place that explains FedWiki. A
|
||
deployment normally sits behind the co-op's own site, which explains the
|
||
product (wiki.cafe assumes FedWiki is known or explains it outside). What
|
||
would help is a hook for integration developers to add their own help
|
||
text, tooltips, intro copy, or descriptions where their surfaces appear
|
||
(the member dashboard card, the operator provider page, the product
|
||
readiness rows), so the explanation belongs to the integration that needs
|
||
it, not to core copy. Shape: an optional field or two on the dashboard
|
||
card and provider-page declarations (`server.DashboardCard`, the
|
||
integration registry entry), rendered by the parts that already render
|
||
the card and page headers. Not scheduled.
|
||
|
||
## Operators listed in People
|
||
|
||
`idea`. Maintainer, 2026-09-02: "it might be useful down the road to show
|
||
the list of operators in People." Fact that shapes it: the operator role
|
||
(`operator-member`) exists only in the identity provider's token and is
|
||
read at login into the session; nothing in the database records who holds
|
||
it (`core.roles` is organization membership, not console roles). Listing
|
||
operators therefore needs either the last-seen token roles persisted per
|
||
person at login (cheap, honest as "as of their last sign-in") or a query
|
||
to the identity provider (accurate, provider-specific). Belongs with the
|
||
operator-versus-administrator role work above.
|
||
|
||
## Design-system rules cite the published system they follow
|
||
|
||
`process`. Maintainer, 2026-09-02, on the button-weight rule ("one filled
|
||
primary per page", applied unevenly): "This really makes me question how
|
||
much research we did before any of these passes... I bet there is so much
|
||
literature on design patterns that we just didn't do our due diligence
|
||
with and now we are just reinventing the wheel. We basically accidentally
|
||
created our own design system, which is fine now, but you know we ought to
|
||
learn and lean on what is already out there... We stand on the shoulders
|
||
of giants." Agreed. What was checked the same day for the button rule:
|
||
GOV.UK Design System, Button ("Use a default button for the main call to
|
||
action on a page. Avoid using multiple default buttons on a single page";
|
||
secondary for secondary calls to action; warning buttons sparingly), and
|
||
IBM Carbon, Button usage ("Primary button should only appear once per
|
||
screen (not including the application header, modal dialog, or side
|
||
panel)"; a lower-emphasis button in a page header when the content has a
|
||
primary action; danger primary only for a required destructive step).
|
||
Both assume single-purpose pages; the console's composites are not, so the
|
||
rule was restated by role, one filled commit per form (design D19,
|
||
`form-conventions` "Button weight follows the button's role"). Rule
|
||
going forward, written into `docs/design-system.md`: a rule added to the
|
||
design system names the published system it follows or says why it
|
||
departs. The Material 3 and Atlassian pages are script-rendered and could
|
||
not be fetched; Shopify Polaris moved its component docs. Candidates for
|
||
the next pass, where the same due diligence is owed: form layout and
|
||
required/optional marking (GOV.UK forms patterns), empty states, toasts
|
||
versus inline notices, the create-flow shape (drawer, modal, page, panel),
|
||
and breadcrumb rules.
|
||
|
||
2026-09-03, maintainer, on the round-2 sheets: the by-role button rule
|
||
"we half-assed sucks": the openers ("New product") stand out less than
|
||
the record's main action should, and an open opener rendered pressed
|
||
"looks pressable as opposed to untoggleable"; they misclicked "New
|
||
product" for "Create product". Researched the same day (Carbon "Create
|
||
flows", "Accordion", "Button"; GOV.UK "Button"; NN/g toggle guidelines):
|
||
Carbon's create-flow table gives the size rule (inline for quick simple
|
||
creations; modal for a couple of fields; side panel for medium
|
||
complexity needing page context; full page for creations a service
|
||
depends on), and disclosure state is shown by an icon (a chevron), never
|
||
a pressed button. Position, awaiting the maintainer's pick: record
|
||
creation (product, set, ladder) on its own page with the list page's
|
||
"New product" as its one filled call to action; sub-record creation
|
||
(price, tier, rule) and the composite's Issue grant / Extend stay inline
|
||
behind a tertiary "+ Add …" opener whose icon flips while open; the
|
||
2026-08-23 "pressed while open" rule retires. Also requested: a forms
|
||
consistency audit as its own change, rules first (single column, widths
|
||
by content, one control size, labels and legends, checkbox not switch in
|
||
saved forms, helper text length, commit placement) with a lint rule and
|
||
a forms contact sheet, because "manual reviews barely caught these".
|
||
|
||
## Vocabulary: a plan is a role, not a kind of product
|
||
|
||
`ui-quality`. Maintainer, 2026-09-02, correcting the orchestrator's "plans
|
||
and products are two first-class kinds": "Careful! Plans ARE products!"
|
||
`docs/models/product-catalog.md`: a product is a plan tier because a ladder
|
||
references it; there is no kind label. The sweep rule (design D27): copy
|
||
says "products", and where the role matters, "products that are tiers" or
|
||
"products not on a ladder"; never "plans and products"; nothing frames
|
||
products that are not tiers as exceptions. Round 2 applies it to the setup
|
||
copy; the sweep across every surface (the ladders page, the org-types
|
||
page, the member catalog's "plans" wording, docs) is its own pass.
|
||
|
||
## Small rethink notes from the acceptance-fixes reviews (2026-09-02)
|
||
|
||
`ui-quality`. Collected from the maintainer's two reviews of round 1 so
|
||
they are not lost; none is scheduled.
|
||
|
||
- Composite Pools card: the pool's name and its Extend control sit far
|
||
apart and the unit is not obvious; a help icon on the Pools header is
|
||
round 2's stopgap.
|
||
- A documentation panel or glossary for the composite's nouns (pool,
|
||
provision, grant, tier) was raised; the help icons carry one sentence
|
||
each for now.
|
||
- Member "Your entitlements" section needs a make-over (the maintainer's
|
||
word); it lists rules by key.
|
||
- Org-types default-change preview: the warning-alert consequence idiom
|
||
is audit-clean but reads heavy; a dedicated look for "here is what will
|
||
happen" previews, shared with tier removal.
|
||
- Entitlement-set rule picker: a resource key owned by a provider that is
|
||
not yet configured can be chosen (the picker lists the whole catalog;
|
||
`fedwiki_sites` exists in a fresh database before FedWiki is
|
||
configured), which is the second way a product's readiness panel shows
|
||
an unmet provider row (the first is a provider losing its
|
||
configuration). Marking such keys in the picker ("FedWiki, not
|
||
configured") would say it at the point of choice.
|
||
- Overview attention aggregator: see the overview rethink entry.
|
||
- Two-axis topology grid: see the plan ladders entry.
|
||
- Product page (maintainer, 2026-09-03): "the product detail pages
|
||
themselves don't tell you how many people have it"; a "Held by N
|
||
organizations" fact on the product page, linking to the composites.
|
||
- Composite billing card: built on the maintainer's go (acceptance-fixes
|
||
round 5, design D5) to the product-agnostic shape two outside cold reads
|
||
(Kimi K3, GLM 5.3) converged on: Outstanding balance (plain at zero;
|
||
"Unpaid" badge and the amount linking to the organization's open
|
||
invoices when owed), Subscriptions (count with the worst-state badge,
|
||
linking to the subscriptions list searched for the organization; no
|
||
product names), and Latest invoice, simplified in round 6 to one line
|
||
(state badge, number, date; the amount dropped). Durable scoping for
|
||
the three billing lists is still an `account=` facet, not built here;
|
||
today they take the organization name as a search.
|
||
- The capture utility's blast radius (2026-09-03): `make screens` resets
|
||
the application database to the demo snapshot and, when a migration or
|
||
the seed changed, rebuilds the snapshot with fresh ids; since round 3 it
|
||
also ends every session. A maintainer reviewing live during a capture
|
||
run loses their session and their record URLs. Position: the capture
|
||
should run against its own application instance and database (the
|
||
worktree stack contract already provides collision-free ports), leaving
|
||
the instance a person is browsing untouched; until then, announce
|
||
capture runs. Also from that review: the CSRF cookie's twelve-hour
|
||
default life against the one-week session made a page left open
|
||
overnight fail its next POST with a "session has expired" toast; the
|
||
cookie now lives as long as the session and the toast says the page is
|
||
out of date (D29).
|
||
- Forms consistency audit (its own change after acceptance-fixes;
|
||
maintainer 2026-09-03 "fine", then, on seeing the create pages: "we
|
||
have a set of fields for the create form that differs from the set of
|
||
fields on the detail form, which fields apply where is arbitrary and
|
||
potentially prone to drift. Also, the new product page form is
|
||
stylistically inconsistent from that of the detail form"). Position for
|
||
the audit: one field set per record, declared once, and one form
|
||
partial rendered by both the create page and the record page's edit
|
||
form, so the two cannot drift; the only differences allowed are fields
|
||
that do not exist before the record does (a key, a status), and those
|
||
are listed in the spec, not left to each template. The audit also
|
||
covers control sizes, label placement, hint text, and button placement
|
||
across every form on both surfaces. Scope, decisions, and the cited
|
||
systems belong to that change's proposal. Audit run 2026-09-03: two
|
||
inventories, 68-source research, 53 verified findings, position
|
||
accepted 2026-09-03; change `forms-library`, implemented 2026-09-04.
|
||
The severity-1 and severity-2 findings this change did not fix are
|
||
their own tracked entry, below.
|
||
- Browser tab title is the same on every operator page (noticed 2026-09-03
|
||
during the forms-library Lighthouse pass): `/operator/products/new`,
|
||
`/operator/domains` and the product record all carry the `<title>`
|
||
"Operator overview - Member Console", so tabs and history entries cannot
|
||
be told apart. Lighthouse does not flag it because a title exists.
|
||
Fixed the same day inside `forms-library` at the maintainer's ask
|
||
(`server.pageTitle` reads the body's own page header; a `page-anatomy`
|
||
requirement records it).
|
||
- Stripe test-mode banner can lie after a mode change (maintainer
|
||
2026-09-03: "is there a chance that a stripe was actually in production
|
||
and then moved back to testing? In that case, the banner lies because
|
||
then some data may be not test data"). The billing views' banner says
|
||
the figures are test data, which is a claim about every row, while the
|
||
console only knows the connection's current mode. Position: the banner
|
||
states the connection's mode ("Stripe is connected in test mode"), not
|
||
the data's nature; every Stripe object carries `livemode`, so the
|
||
projections should store it per record and the lists mark rows from the
|
||
other mode; and the mode switch in either direction carries the
|
||
consequence warning `integration-settings` already requires for the
|
||
test-to-live direction. For the billing rethink.
|
||
- "Purchasability" is a misnomer for a product that is internal by design
|
||
(maintainer 2026-09-03: "sometimes the product will never be purchasable
|
||
because it is internal"). The record page's readiness section needs a
|
||
name that covers "ready to deliver" for private products and "ready to
|
||
sell" for public ones. For the products-list Status rethink, with the
|
||
purchasability-verdict entry above.
|
||
- Integrations page and settings pages, for the rethink (maintainer
|
||
2026-09-03): the landing's per-integration "Set discourse-base-url and
|
||
discourse-api-key ... to enable the integration" lines go; that
|
||
information belongs on the integration's own settings page as a banner
|
||
naming exactly what is needed, and the settings pages carry no status
|
||
signal at all today. The FedWiki operator page is titled "FedWiki Sites"
|
||
where the integration is "FedWiki" (the sites are what it manages), and
|
||
its lead follows the title.
|
||
- Security audit before launch (maintainer 2026-09-03: "I think we'll
|
||
want a security audit before launch too as yet another phase"): a
|
||
pre-launch phase of Milestone 10, after the UI acceptance rounds.
|
||
Candidate scope: authentication and session handling (the session-person
|
||
check and the CSRF cookie lifetime were found by review, not by design),
|
||
authorization on every operator route and partial, CSRF exemptions
|
||
declared by integrations, webhook signature verification, secrets in
|
||
configuration and logs, the content security policy and the response
|
||
headers, dependency and image scanning, and a threat model for the
|
||
identity-provider trust boundary. Recorded on the milestone.
|
||
|
||
## Forms audit: two findings the `forms-library` change did not fix
|
||
|
||
`audit`, `debt`, `tracking`, `ui-quality`. `forms-library` (lane E,
|
||
2026-09-04) reviewed every one of the forms audit's sixteen severity-2
|
||
and four severity-1 findings against the merged tree
|
||
and closed all but the two below; the twenty-four severity-3 and nine
|
||
severity-4 findings were the change's own primary scope, closed under
|
||
task 7.1 of that change's `tasks.md`.
|
||
|
||
1. **FA-33, severity 2, not fixed, out of scope by the task brief.** A
|
||
bare `⚠` character still marks a product in the Issue grant select
|
||
when it would supersede a tier a subscription currently holds
|
||
(`internal/server/operator_enrollment_forms.go`'s
|
||
`issuanceProductOptions`), with no text equivalent for a screen reader
|
||
or anyone who does not recognise the glyph. Lane C's report: "carried
|
||
forward unchanged, as the task's parenthetical said was out of scope."
|
||
Real work: give the option's own label the equivalent words (e.g.
|
||
append "(supersedes a subscription)"), which is a one-field content
|
||
change wherever `issuanceProductOptions` builds the label, not a
|
||
library change.
|
||
2. **FA-47, severity 2, structurally fixed but not yet capture-verified.**
|
||
`shell.confirm` (task 5.6) is now a declared `Confirm`-kind form,
|
||
rendered through the shared part, so a screen whose capture opens a
|
||
confirm-modal trigger (design D16) should show it captured open for
|
||
the first time. This lane could not run `make screens` (constraint:
|
||
never rebuild or restart the app, never touch Chrome) and did not
|
||
observe the capture actually show it. Closing this needs a `make
|
||
screens` run against the merged tree, which is the orchestrator's
|
||
verification task, not a further code change unless that run finds
|
||
the fix incomplete.
|
||
|
||
## Test suite
|
||
|
||
### The Discourse mapping tests depend on provider stamping another package commits first
|
||
|
||
`internal/integrations/discourse/web`'s operator mapping tests (`TestOperatorCreateMapping*`, `TestOperatorDeleteMapping`) render the mapping form's entitlement options from `ListOwnedResourceKeys`, which selects `core.resource_keys WHERE provider = 'discourse'`. Nothing in that package stamps the provider column: the discourse store migration inserts `discourse_posting` with `provider` NULL and leaves stamping to boot-time registration. In `make test` the tests pass because `internal/integration/registration_db_test.go` runs `RegisterProviders` earlier in package order and commits the stamp to the shared test database. Run after a reset without that package, the form answers "Choose an entitlement from the list." and every create returns 422. Found 2026-09-14 while running packages in isolation during `entitlement-set-changes`. Fix: the package's fixture stamps the provider itself (or calls `RegisterProviders` over the Discourse manifest) so the tests carry their own precondition. `TestLoadProductsPageDataCatalogSummary` in `internal/server` also failed once in a partial run and passed alone; not yet reproduced.
|
||
|
||
## Deployment
|
||
|
||
### The console connects as a superuser, so the enclosure's revokes bind nothing as deployed
|
||
|
||
`core.confer` and its family (00005) and now `core.commit_rule_change` (00018) revoke DML on their tables from `core_writer`, the role the console's login `member_console` is granted (`internal/db/migrations/00001_init.sql:1174`). In the test stack the login is the container's bootstrap user (`test/compose.yaml:20`), which Postgres creates as a superuser, and a superuser bypasses every privilege check; the production database was created the same way. The enclosure is therefore enforced only when the console runs as a role that is not a superuser. Found 2026-09-14 by the entitlement-set-changes schema review. Fix: create a non-superuser login for the console in both stacks, keep the bootstrap superuser for migrations and operations, and add a boot-time check that refuses to start as a superuser outside `--dev`; until then `TestRuleTableDMLRevoked` proves the grants only under `SET LOCAL ROLE core_writer`.
|