Product entitlements catalog (Phase 1: schema + admin + buy page)

Closes the request to make entitlements first-class on products
instead of free-text strings on policies. Operators declare the
closed list of entitlements a product offers — slug + display name
+ optional description — and policies pick from that list with a
click-to-toggle bubble UI. Buy page renders human-readable names
("AI summaries") with descriptions as tooltips, never the raw slug
("ai_summaries").

Schema (migration 0014):
- products.entitlements_catalog_json: nullable JSON column shaped
  as [{slug, name, description}, ...]
- Auto-backfill on upgrade: for each existing product, derive a
  catalog from the union of its policies' entitlement slugs, with
  name = slug.replace('_', ' ') and empty description. Operators
  can refine afterward.
- Products with no policy entitlements stay NULL (legacy
  free-text mode preserved).

Server:
- Product struct gains entitlements_catalog: Option<Vec<EntitlementDef>>
- repo::set_product_entitlements_catalog (validates lowercase ASCII
  slugs, uniqueness, defaults name to slug if empty)
- Product create/update API accept entitlements_catalog;
  update uses double-Option PATCH shape so operators can clear
- Closed-list validation: when product has a non-empty catalog,
  policy create + update reject any entitlement slug not in the
  catalog with a clear error pointing at the right path
- /v1/products/<slug>/policies surfaces entitlements_catalog
  in the product object so SDK consumers can render display
  names client-side
- Buy page renders entitlement display names + description tooltips
  on tier cards (falls back to raw slug for legacy entries that
  predate the catalog)

Admin UI:
- New catalogEditor() helper (repeating slug/name/description rows
  with add/remove buttons) embedded in product create + edit forms
- New entitlementBubblePicker() helper (click-to-toggle pill chips
  showing display name with description tooltip)
- Policy create form: entitlements input swaps based on the chosen
  product's catalog — bubble picker when catalog has entries,
  legacy textarea otherwise. Rebuilds when operator changes
  product.
- Policy edit modal: same bubble-picker-or-textarea swap, scoped
  to the policy's product
- Policy list table: entitlement column shows display names
  (resolved against the product's catalog) instead of slugs

Migration regression test verifies:
- Backfill correctly unions entitlements across all of a product's
  policies, deduplicates, applies name = slug-with-underscores-as-
  spaces transformation
- Products with no policy entitlements get NULL (not [])
- Manually-set catalog values round-trip
- Schema is otherwise FK-clean post-migration

Test count: 78 (was 77; +1 for migration_0014_backfills_*).

Phase 2 (SDK updates + integration doc + side-by-side card-grid
policy authoring UI) ships in follow-up commits before v0.2.0:8.
This commit is contained in:
Grant
2026-05-10 07:55:14 -05:00
parent b95b47e0d5
commit 68dfe7f6fc
8 changed files with 728 additions and 28 deletions
@@ -0,0 +1,86 @@
-- Product-level entitlements catalog.
--
-- Before this migration, entitlements were free-text strings stored
-- per-policy as a JSON array. There was no shared vocabulary across
-- the policies of a single product, no display-name metadata, and no
-- way for the buy page to render anything but the raw slug
-- (`ai_summaries` instead of "AI summaries").
--
-- This migration introduces a per-product catalog: each product
-- declares the entitlements it offers, with slug + display name +
-- optional description. Policies still store entitlements as a JSON
-- array of slugs (no schema change to that side); the catalog is the
-- source of truth for the human-readable rendering and the
-- closed-list validation that policy entitlements must reference
-- catalog entries.
--
-- Strategy: additive only. The new column is nullable. Existing
-- products are auto-backfilled with a catalog derived from the union
-- of entitlements across their existing policies — operator can edit
-- afterward to add display names + descriptions. Products with zero
-- existing policy entitlements get NULL (legacy "free-text" mode
-- continues to work; operator opts in by editing the product).
PRAGMA foreign_keys = ON;
-- ---------------------------------------------------------------------------
-- products: entitlements_catalog_json
-- ---------------------------------------------------------------------------
-- Stored shape (when non-null):
-- [
-- { "slug": "core", "name": "Core", "description": "..." },
-- { "slug": "ai_summaries", "name": "AI summaries", "description": "..." },
-- ...
-- ]
--
-- Nullable so existing products that don't want a catalog stay clean,
-- and so operators can opt out by clearing the field.
ALTER TABLE products ADD COLUMN entitlements_catalog_json TEXT;
-- ---------------------------------------------------------------------------
-- Backfill: derive an initial catalog from existing policy entitlements
-- ---------------------------------------------------------------------------
-- For each product, collect the union of distinct entitlement slugs
-- across all its policies. Build a catalog row for each:
-- - slug = the slug as found in policies.entitlements_json
-- - name = slug with underscores replaced by spaces (so "ai_summaries"
-- becomes "ai summaries"; operator can title-case via the
-- admin UI)
-- - description = empty string (operator fills in)
--
-- Products with NO entitlements anywhere across their policies get
-- left with NULL — they're presumed not to use entitlements yet, and
-- forcing an empty catalog on them just adds a row to delete later.
UPDATE products SET entitlements_catalog_json = (
SELECT json_group_array(
json_object(
'slug', uniq_slug,
'name', replace(uniq_slug, '_', ' '),
'description', ''
)
)
FROM (
SELECT DISTINCT je.value AS uniq_slug
FROM policies p, json_each(p.entitlements_json) je
WHERE p.product_id = products.id
)
)
WHERE EXISTS (
SELECT 1
FROM policies p, json_each(p.entitlements_json) je
WHERE p.product_id = products.id
);
-- ---------------------------------------------------------------------------
-- Note: no CHECK constraint enforcing that policy entitlements
-- reference catalog slugs. SQLite doesn't easily express this kind of
-- cross-row JSON validation as a CHECK, and even if it did, the
-- validation needs to be conditional on the catalog being non-NULL
-- (legacy mode = no catalog = anything goes). The API layer enforces
-- the closed-list rule at write time:
--
-- - On policy create/update: if the parent product has a non-NULL,
-- non-empty catalog, every entitlement slug must appear in the
-- catalog. Otherwise (NULL or empty catalog), free-text accepted.
--
-- See api/policies.rs::validate_entitlements_against_catalog.