- Add deployment-name branding to titles, mastheads, and OG tags - Share one grant delivery-state query with lineage across grants surfaces - Show pool status/usage, org owners, and config readiness - Make billing views projection-aware with recency and sync vocabulary - Guard FedWiki creation without domains and render route-aware 404s
12 KiB
title, audience, summary
| title | audience | summary | |
|---|---|---|---|
| Product Catalog |
|
How the console packages capabilities into sellable products: the products and prices tables, plan ladder membership, and the views that derive what a product is from its structure. |
Product Catalog
Purpose
A product is the console's unit of commerce. An operator (a person administering a deployment) creates, prices, and publishes products; a member (an end user of that deployment) sees them, buys them, or is granted them. A product does not define any capabilities itself. Instead it points at an entitlement set — a named bundle of capability rules, where a capability rule is something like "this organization may host up to five sites". The product handles only the commercial side: name, description, pricing, visibility, and the connection to Stripe. Because commerce and capabilities are split this way, several products can sell the same capabilities (a monthly and an annual product pointing at one set), and capability rules can change without touching how anything is sold.
When a member buys a product, or an operator grants one, conferral turns that event into a provision: a row recording what the member's organization now holds. Conferral runs as five database functions (all listed in the map below), and the provision rows it writes are the bridge from this model into the entitlements model.
The model stores no "type" or "kind" label that drives behavior. What a product is gets derived from structure: a product is a plan tier because a plan ladder references it, it is a subscription product because it carries a recurring price, and it is metered because a price says so. The one label that does exist, display_category, only groups products visually in the storefront and in operator lists.
Where this lives
Database objects sit in the core schema unless noted; each row names the migration file that defines the object.
| Piece | What it is | Where |
|---|---|---|
core.products |
The products table: name, description, display_category (free-text presentation label), lifecycle_status (draft/published/retired), is_active, is_public, and entitlement_set_id (which set this product confers) |
internal/db/migrations/00001_init.sql, altered by 00004–00006 |
core.prices |
Prices, many per product: unit_amount (cents), currency, recurring_interval (NULL means one-time), usage_type (NULL, licensed, or metered), is_default, is_active |
00001_init.sql; usage_type added in 00004_doc41_schema_and_data.sql |
core.plan_ladders and core.plan_ladder_tiers |
A plan ladder is a named sequence of plan tiers ordered by rank (rank 0 is the entry tier); a tiers row places one product on one ladder at one rank. Ladders belong to the Plan Ladders and Transitions model | 00001_init.sql |
core.entitlement_sets |
The capability bundles products point at; owned by the Entitlements model | 00001_init.sql |
core.grants |
An entitlement to a product issued without a purchase — by an operator, or by the system during signup | 00001_init.sql |
core.pool_provisions |
The provision rows conferral writes: what each organization currently holds, and from which product | 00001_init.sql |
core.product_shape |
A read-only view deriving each product's billing shape and consumption shape (defined under Dimensions) from its active prices | 00006_doc41_sweep_and_catalog.sql |
core.product_conferral_shapes |
A read-only view assembling the product facts conferral reads: entitlement set, lifecycle, member visibility, and ladder positions | 00004_doc41_schema_and_data.sql |
| The five conferral functions | core.confer, core.end_conferral, core.sync_source_status, core.align_conferral_shape, core.update_conferral_bounds |
00005_doc41_conferral_functions.sql |
stripe.product_mappings, stripe.price_mappings |
The links between local products/prices and their Stripe counterparts; a price is sellable only once its mapping row exists | internal/integrations/stripe/store/migrations/00001_init.sql |
| Operator product management | The create/edit forms and list filters operators use | internal/server/operator_products.go |
| Readiness / purchasability computation | The code that computes the full purchasability verdict and the member-catalog-visibility diagnostic, shown on the operator readiness panel | internal/server/product_readiness.go |
| Member storefront | The plan catalog and add-ons pages members see | internal/server/member_products.go |
| Checkout | The handler that starts a Stripe checkout for a price | internal/server/billing.go |
| Catalog queries | The SQL the storefront and operator pages run | internal/billing/queries/products.sql |
| Behavioral contract | The spec that pins this model's required behavior | openspec/specs/product-catalog/spec.md |
Invariants
An invariant is a rule the model guarantees everywhere; code that would break one is wrong even if it works locally. They are numbered so reviews and other documents can point at, for example, "product-catalog invariant 2". Each carries a tag for how the rule is enforced: [db] — the database schema rejects violations outright; [db-code] — the rule holds because the database functions are written to uphold it, so a review must protect it when those functions change; [app] — only Go application code upholds it, so every new code path must uphold it too.
- [db] A product is a plan tier exactly when a
plan_ladder_tiersrow references it. No column stores "this is a plan"; adding the ladder row is what makes a product a plan tier, and removing it is what un-plans it. - [app]
display_categorynever drives behavior. No code may branch on it to decide conferral, purchasability, or anything else; it exists only to group products in lists. (The column carries a schema comment stating this rule, but nothing stops a query from branching on it — reviewers must.) - [db-code] Conferral takes a product's availability facts — its entitlement set, lifecycle, member visibility, and ladder memberships — from the
product_conferral_shapesview, not from the products table. (The conferral functions do readplan_ladder_tiersdirectly for tier ranks when they record a transition; the view itself exposes those same ranks.) - [db]
lifecycle_statusis exactly one ofdraft,published,retired; a CHECK constraint rejects anything else. Lifecycle is independent of pricing and visibility: a draft can carry prices, and a retired product keeps its history. - [app] Visibility is two independent switches with two different jobs: an inactive product (
is_activefalse) drops out of the public catalog and out of the operator pickers that attach products to new things, whileis_publicfalse hides the product from members only. Neither switch implies the other. - [db] A product has at most one default price; a partial unique index rejects a second one. The default price is the price members are offered.
- [db] A usage-typed price must be recurring: one CHECK constraint limits
usage_typetoNULL,licensed, ormetered, and another rejects any price whoseusage_typeis set whilerecurring_intervalisNULL. A one-time price therefore never carries a usage type. - [app] A member can only be offered the default price, and only when that price is active and has a row in
stripe.price_mappings; without the mapping, the buy control renders disabled instead of submitting to checkout. - [db] Entitlement sets do not nest — the schema has no way for a set to include another set. To sell a combination, create a new set holding the combined rules. Many products may point at the same set.
- [db-code] What a provision delivers is fixed at conferral time:
core.conferwrites the product and the entitlement set onto the provision row as they are at purchase or grant, and nothing later rewrites live provisions when a product is edited. - [app] Member surfaces offer only published products. The public-catalog queries return only rows with
lifecycle_status = 'published', and checkout independently rejects any price whose product is not published, active, and public before contacting Stripe — so a draft or retired product cannot be bought even through a crafted or stale request. One exception: an organization already enrolled on a ladder tier keeps seeing that rung, marked current with no move control, after its product leaves publication.
Dimensions
These axes are independent, and holding them separate is most of understanding this model — many past bugs came from collapsing two of them into one:
- Lifecycle —
draft/published/retired. Editorial state: is this offer real yet, and is it still current? - Visibility —
is_active×is_public. Two switches, two jobs (invariant 5). - Ladder position — zero, one, or several
plan_ladder_tiersrows. The structural "is this a plan tier" fact, and if so, where it sits in a ladder's rank order. - Billing shape — derived from the product's active prices:
unpriced,one_time,recurring, ormixed(recurring and one-time prices active at the same time). Theproduct_shapeview reportsmixedrather than choosing one, and selling both ways at once is legal and deliberately supported — a lifetime-license tier sold alongside a subscription is the canonical case. - Consumption shape — derived from the active prices'
usage_type:none,licensed(pay for access),metered(pay for use), ormixed. - Entitlement linkage — which set the product confers.
- Stripe sync — whether the default price has its
stripe.price_mappingsrow; without one the product cannot be bought. - Presentation —
display_category. Deliberately not a behavioral axis (invariant 2); the storefront's "add-ons" page is this grouping at work, not a structural category.
Two composite verdicts assemble these axes, and they must not be conflated. Purchasability answers "may a member buy this now?" — the complete verdict requires published, active, public, an entitlement set linked, and an active default price with its Stripe mapping, and is computed in product_readiness.go for the operator readiness panel. The member storefront and checkout enforce every leg of that verdict a member could trip (invariant 11): the catalog queries return only published, active, public products, and checkout re-checks those three switches plus price activity and the Stripe mapping before contacting Stripe. The one leg reported only on the operator panel is the linked entitlement set. Conferral shape answers "what does holding this product deliver?" — assembled by product_conferral_shapes from the entitlement set, lifecycle, member visibility, and ladder position.
Purchasability is not the same question as findability: whether the member catalog actually renders a control for the product at all. The member catalog only ever draws two sections — plan-ladder tiers and display_category = 'addon' products — so a public, purchasable product that is on no ladder and carries no addon category is real and legitimately buyable (invariant 2: display_category is presentation, not a gate) but has no catalog path a member can reach. The readiness panel reports this as a diagnostic "member catalog visibility" line, ladder_count > 0 OR display_category = 'addon', and attaches a qualifier to an otherwise-purchasable verdict rather than failing it — off-ladder purchasability is an ordinary, supported shape, not an error, but the panel must never let "Purchasable" be misread as "a member can find this."