The Rules section is one record table grouped by kind, Limit then Boolean, on fixed columns, edited in place: Edit opens a row's controls in their columns, Add rule opens a dense row above the table, and every change is staged into a tray that lists the deltas with Undo and applies them as one rule-change act. The reduction policy is a column of the rule beside its limit. History shows counts only. Group rows are a quiet heading rather than a divider, the maintainer's pick from four rounds of outside-model ideation. Dense rows align to the top and render each error under its control in every form family (design D16), replacing the below-row error block; the forms library gains the batch form (rows plus one tray) and the RowField dense and label-hidden options. Migration 00019 records the governing reduction policy on effect rows. Archive staged-rule-changes with its spec updates (entitlement-set- management, entitlement-set-history, entitlements, form-library, form-conventions, ui-quality-gate). Screens accepted 2026-09-19.
107 lines
18 KiB
Markdown
107 lines
18 KiB
Markdown
---
|
||
title: "Entitlements"
|
||
audience: [developer]
|
||
summary: "How capabilities are defined, delivered, and enforced: sets and their rules, grants as decrees, provisions as delivery, and the materializer that turns it all into effective limits."
|
||
---
|
||
|
||
# Entitlements
|
||
|
||
## Purpose
|
||
|
||
This model answers "what can this organization actually do, and how much of it?". A capability is named by a **resource key** (for example `fedwiki_sites`), carrying a **kind** — `numeric` (a countable limit) or `boolean` (an on/off capability). Most keys are owned by a **provider** — the registry entity representing an integrated external service such as FedWiki (federated wiki hosting) or Discourse (forums) — which is also the code that enforces them; a key with no provider is platform-owned. An **entitlement set** bundles **rules**; a boolean or limit rule says what one resource key grants (the reserved credit rule type names no key). Products point at sets — the Product Catalog card owns that linkage.
|
||
|
||
Delivery is a five-step pipeline:
|
||
|
||
1. A **funding source** exists: a subscription, or a **grant** — the decree ledger, a durable record that an operator or the system decided to give an organization a product.
|
||
2. **Conferral** turns the source into a **provision** row in the organization's **resource pool**, the per-organization container; every organization gets a default pool.
|
||
3. **Materialization** recomputes the pool's effective entitlements from all its live provisions.
|
||
4. The results land in two read models: **numeric entitlements** (`core.numeric_entitlements`, one limit per resource key, explained row-by-row in `core.numeric_entitlement_contributions`) and **boolean entitlements** (`core.boolean_entitlements`).
|
||
5. **Consumers** read only those materialized rows — never the rules. A consumer resolves its **workspace** (the working surface an organization gets, where sites and services live; the identity model owns it) to a pool through `core.pool_assignments`, then reads the pool's rows, and reports consumption into `core.numeric_entitlement_usage`.
|
||
|
||
Three statuses live on three different objects and must never be conflated: a grant's status (`active`/`revoked`/`expired`) is issuance history; a provision's status (`active`/`suspended`/`ended`) is the currently-delivering fact; and the "live / superseded / inactive" labels the operator UI shows are derived at read time by joining the two — never stored.
|
||
|
||
Vocabulary reused from the [Product Catalog card](product-catalog.md): product, conferral, provision, the five conferral functions. From [Plan Ladders and Transitions](plan-ladders-transitions.md): occupancy, supersession, default restoration. From [Payments and Billing](payments-billing.md): subscription, reconciler.
|
||
|
||
## Where this lives
|
||
|
||
Database objects sit in the `core` schema; baseline DDL in `internal/db/migrations/00001_init.sql`, altered by `00004`–`00008` (the "doc41" in some file names is an internal change label, nothing more).
|
||
|
||
| Piece | What it is | Where |
|
||
|---|---|---|
|
||
| `core.resource_keys` | The capability namespace: `kind` (`boolean` or `numeric`) discriminates what rule shape a key accepts; `provider` names the owning integration, or is empty for platform-owned keys (integrations stamp their ownership at boot) | `00001_init.sql`; seeds in `00002`; kind in `00008_resource_key_kinds.sql` |
|
||
| `core.entitlement_sets` | Named rule bundles; name is unique; a nullable unique `key` is the row's declarative address for seeds, configuration, tests, scripts, and API clients (NULL unless one of them named the row); `is_active` gates only whether new products can pick the set | `00001_init.sql`; `00012_entity_keys.sql` |
|
||
| `core.entitlement_set_rules` | One rule per row: `rule_type` (`boolean`/`limit`/`quota`/`credit`), value, stacking policy, per-unit flag — with a CHECK forcing each type's fields into shape; since September 2026 also `tier_reduction_policy`, the rule's promise about headroom when its limit drops below usage (see [Reduction policy](#reduction-policy)) | `00001_init.sql`; `00018_entitlement_set_changes.sql` |
|
||
| `core.resource_keys.over_limit_behavior`, `over_limit_consequence` | The provider's declaration of what it does with a surplus (`deny_new`, `park`, `reclaim`) and the one sentence the preview shows for it; stamped by the integration at boot, never operator-edited, platform-owned keys CHECKed to `deny_new` | `00018_entitlement_set_changes.sql` |
|
||
| `core.entitlement_set_changes`, `_change_effects`, `_change_obligations` | The rule-change ledger: one append-only act row per rule change with the rule snapshotted before and after, one effect row per pool and key that moved (carrying the changed rule's reduction policy and, since migration 19, the policy governing the pool and key), and the mutable obligations a commit above the synchronous cap leaves for the drain | `00018_entitlement_set_changes.sql`, `00019_effect_governing_policy.sql` |
|
||
| `core.commit_rule_change` | The sole writer of rule changes: files the act, rewrites the rule, recomputes the carrying pools up to the cap, and leaves obligations for the rest | `00018_entitlement_set_changes.sql` |
|
||
| `core.grants` | The decree ledger: who granted which product to whom and why, with `extends_grant_id` chaining a replacement grant to its ancestor | `00001_init.sql`; reshaped in `00004` |
|
||
| `core.pool_provisions` | The delivery ledger: one row per (pool, product, source) with quantity, time bounds, and the `active`/`suspended`/`ended` status that is the currently-delivering fact | `00001_init.sql`; `00004` |
|
||
| `core.resource_pools`, `core.pool_assignments` | The per-organization container entitlements materialize onto, and its links to workspaces | `00001_init.sql` |
|
||
| `core.numeric_entitlements`, `core.numeric_entitlement_contributions`, `core.numeric_entitlement_usage` | The materialized numeric limits, the per-provision contribution ledger explaining each limit, and the usage counters consumers increment | `00001_init.sql` |
|
||
| `core.boolean_entitlements` | Materialized on/off capabilities: `granted` true while at least one active provision carries the rule; rows lapse to false but are never deleted, so an absent row always means never-conferred | `00007_boolean_entitlements.sql` |
|
||
| The five conferral functions | The sole writers of the provision tables (verbs glossed in the ladders card), raising named error codes the conferral boundary translates | `00005_doc41_conferral_functions.sql` |
|
||
| Grant-lineage guards | A CHECK rejects self-extension; two triggers reject cross-organization lineage and freeze `extends_grant_id` after insert | `00001_init.sql` |
|
||
| Materializer | `MaterializePoolEntitlements`: full in-transaction re-evaluation of a pool's active provisions into limits, contributions, and boolean grants | `internal/entitlements/materialize.go` |
|
||
| Conferral boundary | Typed wrappers over the five functions, error mapping, and `ConferGrantTx` — decree, confer, materialize in one transaction | `internal/entitlements/conferral.go` |
|
||
| Default restoration | Mints and confers the baseline default grant (see the ladders card for the floor guard) | `internal/entitlements/reapply_defaults.go` |
|
||
| Delivery-state query | `ListGrantsWithDelivery` derives each grant's live/superseded/inactive label (plus supersession lineage — `extends_grant_id` and, for a superseded row, the grant that replaced it) by joining onto provisions at read time; one shared query an optional org filter narrows, so the grants index and the per-organization grants table render from the exact same derivation and can never disagree | `internal/entitlements/queries/grants.sql` |
|
||
| Rule authoring | Operator set and rule CRUD: `rule_type` derived from the key's kind, stacking fixed to `additive` (the selector left the form), constraint violations mapped to field errors | `internal/server/operator_entitlement_sets.go` |
|
||
| Grant issuance | The operator grant forms (quantity capped at 1..1,000,000): issuing composes through `ConferGrantTx`; extending hand-rolls the same three steps in its own transaction. The Issue Grant form discloses that recording the grant is immediate while any provider-side follow-through it triggers is separately queued work | `internal/server/operator_enrollment.go` |
|
||
| Grant surfaces | The grants index (`/operator/grants`) and the per-organization composite both render `ListGrantsWithDelivery`'s rows: delivery state as a badge (never `grants.status` under a bare "Status" header), lineage, and an in-place ledger-vs-delivery explanation | `internal/server/operator_partials.go` (index); `internal/server/operator_enrollment.go` (org detail) |
|
||
| Consumers | The gate for claiming DNS domains ([Domains Registry card](domains-registry.md)), the member entitlements panel, and the FedWiki site-limit check reading limit vs usage | `internal/server/external_claim_gate.go`; `internal/server/member_products.go`; `internal/integrations/fedwiki/web/api.go` |
|
||
|
||
## Invariants
|
||
|
||
An invariant is a rule the model guarantees everywhere; code that would break one is wrong even if it works locally. Tags: **[db]** — a schema constraint rejects violations; **[db-code]** — database-side code upholds it; **[app]** — only Go code upholds it. (The position-table invariants live in the ladders card: only the five functions write them (its invariant 2), one live provision per source (4), supersession is whole-bundle and atomic (7). They hold here unchanged.)
|
||
|
||
1. **[db]** A set carries at most one active rule per resource key — a partial unique index (a uniqueness rule applied only to rows matching a condition, here active rules with a key) rejects the duplicate at insert — and each rule's fields must match its type: a boolean rule must leave value, stacking, and per-unit empty.
|
||
2. **[db]** A resource key's kind is one of `boolean` or `numeric`, CHECK-enforced.
|
||
3. **[app]** Rule shape follows key kind: the authoring form derives the rule type from the chosen key's kind, so the UI cannot create a mismatched rule. No CHECK ties `rule_type` to the key's kind, so direct SQL still could — the guarantee is app-layer.
|
||
4. **[db]** Every grant names a real product — `product_id` is NOT NULL, and no column lets a grant point at an entitlement set directly.
|
||
5. **[db]** A grant's reason is one of exactly eight allowed values — `manual`, `default`, `evaluation`, `promotional`, `complimentary`, `sponsored`, `board_decision`, `legacy` — and `default` is legal only on system-authored grants (no granting person recorded).
|
||
6. **[db-code]** Grant lineage is safe: a CHECK rejects self-extension, and triggers reject cross-organization lineage and freeze the lineage pointer after insert.
|
||
7. **[app]** `grants.status` is issuance history only — `active`, `revoked`, `expired`, written by the operator and expiry paths, with no schema CHECK. The currently-delivering fact lives on the provision's status, and any UI classification is derived per read by joining the two. Code must never read a grant's status as delivery.
|
||
8. **[app]** Materialization is an idempotent full re-evaluation, and it runs in the same database transaction as every conferral-changing write — grant issuance, Stripe reconciliation, expiry, tier changes, default restoration — so effective entitlements never lag a committed conferral.
|
||
9. **[app]** Rule authoring accepts only the `additive` stacking policy; the policy selector no longer exists on the form, and a request carrying any other value is rejected. The materializer still computes `additive` and `maximum` for rows that already carry them, and any other stored value contributes nothing to the limit. (What stacking policies are for, and how mixtures should combine, is an open exploration in `status/issues.md`.)
|
||
10. **[app]** A boolean entitlement is granted while at least one active provision carries its rule; when the last one goes, the row flips to not-granted but is never deleted — absence always means never-conferred.
|
||
11. **[app]** A per-unit rule contributes its value multiplied by the provision's quantity; a flat rule contributes its value once. The choice is per rule, applied within one provision — quantity never multiplies across provisions.
|
||
12. **[db]** A provision's quantity is positive — a CHECK rejects zero and negatives.
|
||
13. **[app]** The usage counter has one mutator: consumers increment it. The materializer seeds each counter row at zero when it first materializes a limit, then never touches the value; enforcement compares limit to usage at read time.
|
||
14. **[db]** Every rule carries a reduction policy, NOT NULL and CHECK-constrained to `block`, `defer`, `clamp` or `force_reduce`, defaulting to `defer`. It is meaningful for limit rules and inert for boolean ones.
|
||
15. **[db-code]** Rule changes reach existing pools: `core.commit_rule_change` is the only path that writes a rule, and it recomputes every pool carrying the set, synchronously up to the cap and through obligations beyond it, filing one act row and its effects as it goes.
|
||
|
||
## Dimensions
|
||
|
||
Four quantity-flavored axes come first, because conflating any two of them is this model's classic bug:
|
||
|
||
- **Quantity** — how many units one source delivers, stored on the provision; resizing mutates the same row. (Today every checkout confers quantity 1, so the plumbing is exercised only by grants.)
|
||
- **Provision count** — how many sources deliver to the pool; each subscription, purchase, or grant is its own provision, and cross-source accumulation is intentional.
|
||
- **Per-unit** — whether one rule's value multiplies by quantity (within a single provision only).
|
||
- **Stacking** — how contributions from *different* provisions fold into one limit: `additive` sums, `maximum` keeps the largest. Each contribution applies its own rule's policy against the running total, so mixing policies on one resource key gives order-dependent results — a known trap, tracked as a bug candidate in `status/issues.md` under the entitlements ledger.
|
||
|
||
And the rest:
|
||
|
||
- **Ledger vs delivery vs position** — grants (decree history), provisions (what delivers now), occupancy (which ladder rungs — the ladders card). Three layers; UI must join, never shortcut.
|
||
- **Rule shape** — the key's kind decides boolean vs numeric, which decides the materialized table consumers read.
|
||
- **Admitted vs materialized rule types** — the schema's CHECK admits four types, but only `boolean` and `limit` materialize; `quota` and `credit` are reserved (their intended machinery — periodic reset, spendable balance — is unbuilt), the UI never offers them, and the materializer skips any that direct SQL might create.
|
||
- **Definition vs usage** — limits say what is allowed; usage counters say what is consumed; the comparison happens at the consumer (invariant 13).
|
||
- **Conferral snapshot vs current catalog** — a provision freezes its product and set at conferral; when the catalog changes shape under a live provision, `core.align_conferral_shape` reconciles its ladder occupancy — the frozen product and set on the provision itself are never rewritten.
|
||
- **Reduction policy vs over-limit behavior** — the rule says whether a surplus is acted on (the promise to the member); the resource key says what its provider does with one (park, reclaim, or refuse new). The policy decides whether the behavior is exercised. See the next section.
|
||
|
||
## Reduction policy
|
||
|
||
A limit can drop below what a pool already uses: a rule is lowered or removed, a plan is downgraded, a grant expires. The rule's `tier_reduction_policy` is the platform's promise to the member about that moment (Decision 125 in the model; Decisions 143 and 147 brought it into this codebase in September 2026). Four values exist. The designed meaning is the model's; the as-built column is what this codebase does today.
|
||
|
||
| Value | Designed meaning (data model, Decision 125) | As built today | Strength |
|
||
|---|---|---|---|
|
||
| `force_reduce` | Apply the new limit immediately, even below current usage; the provider acts on the surplus | Exercised. The FedWiki quota sweep parks the excess sites read-only when the governing policy is `force_reduce` (`internal/integrations/fedwiki/workflows/reconcile.go`); the preview names the provider's `over_limit_consequence` sentence | 1, strongest |
|
||
| `clamp` | Lower the ceiling but keep existing usage above it until it drains; refuse increments meanwhile | Exercised, and needs no wiring: `AtomicIncrementUsage` refuses while `current_usage >= resource_limit`; nothing is parked | 2 |
|
||
| `block` | Disallow the reducing change, as a precondition on the switch request | Not wired. Behaves as `clamp`: a rule commit has "no boundary" and the preview says so | 3 |
|
||
| `defer` (default) | Grandfather the prior allocation until the period or term boundary, via a scheduled change | Not wired. Behaves as `clamp` at a rule commit and on downgrades; the console has done so since v14 | 4, weakest |
|
||
|
||
**Strength and the fold.** A pool's key can be funded by rules from more than one set (two products held at once, a plan plus a grant). The policy that governs the pool is the strongest of the funding rules' policies, in the order of the Strength column: `force_reduce`, then `clamp`, then `block`, then `defer`. The order is stated once as a list in the Go fold (`governingPolicy` in `internal/entitlements/materialize.go`, which reports it per key beside the resolved value) and once as an explicit ordered `CASE` in `GetGoverningReductionPolicy` (`internal/entitlements/queries/entitlement_set_rules.sql`); a test feeds both the same mixtures. The reasoning: a promise the member's provider will act on cannot be weakened by a second product the member also holds. Chosen by the maintainer on 2026-09-18 over the alternative (the primary provision's rule governs), recorded in `status/issues.md` under "Reduction policies from several sets on one pool", and stated as a requirement by the `staged-rule-changes` change (`entitlements`, "The reduction policy governing a pool and key is the strongest across the rules funding it").
|
||
|
||
**Who reads what.** The FedWiki sweep reads the governing policy through the query. A rule commit reads it from the fold and stamps it on every effect row beside the changed rule's own policy (`governing_policy`, migration 19), so a reader sees when a rule's own policy was not the one that governed. The operator's rule-change surface offers a rule's own policy for editing and discloses nothing per pool; the batch dry run (`RuleChangeBatchPreview`) carries the governing policy for the review-details link that a later change adds.
|
||
|
||
**Drift to carry upstream.** The model defines four values and where each is enforced; it does not define how policies from several sets combine. Under the documentation division (Decisions 140 and 141) that is a Stage-1 issue for `membcons-db`, filed now that the console's spec states the fold; this card describes, it does not settle.
|