29 KiB
Design feedback, owed upstream
These entries are feedback owed to the upstream design repository, where the shared data model and its documents are maintained. They are kept here, verbatim as they were filed, until that repository has a tracker of its own.
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 (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 downgrade-flow MVP (member-downgrade-flow), 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 member-downgrade-flow 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_dispositiononsubscription_scheduled_changes(Decision 124) is implemented as a nullable column but nothing reads it: there is no credit-ledger table and nobilling_accounts.default_credit_dispositioncolumn in the code schema. Decision 124's ledger/cash routing has no landing zone yet.invoice_line_items.line_typedoes not exist as a column in the code schema at all (the table hasamount/description/quantitybut noline_type), so the design's+= 'early_termination_fee'enum value (Decision 126fee) can't be added until the column itself is introduced. This is the schema-side reason thefeebranch is deferred (see theearly_termination_policy='fee'issue above).
Upstream ask: when Decisions 124 and the fee branch are scheduled, land the credit-ledger table (+ default_credit_disposition) and the invoice_line_items.line_type column together, so the already-present credit_disposition/commitment columns become live rather than inert.
Boolean entitlement materialization is code-defined, not design-documented
Labels: design-feedback, entitlements, upstream-membcons-db
design/entitlements/model.md defines entitlement_set_rules.rule_type ∈ {boolean, limit, quota, credit} but only describes materialization for numeric rules. The boolean-entitlement-materialization change (2026-07-17) added core.boolean_entitlements — pool-scoped rows recomputed by the same materializer as numeric entitlements, granted = OR across active provisions carrying the rule, rows retained with granted = FALSE on lapse (absence = never conferred), no contributions/usage side tables (provenance answerable by joining active provisions). Consumers: Discourse forum access (9e discourse_posting), 10d custom domains. Upstream ask: document boolean materialization semantics in design/entitlements/model.md alongside the numeric materialization section — the OR-aggregation rule, the lapse-row-retention convention, and that quota/credit remain unmaterialized.
Cross-module queries are undocumented
Labels: design-feedback
See 2026-03-26 entitlement sets log for discussion on raw SQL vs shared interfaces vs service-layer orchestration.
Use <meter> for quotas, not for limits
Labels: design-feedback, frontend
<meter> is the right element for showing usage against a quota (e.g. 3 out of 17 sites used). It should not be used to display a limit value on its own — e.g. showing "17 sites included" as a bar makes less psychological sense than plain text. Use <meter> only where there is a current usage value to compare against a maximum.
Org type CRUD when non-personal types arrive
Labels: design-feedback, organization, ux
The organization.org_types table supports more than the seeded 'personal' row — schema includes display_name, description, is_active, default_product_id, and default_plan_ladder_id — but the operator UI deliberately exposes no create/edit affordances. This is the right call today (operator misuse risk; only consumer is the personal-org auto-provisioning flow), but when non-personal org types arrive (a real "organization" type beyond the per-person workspace) the IA needs to grow:
- A create/edit surface for org types (with cascade visibility — changing
default_product_iddoes not retroactively backfill; see "Auto-provisioning does not backfill existing orgs whendefault_product_idchanges" 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, the operator UI should leave the schema alone and not surface CRUD. Discovered during the operator surface inventory (docs/operator-ux-research.md, 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.
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.
Should entitlement sets be immutable?
Labels: 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.
Design/code drift on pool parentage (from the resource-pools card)
Labels: design-feedback, entitlements, upstream-membcons-db
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).
Identity design is far richer than the schema (from the identity card)
Labels: design-feedback, identity, upstream-membcons-db
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.
Billing residue when a grant supersedes a subscription-held position
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.
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.
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.
Core domain events have no fan-out to interested integrations (discourse-integration contract finding)
Labels: architecture, integration, contract-evolution
The integration-extraction 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 (discourse-integration 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).