- 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
63 lines
11 KiB
Markdown
63 lines
11 KiB
Markdown
---
|
|
title: "Resource Pools"
|
|
audience: [developer]
|
|
summary: "The per-organization container where delivered capabilities live: pool identity, workspace assignments, the two resolution directions, and pool-grain usage counters."
|
|
---
|
|
|
|
# Resource Pools
|
|
|
|
## Purpose
|
|
|
|
A **resource pool** is the container where everything conferral delivers to an organization actually lands: provisions attach to a pool, the materializer writes the pool's effective entitlements, and consumers draw against pool-grain counters. The pool is also the join point between two worlds that do not reference each other in the schema: governance (an organization and its **workspaces** — the working surfaces where sites and services live) and commerce (billing accounts and subscriptions). A pool belongs to an organization and has no billing linkage; money reaches it only through provisions. (The design wanted the opposite parentage — pool under billing account — so this orthogonality is as-built, not as-designed; tracked in `status/issues.md`.)
|
|
|
|
Every resolution in this model runs in one of two directions, and they are different idioms with different failure modes. The **delivery side** resolves organization → default pool: conferral, Stripe reconciliation, and grant expiry all ask "which pool receives this?". The **consumption side** resolves workspace → primary assignment → pool: the domain-claim gate and the FedWiki site-limit check ask "which pool does this workspace draw from?". Today both directions collapse onto the same single pool, because every organization gets exactly one — the auto-managed default pool minted at signup — but the schema admits many, and several code paths already guard defensively against that future.
|
|
|
|
Usage lives at pool grain: one counter row per (pool, resource key), so sibling workspaces of one organization share one bucket. Acquiring quota is a single atomic, limit-guarded statement — concurrent acquisitions cannot overshoot.
|
|
|
|
Vocabulary reused from the [Entitlements card](entitlements.md): provision, conferral, materializer, resource key, numeric and boolean entitlements, usage counter, grant. From [Plan Ladders and Transitions](plan-ladders-transitions.md): ladder positions and occupancy. From [Payments and Billing](payments-billing.md): billing account, subscription. An **organization type** classifies organizations and carries the default-plan pointer (the identity model owns it; today every organization is the personal type). "Tracked in `status/issues.md`" below always means the model-documentation ledger there, not a database table.
|
|
|
|
## Where this lives
|
|
|
|
Database objects sit in the `core` schema, defined in `internal/db/migrations/00001_init.sql`.
|
|
|
|
| Piece | What it is | Where |
|
|
|---|---|---|
|
|
| `core.resource_pools` | Pool identity: organization, name, per-org-unique slug, `pool_type` (only `default` is ever written), `is_auto_managed`, and a status that in practice is always `active` | `00001_init.sql` |
|
|
| `core.pool_assignments` | Workspace-to-pool links with an `is_primary` flag and their own `status` column (resolvers require `active`); a workspace can be assigned to a given pool only once | `00001_init.sql` |
|
|
| `core.numeric_entitlement_usage` | Pool-grain usage counters, one row per materialized numeric entitlement, with period-window columns (reserved for the unbuilt quota-reset machinery) | `00001_init.sql` |
|
|
| Pool queries | Create and fetch pools; `GetDefaultPoolByOrgID` is the delivery-side resolver (`pool_type='default'`, active, first match); `ListResourcePoolsByOrgIDAnyStatus` is a display-only read that, unlike the active-only fetchers, does not hide a non-active pool from view | `internal/entitlements/queries/resource_pools.sql` |
|
|
| Assignment queries | `GetPrimaryPoolAssignmentByWorkspace` — the consumption-side resolver every consumer uses | `internal/entitlements/queries/pool_assignments.sql` |
|
|
| Atomic usage counters | `AtomicIncrementUsage` / `AtomicDecrementUsage`: one guarded UPDATE resolving the workspace to its primary pool; increment refuses at the limit, decrement stops at zero. `ListPoolUsageWithLimitsByPoolID` is the read-only counterpart pairing each counter with its materialized limit, for display | `internal/entitlements/queries/numeric_entitlement_usage.sql` |
|
|
| Signup provisioning | `AutoProvision`, the only production pool creator: one transaction minting user → person → organization → workspace → pool → assignment → billing account → default grant | `internal/provisioning/provisioning.go` |
|
|
| Workspace creation | Attaches each new workspace to the org's default pool as primary | `internal/server/workspace_partials.go` |
|
|
| Delivery-side callers | Grant conferral, Stripe reconciliation, grant expiry, and org-type changes all target the default pool | `internal/entitlements/conferral.go`; `internal/fulfillment/reconcile.go`; `internal/workflows/entitlements/activities.go`; `internal/server/operator_org_types.go` |
|
|
| Consumption-side callers | The domain-claim gate and the FedWiki quota reads | `internal/server/external_claim_gate.go`; `internal/integrations/fedwiki/web/api.go` |
|
|
| Operator enrollment guard | Refuses grant issuance for an organization with more than one pool, and locks the pool row to serialize conferral-mutating flows. Also refuses issuance outright when the organization has zero pools, rendering a breakage warning (missing default pool) in place of the pool panel's neutral empty state and blocking the Issue Grant form with the same reason | `internal/server/operator_enrollment.go` |
|
|
| Org-detail pools panel | Read-only: each pool's status (not just its existence) and its per-resource usage counters (used/limit), so a suspended pool and a quota refusal are both diagnosable from the console | `internal/server/operator_enrollment.go`; `internal/embeds/templates/partials/operator_enrollment.html` |
|
|
|
|
## 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.
|
|
|
|
1. **[db]** A pool belongs to exactly one organization, and the schema gives pools no billing-account linkage — funding reaches a pool only through provisions.
|
|
2. **[db]** Pool slugs are unique per organization, and a workspace is assigned to a given pool at most once.
|
|
3. **[db]** Every organization gets exactly one pool: the auto-managed default pool minted at signup. No other production path creates pools, and no operator pool-management surface exists. A partial unique index (`uq_resource_pools_one_default_per_org`) rejects a second default pool for an organization, and a CHECK pins `pool_type` to its known values so an unknown type cannot slip past the index.
|
|
4. **[db]** Every workspace is linked to its organization's default pool as primary, in the same transaction that creates the workspace. A partial unique index (`uq_pool_assignments_one_primary_per_workspace`) rejects a second primary assignment for a workspace, so the atomic counter — which debits every pool the workspace's primary assignments resolve to — can match at most one pool.
|
|
5. **[app]** Delivery targets the default pool: the four canonical conferral paths (grant issuance, Stripe reconciliation, grant expiry, org-type changes) all resolve organization → default pool. One path does not: the operator grant-extend flow confers into the pool named in its URL without resolving or checking the default — harmless while every organization has one pool, and tracked in `status/issues.md`.
|
|
6. **[app]** All consumption resolves workspace → primary active assignment → pool; consumers never enumerate multiple pools.
|
|
7. **[app]** Usage is pool-grain: counters key on (pool, resource key), so every workspace of an organization draws down the same row.
|
|
8. **[app]** Quota acquisition is atomic and limit-guarded in one statement — the increment's WHERE clause requires usage below limit, so concurrent acquires cannot overshoot; decrement clamps at zero. Callers detect refusal by the statement reporting no row changed — which is also what a missing counter row or a missing primary assignment produces, so "no row changed" means "not allowed", not specifically "at limit".
|
|
9. **[db-code]** A usage counter row exists only under a materialized numeric entitlement (foreign keys and a per-entitlement uniqueness reject orphans), and only the materializer creates one — seeded at zero.
|
|
10. **[app]** Pool status never leaves `active` as built: nothing writes `suspended` or `archived`. The delivery-side resolvers filter on pool status, so suspending a pool would sever conferral; the quota paths consult only assignment status, so consumption would keep working — a writer must not be introduced before that cascade is defined (tracked in `status/issues.md`).
|
|
11. **[app]** The pool row is the operator-side serialization lock: the operator revoke path and org-type change enactment take a row lock on the pool before touching ladder positions. Stripe-driven mutation serializes differently — a per-subscription advisory lock inside the reconciler (see the Payments and Billing card).
|
|
12. **[app]** Operator grant issuance refuses a multi-pool organization outright rather than guessing which pool to target — an explicit placeholder for the unbuilt multi-pool future.
|
|
|
|
## Dimensions
|
|
|
|
- **Pool grain vs workspace grain** — entitlements and usage attach to the pool; workspaces are consumers that resolve into it. Sibling workspaces share one quota bucket.
|
|
- **Governance scope vs funding scope** — a pool lives in an organization; money reaches it only through provisions. The absence of any pool-to-billing link is deliberate.
|
|
- **Delivery direction vs consumption direction** — organization → default pool for delivering; workspace → primary assignment → pool for consuming. Same pool today, different idioms and different failure modes.
|
|
- **Designed multi-pool vs as-built single-pool** — the schema admits N pools and N assignments (the designed `pool_type` values `shared` and `dedicated` are unreachable; only `default` is written, and no CHECK constrains the column); the code creates exactly one pool and guards the rest — the enrollment refusal guard, and the plan-change path scanning every org pool for a ladder's active position (`internal/fulfillment/plan_change.go`).
|
|
- **Container vs lock** — beyond holding entitlements, the pool row is the per-organization concurrency unit (invariant 11).
|
|
- **Auto-managed vs operator-managed** — `is_auto_managed` marks system-owned pools; still write-only (the column is never read back for display), but no longer contrast-less: the organization-detail pools panel is a read-only operator surface (invariant 3's "no pool-management surface" still holds — the panel creates and edits nothing) showing each pool's status and usage counters.
|