# Data Model Reference **Version 16.0** > **Modular navigation available.** This document is the consolidated master reference for all 63 tables. For module-scoped navigation, see [`modules/README.md`](README.md), which decomposes the model into seven conceptual modules (identity, organization, entitlements, billing, cooperative, audit, integration) — seams within the physical topology of Decision 140. The master reference is authoritative and is the sole normative table-level surface: the per-module `model.md` projections are retired (Decision 141), and as-built models are documented descriptively in member-console `docs/models/`. --- ## Foundational Concepts This data model separates four concerns that are often conflated: 1. **Identity & Credentials**: Who is this actor, and how do they authenticate? 2. **Organization & Access**: What containers exist, and who can access them with what permissions? 3. **Entitlements**: What capabilities are available, and who provisioned them? 4. **Billing & Value**: Who pays, what do they pay for, and what value have they contributed? These concerns have different lifecycles, different data requirements, and different stakeholders. A person's legal name for tax forms is not the same as their login email. An organization's access control is not the same as its billing arrangement. A resource pool's entitlements are not the same as the subscription that funded them. Separating these concerns allows each to evolve independently. Four architectural principles govern the relationships between these modules: **Billing and governance are orthogonal.** Who pays for a resource and who controls a resource are different questions with different answers. The data model does not structurally fuse them. A billing account in one organization can provision a resource pool in another organization. This principle is drawn from analysis of GCP's resource hierarchy. **Access mechanisms are plural.** Subscriptions are one way to obtain access. One-time purchases and administrative grants are others. All access mechanisms produce pool provisions through a uniform interface. The entitlement system does not need to know how access was obtained. **Actors are plural.** Humans are not the only entities that act in the system. Personal access tokens, service accounts, and the system itself all perform actions. Each actor type authenticates differently, receives permissions differently, and appears in audit trails distinctly. **Payment topologies are plural.** Postpaid invoicing (consume first, pay later) is one payment topology. Prepaid credits (pay first, consume later) is another. Hybrid models blend both. The billing system supports all three without requiring structural changes to the entities that track consumption, entitlements, or access control. The payment topology is a billing account configuration, not an architectural assumption. --- ## Primary Key Strategy All tables use **UUIDv7** as their primary key type. UUIDv7 (RFC 9562) encodes a millisecond-precision Unix timestamp in the high bits followed by random data, producing identifiers that are globally unique, time-ordered, and non-sequential. This choice addresses three requirements: - **Non-predictability.** UUIDv7 values cannot be enumerated or used to infer entity counts. Sequential BIGSERIAL keys leak creation order and cardinality. - **Merge safety.** If two instances of this system need to merge (e.g., cooperative federation, environment reconciliation), UUIDv7 keys will not collide. Auto-incrementing keys require remapping. - **Partition friendliness.** The time-ordered prefix supports range-based partitioning on high-volume tables (`audit_logs`, `usage_events`) without a separate partition key. The time-ordered prefix preserves B-tree index locality — inserts append near the end of the index, and range scans on creation time are efficient. The cost relative to BIGSERIAL is 16 bytes per key instead of 8, which increases index size and JOIN overhead. This is acceptable for the entity cardinalities this system expects. **Timestamp leakage.** UUIDv7 encodes the creation timestamp in the high 48 bits. This is acceptable for internal identifiers. If the system develops an external-facing API, tables exposed through that API should add an opaque, random external identifier column (e.g., `external_id UUID DEFAULT gen_random_uuid()`) that is used in API responses and URL paths. Internal UUIDv7 keys should not appear in external interfaces. Throughout this document, `UUID` as a column type denotes UUIDv7 with a `DEFAULT uuidv7()` generation strategy. --- ## Schema Organization The model's seven modules are *conceptual* boundaries — seams in the sense of `GLOSSARY.md` §5.7 (Decision 140, superseding the physical claim of Decision 113). Module-qualified names throughout this reference (`billing.subscriptions`, `entitlements.grants`) are conceptual addresses: they name the module that owns a table, not necessarily a distinct physical schema. | Module | Domain | Tables | |--------|--------|--------| | `identity` | Identity & Credentials | 5 | | `organization` | Organization & Access | 8 | | `entitlements` | Entitlements | 15 | | `billing` | Billing & Value | 26 | | `cooperative` | Cooperative | 2 | | `audit` | Audit | 5 | | `integration` | Integration | 2 | The physical layout (Decision 140) is coarser: domain-module tables reside in the `core` and `domains` schemas — the exact table-level mapping is an as-built fact, documented on the member-console side with migrations as ground truth (Decision 141) — while the `public` schema holds shared extensions and functions only. Cross-module foreign keys remain governed by Decision 114: schema qualification applies where a module boundary coincides with a physical schema boundary; between modules co-resident in one schema the same foreign keys are intra-schema. Integration data for external services (payment processors, infrastructure provisioning, tax computation, notification delivery) lives in dedicated per-provider schemas (e.g., `stripe`, `polar`, `nextcloud`). Integration schemas are designed when each integration is built and are not part of this reference document. They reference domain tables via schema-qualified foreign keys (e.g., `REFERENCES billing.products(product_id)`) but do not reference each other (Decision 86). --- ## Structural Policies Four cross-cutting policies govern the behavior of this schema. Their full specifications are maintained as separate policy documents; this section summarizes the constraints that table definitions depend upon. **Soft-Delete and Terminal State Policy.** No record that has ever been referenced by another table's foreign key is physically deleted. All lifecycle terminations are status transitions to terminal states. Foreign key references to terminal-state records are valid and expected. All FK constraints use `ON DELETE RESTRICT`; exceptions require documented justification in the migration. **GDPR Anonymization Protocol.** Erasure requests are satisfied by comprehensive anonymization — irreversible destruction of all PII on the `persons` and linked `users` records — not physical deletion. Before executing, the protocol checks for active retention holds. If holds exist, partial erasure is performed: non-retained PII is scrubbed while legally required fields are preserved. **JSONB Governance Policy.** No JSONB column stores personally identifiable information or sensitive personal data. This is a categorical prohibition with no exceptions. JSONB columns serve as structured extensibility surfaces for configuration, supplementary attributes, and non-PII metadata. Attributes that become query dependencies should be promoted to relational columns. **Temporal Modeling Convention.** Each status-bearing entity records explicit `{state}_at` timestamps for each non-initial status value. Governance-sensitive entities additionally record `{state}_by` actor attribution. These columns capture the most recent entry into each state. The audit log serves as the complete history for repeated transition cycles. Status transitions must be recorded in the audit log `changes` JSONB as `{"status": {"from": "X", "to": "Y"}}`. --- ## Identity & Credentials — `identity` schema Identity & Credentials answers: "Who is this actor, and how do they authenticate?" This module covers both human and non-human actors. Human actors authenticate through the identity provider (Keycloak) as users, or programmatically through personal access tokens. Non-human actors authenticate through service account API keys or (optionally) Keycloak client credentials. ### users A **user** is an authentication identity. It represents a login credential managed by the identity provider (Keycloak). This table is a local cache of authentication data, synchronized from the identity provider. A user is not a person. A user is a way for a person to authenticate. This distinction matters because: - A person exists in the business domain even before they create a login (e.g., when invited) - Authentication data (email, username) may differ from legal/business data - If the identity provider changes, user records change but person records persist | Field | Type | Purpose | |-------|------|---------| | `user_id` | UUID | Primary key | | `oidc_subject` | VARCHAR(255) | Unique identifier from identity provider. Immutable reference that survives email changes. | | `oidc_issuer` | VARCHAR(255) | Which identity provider issued this identity. Supports multiple IdPs. | | `email` | VARCHAR(255) | Email used for authentication. May differ from billing email. | | `email_verified` | BOOLEAN | Whether the identity provider has verified this email. | | `username` | VARCHAR(100) | Display username from identity provider. | | `display_name` | VARCHAR(255) | Human-readable name for UI display. | | `avatar_url` | VARCHAR(500) | Profile image URL. | | `locale` | VARCHAR(10) | Preferred language/locale. | | `timezone` | VARCHAR(50) | Preferred timezone for date display. | | `last_login_at` | TIMESTAMPTZ | Most recent authentication timestamp. | | `last_login_ip` | INET | IP address of most recent login. For security auditing. | | `status` | VARCHAR(20) | Account state: `active`, `suspended`, `deleted`. | | `suspended_at` | TIMESTAMPTZ | When most recently suspended. | | `deleted_at` | TIMESTAMPTZ | When deleted. | | `created_at` | TIMESTAMPTZ | When this auth identity was created. | | `updated_at` | TIMESTAMPTZ | Last modification timestamp. | **Relationships:** - `users` (1) → (0..1) `persons`: A user may be linked to a person. The relationship is optional on the persons side because a person can exist before they sign up (invited but not yet registered). --- ### persons A **person** is a human being as a business and legal entity. It holds information needed for invoices, contracts, tax forms, and cooperative membership. A person is not a user. A person is a human who participates in business relationships. This distinction matters because: - Business data (legal name, tax ID, mailing address) doesn't belong in the authentication system - A person may be invited to an organization before they create a login - Cooperative membership, equity ownership, and patronage belong to the person, not to their login credentials - Legal and tax requirements (like 1099-PATR forms) require data the auth system doesn't have | Field | Type | Purpose | |-------|------|---------| | `person_id` | UUID | Primary key | | `user_id` | UUID | FK → users. Link to authentication identity. NULL if invited but not yet signed up. | | `legal_first_name` | VARCHAR(100) | Legal first name as it appears on official documents. | | `legal_last_name` | VARCHAR(100) | Legal last name as it appears on official documents. | | `phone` | VARCHAR(50) | Contact phone number. | | `address_line1` | VARCHAR(255) | Street address line 1. Required for invoices and tax forms. | | `address_line2` | VARCHAR(255) | Street address line 2. | | `city` | VARCHAR(100) | City. | | `state_province` | VARCHAR(100) | State or province. | | `postal_code` | VARCHAR(20) | Postal/ZIP code. | | `country_code` | VARCHAR(2) | ISO 3166-1 alpha-2 country code. | | `tax_id_type` | VARCHAR(20) | Type of tax identifier: `ssn`, `ein`, `itin`, `vat`, `gst`, `other`. | | `tax_id_last4` | VARCHAR(4) | Last 4 digits of tax ID. Full ID stored in secure vault. | | `tax_id_verified` | BOOLEAN | Whether tax ID has been verified. | | `tax_id_verified_at` | TIMESTAMPTZ | When tax ID was verified. | | `retention_hold` | BOOLEAN DEFAULT FALSE | Whether this person's PII is subject to an active retention obligation. When `true`, the anonymization protocol must not execute without resolving the hold. | | `status` | VARCHAR(20) | State: `pending`, `active`, `inactive`, `partially_erased`, `anonymized`, `merged`. | | `activated_at` | TIMESTAMPTZ | When pending → active. | | `deactivated_at` | TIMESTAMPTZ | When active → inactive. | | `deactivated_by` | UUID | FK → persons. Who deactivated. | | `partially_erased_at` | TIMESTAMPTZ | When partial erasure was performed. | | `anonymized_at` | TIMESTAMPTZ | When full anonymization was performed. | | `created_at` | TIMESTAMPTZ | When this person record was created. | | `updated_at` | TIMESTAMPTZ | Last modification timestamp. | **Constraints:** - `user_id` has a partial unique index: `UNIQUE (user_id) WHERE user_id IS NOT NULL`. A user maps to at most one person. **Status lifecycle:** - `pending` → `active`: Invitation accepted, account established. - `active` → `inactive`: Administrative deactivation. Reversible. - `active` → `partially_erased`: Erasure requested but retention holds prevent full anonymization. Non-retained PII scrubbed. Not reversible. - `active` or `inactive` → `anonymized`: Full PII destruction. Irrevocable terminal state. - `partially_erased` → `anonymized`: All retention holds released. Remaining PII scrubbed. Irrevocable. - `active` → `merged`: Duplicate resolved. All FK references repointed to surviving person. Irrevocable. **Relationships:** - `persons` (0..1) → (1) `users`: A person may be linked to a user for authentication. The relationship is 1:1: each user maps to at most one person (enforced by partial unique index on `user_id`), and each person has at most one user (enforced by the single FK column). - `persons` (1) → (0..1) `organization.organizations`: A person may own a personal organization. - `persons` (1) → (0..*) `organization.org_members`: A person may be a member of multiple organizations. - `persons` (1) → (0..*) `organization.workspaces`: A person may have created workspaces. - `persons` (1) → (0..*) `organization.role_assignments`: A person may have scoped role assignments. - `persons` (1) → (0..*) `personal_access_tokens`: A person may have programmatic access tokens. - `persons` (1) → (0..*) `retention_holds`: A person may have active retention obligations. --- ### personal_access_tokens A **personal_access_token** is a credential that allows a person to access the API programmatically. The token acts as the person — it inherits the person's permissions (potentially narrowed by scopes). It is the most common form of programmatic access for human users. Personal access tokens are distinct from service accounts. A PAT acts *as the person* (same identity, same permissions). A service account acts *as itself* (independent identity, independently granted permissions). This distinction matters for audit trails, revocation semantics, and the "what happens when a person leaves the organization" question — their PATs are revoked with their account, but service accounts they created continue operating. | Field | Type | Purpose | |-------|------|---------| | `token_id` | UUID | Primary key | | `person_id` | UUID | FK → persons. The person this token acts as. | | `name` | VARCHAR(255) | Human-readable name (e.g., "CI deploy token", "Local development"). | | `description` | TEXT | What this token is used for. | | `token_hash` | VARCHAR(255) | Hashed token value. The plaintext is shown once at creation and never stored. | | `token_prefix` | VARCHAR(10) | First few characters of the token, for identification in logs and UI (e.g., "mc_pat_a3f..."). | | `scopes` | TEXT[] | Permission scopes this token is limited to. NULL means full permissions of the person. When set, effective permissions are the intersection of the person's permissions and the token's scopes. | | `expires_at` | TIMESTAMPTZ | When this token expires. NULL for no expiration (discouraged). | | `last_used_at` | TIMESTAMPTZ | When this token was last used for authentication. | | `last_used_ip` | INET | IP address of last use. | | `revoked_at` | TIMESTAMPTZ | When this token was revoked. | | `revoked_by_person_id` | UUID | FK → persons. Who revoked this token (may differ from the owner). | | `status` | VARCHAR(20) | `active`, `expired`, `revoked`. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Token format convention:** `mc_pat_` — the prefix `mc` identifies the Member Console, `pat` identifies the token type. Leaked tokens can be identified and traced by prefix. **Security properties:** - The plaintext token is displayed exactly once at creation. Only the hash is stored. - The `token_prefix` allows identification without exposing the full token. - Expired and revoked tokens cannot authenticate. Status checks are part of the authentication path. - Tokens inherit the person's *current* permissions, not a snapshot. If the person loses access, their tokens lose access immediately. **Relationships:** - `personal_access_tokens` (0..*) → (1) `persons`: Tokens belong to a person. --- ### retention_holds A **retention_hold** records an active legal obligation requiring retention of a person's PII. Retention holds prevent the anonymization protocol from executing full erasure and instead trigger partial erasure — scrubbing non-retained data while preserving fields required by the cited legal authority. | Field | Type | Purpose | |-------|------|---------| | `hold_id` | UUID | Primary key | | `person_id` | UUID | FK → persons. The person whose PII is held. | | `legal_authority` | VARCHAR(100) | The legal basis for retention (e.g., `irc_6001`, `irc_1381_1388`, `26_cfr_31_6001`). | | `description` | TEXT | Human-readable explanation of the retention obligation. | | `data_categories` | TEXT[] | Which data categories are held (e.g., `['legal_name', 'tax_id', 'billing_address']`). | | `hold_placed_at` | TIMESTAMPTZ | When the hold was placed. | | `hold_placed_by` | UUID | FK → persons. Who placed the hold (NULL if system-automated). | | `hold_expires_at` | TIMESTAMPTZ | When the retention obligation expires. NULL if the expiration depends on a future event (e.g., equity redemption). | | `hold_released_at` | TIMESTAMPTZ | When the hold was released. NULL if still active. | | `hold_released_by` | UUID | FK → persons. Who released the hold. | | `release_reason` | TEXT | Why the hold was released. | | `status` | VARCHAR(20) | `active`, `released`, `expired`. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Behavioral contract:** When any `retention_holds` record with `status = 'active'` exists for a person, the `persons.retention_hold` flag must be `true`. When the last active hold is released or expires, the flag is set to `false`. This denormalization is acceptable because the flag is a hot-path check (consulted on every anonymization request) while the holds table is a cold-path audit record (consulted when evaluating *why* a hold exists and *when* it expires). **Relationships:** - `retention_holds` (0..*) → (1) `persons`: Holds apply to a person. --- ### person_merges A **person_merge** records that one person record was merged into another. This provides structured provenance for duplicate resolution, supporting potential reversal and audit compliance. | Field | Type | Purpose | |-------|------|---------| | `merge_id` | UUID | Primary key | | `source_person_id` | UUID | FK → persons. The person being merged away. | | `target_person_id` | UUID | FK → persons. The surviving person. | | `merged_by_person_id` | UUID | FK → persons. Who authorized the merge. | | `merged_at` | TIMESTAMPTZ | When the merge was performed. | | `reason` | TEXT | Why the merge was necessary. | | `affected_references` | JSONB | Snapshot of tables and row counts updated during the merge. | | `created_at` | TIMESTAMPTZ | | **Constraints:** - `source_person_id` ≠ `target_person_id`. **Merge operation:** Executes within a single transaction: (1) validate source is not already `merged` or `anonymized`; (2) repoint all FK references from source to target across all person-referencing tables; (3) transfer active retention holds from source to target; (4) set source status to `merged`; (5) insert `person_merges` record; (6) insert `audit_logs` entry. See the Soft-Delete and Terminal State Policy for the full affected-tables checklist and uniqueness conflict resolution. **Relationships:** - `person_merges` (0..*) → (1) `persons`: Merges reference source and target persons. --- ## Organization & Access — `organization` schema Organization & Access answers: "What containers exist, and who can access them with what permissions?" ### organizations An **organization** is the top-level container for all resources, members, and billing relationships. Every person operates within the context of at least one organization. There are no solo users in this model. Every person has a personal organization created automatically. This design choice means: - The data model is uniform whether someone is solo or on a team - Solo users can invite collaborators without migration - All resources, billing, and access control work the same way regardless of organization size - Organizations can be personal (one owner), team (multiple members), or enterprise (formal business) **Platform administration convention:** The cooperative itself is represented as an organization in the model (e.g., slug = `platform`). Platform administrators are members of this organization with the `platform_admin` role. This reuses the existing org → members → roles mechanism for platform-level governance without requiring new schema entities. | Field | Type | Purpose | |-------|------|---------| | `org_id` | UUID | Primary key | | `name` | VARCHAR(255) | Display name of the organization. | | `slug` | VARCHAR(100) | URL-safe identifier. Globally unique. Used in URLs and API references. | | `org_type` | VARCHAR(20) | Classification: `personal` (auto-created for individuals), `team` (collaborative), `enterprise` (formal business). | | `owner_person_id` | UUID | FK → identity.persons. For personal organizations, the person who owns it. Required when `org_type` = `personal`. | | `legal_name` | VARCHAR(255) | Registered legal name of the business entity. | | `entity_type` | VARCHAR(50) | Legal structure: `llc`, `corporation`, `nonprofit`, `cooperative`, `sole_prop`. | | `tax_id` | VARCHAR(100) | Business tax identifier (EIN for US companies). | | `website` | VARCHAR(255) | Organization's website. | | `settings` | JSONB | Organization-level configuration and preferences. | | `status` | VARCHAR(20) | State: `active`, `suspended`, `deleted`. | | `suspended_at` | TIMESTAMPTZ | When most recently suspended. | | `suspended_by` | UUID | FK → identity.persons. Who suspended. | | `deleted_at` | TIMESTAMPTZ | When deleted. | | `deleted_by` | UUID | FK → identity.persons. Who deleted. | | `created_at` | TIMESTAMPTZ | When this organization was created. | | `updated_at` | TIMESTAMPTZ | Last modification timestamp. | **Constraints:** - Personal organizations must have an `owner_person_id`. Enforced by: `CHECK (org_type != 'personal' OR owner_person_id IS NOT NULL)`. - `slug` is globally unique to enable clean URLs. **Relationships:** - `organizations` (0..1) → (1) `identity.persons`: Personal organizations have an owner person. - `organizations` (1) → (0..*) `org_members`: Organizations have members. - `organizations` (1) → (0..*) `workspaces`: Organizations contain workspaces. - `organizations` (1) → (0..*) `billing.billing_accounts`: Organizations have billing accounts. - `organizations` (1) → (0..*) `entitlements.resource_pools`: Organizations have resource pools. - `organizations` (1) → (0..*) `roles`: Organizations may define custom roles. - `organizations` (1) → (0..*) `service_accounts`: Organizations have service accounts. - `organizations` (1) → (0..*) `invitations`: Organizations may have pending invitations. --- ### service_accounts A **service_account** is a non-human actor with its own identity and permissions. It represents an automated system or integration that interacts with the platform's API independently of any person. Service accounts belong to organizations, not persons. They are created and managed by organization administrators. They have their own role assignments, independent of any person's permissions. Service accounts are distinct from personal access tokens: a PAT acts *as a person* (inheriting their permissions); a service account acts *as itself* (with independently granted permissions). When a person leaves an organization, their PATs are revoked, but service accounts they created continue operating. Service accounts are locally-managed by default, with optional Keycloak linkage. The `oidc_subject` and `oidc_issuer` fields allow linking to a Keycloak client's service account identity without requiring it. Creating a Keycloak client for every service account is unnecessarily heavy; local API keys are the primary authentication mechanism. | Field | Type | Purpose | |-------|------|---------| | `service_account_id` | UUID | Primary key | | `org_id` | UUID | FK → organizations. The organization this service account belongs to. | | `name` | VARCHAR(255) | Display name (e.g., "CI/CD Pipeline", "Nextcloud Connector", "Backup Service"). | | `description` | TEXT | What this service account does. | | `oidc_subject` | VARCHAR(255) | If linked to a Keycloak service account: the OIDC subject identifier. NULL for locally-managed accounts. | | `oidc_issuer` | VARCHAR(255) | If linked to Keycloak: the issuer. NULL for locally-managed accounts. | | `created_by_person_id` | UUID | FK → identity.persons. Who created this service account. | | `status` | VARCHAR(20) | `active`, `suspended`, `deleted`. | | `suspended_at` | TIMESTAMPTZ | When most recently suspended. | | `suspended_by` | UUID | FK → identity.persons. Who suspended. | | `deleted_at` | TIMESTAMPTZ | When deleted. | | `deleted_by` | UUID | FK → identity.persons. Who deleted. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Relationships:** - `service_accounts` (0..*) → (1) `organizations`: Service accounts belong to an organization. - `service_accounts` (0..*) → (0..1) `identity.persons`: Service accounts track who created them. - `service_accounts` (1) → (0..*) `service_account_keys`: Service accounts have API keys. - `service_accounts` (1) → (0..*) `role_assignments`: Service accounts receive scoped role assignments. --- ### service_account_keys A **service_account_key** is a credential for a service account. Service accounts may have multiple keys to support rotation: a new key is created before the old one is retired, ensuring uninterrupted access during the transition. | Field | Type | Purpose | |-------|------|---------| | `key_id` | UUID | Primary key | | `service_account_id` | UUID | FK → service_accounts. | | `name` | VARCHAR(255) | Human-readable name (e.g., "Production key", "Rotation 2026-02"). | | `key_hash` | VARCHAR(255) | Hashed key value. Plaintext shown once at creation. | | `key_prefix` | VARCHAR(10) | First few characters for identification (e.g., "mc_sak_b7d..."). | | `expires_at` | TIMESTAMPTZ | When this key expires. NULL for no expiration. | | `last_used_at` | TIMESTAMPTZ | When this key was last used. | | `last_used_ip` | INET | IP address of last use. | | `revoked_at` | TIMESTAMPTZ | When revoked. | | `revoked_by_person_id` | UUID | FK → identity.persons. Who revoked it. | | `status` | VARCHAR(20) | `active`, `expired`, `revoked`. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Token format convention:** `mc_sak_` (Member Console, Service Account Key). Distinct from PATs for immediate identification. **Relationships:** - `service_account_keys` (0..*) → (1) `service_accounts`: Keys belong to a service account. --- ### roles A **role** defines a named set of permissions. Roles determine what actions an actor (person or service account) can perform within an organization, workspace, pool, or other scope. Roles are either system-defined (built-in, applicable to all organizations) or organization-defined (custom roles for a specific organization). System roles provide consistent semantics across the platform. Custom roles allow organizations to tailor access control to their needs. Permissions are flat and explicit. Each role states exactly what it grants as an array of permission strings. There is no implicit derivation, no inheritance. Given an actor's roles, you can enumerate their exact permissions without traversal. | Field | Type | Purpose | |-------|------|---------| | `role_id` | UUID | Primary key | | `org_id` | UUID | FK → organizations. If set, this is a custom role for this organization. If NULL, this is a system role. | | `role_name` | VARCHAR(100) | Machine-readable identifier. Unique within scope (system or per-org). | | `display_name` | VARCHAR(255) | Human-readable name for UI. | | `description` | TEXT | Explanation of what this role is for. | | `is_system` | BOOLEAN | Whether this is a built-in system role. System roles cannot be deleted or modified. | | `permissions` | TEXT[] | Array of permission strings. Format: `resource:action` (e.g., `billing:manage`, `workspace:delete`). | | `created_at` | TIMESTAMPTZ | When this role was created. | | `updated_at` | TIMESTAMPTZ | Last modification timestamp. | **Constraints:** - Custom roles may only contain permissions from the defined vocabulary. Application-level validation prevents invalid or stale permission strings. **System roles:** | Role | Purpose | Key Differentiators | |------|---------|-------------------| | `owner` | Full authority over the organization. | Includes `org:delete`, `org:transfer`. Only role that can destroy or transfer the organization. | | `admin` | Full access except irreversible org-level actions. | Everything except `org:delete`, `org:transfer`. | | `member` | Can work within workspaces and manage resources. | No billing, pool, or org-structure access. | | `billing` | Financial access only. | Can manage billing accounts, subscriptions, purchases, invoices. Cannot access workspaces or resources. | | `viewer` | Read-only access to all visible entities. | Can see everything but modify nothing. | | `platform_admin` | Platform-level administrative access. | Includes `entitlement_rules:manage`. Assigned within the platform organization only. | **Relationships:** - `roles` (0..*) → (0..1) `organizations`: Custom roles belong to an organization. System roles have no organization. - `roles` (1) → (0..*) `org_members`: Roles are assigned to organization members. - `roles` (1) → (0..*) `role_assignments`: Roles are assigned at scoped level. - `roles` (1) → (0..*) `invitations`: Roles are referenced by pending invitations. --- ### org_members An **org_member** represents the membership of a person in an organization with a specific role. This is the primary mechanism for organization-level access control. Membership is distinct from ownership and distinct from scoped role assignments. Membership represents *belonging* — a person is part of this organization. Role assignments represent *access* — an actor can do specific things in a specific scope. A person might have a workspace-scoped role assignment in an organization they are not a member of (e.g., a contractor with access to one workspace). Memberships are created upon invitation acceptance or direct administrative action — they are born `active`, never `pending`. Invitation state is tracked on the `invitations` entity. | Field | Type | Purpose | |-------|------|---------| | `org_member_id` | UUID | Primary key | | `org_id` | UUID | FK → organizations. The organization. | | `person_id` | UUID | FK → identity.persons. The person who is a member. | | `role_id` | UUID | FK → roles. The role this person has in this organization. | | `invitation_id` | UUID | FK → invitations. The invitation that created this membership. NULL if created without an invitation (e.g., auto-created for personal org owner, or platform-level administrative action). | | `status` | VARCHAR(20) | Membership state: `active`, `suspended`, `removed`. | | `suspended_at` | TIMESTAMPTZ | When most recently suspended. | | `suspended_by` | UUID | FK → identity.persons. Who suspended. | | `removed_at` | TIMESTAMPTZ | When removed. | | `removed_by` | UUID | FK → identity.persons. Who removed. | | `created_at` | TIMESTAMPTZ | When this membership was created. | | `updated_at` | TIMESTAMPTZ | Last modification timestamp. | **Constraints:** - A person can only be a member of an organization once. The combination of `org_id` and `person_id` is unique. **Relationships:** - `org_members` (0..*) → (1) `organizations`: Memberships belong to an organization. - `org_members` (0..*) → (1) `identity.persons`: Memberships belong to a person. - `org_members` (0..*) → (1) `roles`: Memberships have a role. - `org_members` (0..*) → (0..1) `invitations`: Memberships may trace their provenance to an invitation. --- ### invitations An **invitation** is a request for someone to join an organizational scope (organization or workspace) with a specific role. Invitations exist independently of both `persons` and `org_members` — the invitee may not yet have an account. Invitee identity is progressive: an invitation begins addressed to an email and acquires richer identity references as its lifecycle progresses. This is a deliberate departure from the exclusive arc pattern used elsewhere in the model — the invitee identity evolves over the invitation's lifecycle rather than being fixed at creation. | Field | Type | Purpose | |-------|------|---------| | `invitation_id` | UUID | Primary key | | `invitee_email` | VARCHAR(255) | Email address of the invitee. NULL if inviting an existing person directly. | | `invitee_person_id` | UUID | FK → identity.persons. NULL if email-only invitation. May be populated when an existing person is discovered for the email. | | `org_id` | UUID | FK → organizations. If this is an organization-scoped invitation. NULL if workspace-scoped. | | `workspace_id` | UUID | FK → workspaces. If this is a workspace-scoped invitation. NULL if org-scoped. | | `role_id` | UUID | FK → roles. The role to be granted upon acceptance. | | `invited_by_person_id` | UUID | FK → identity.persons. Who created the invitation. | | `token_hash` | VARCHAR(255) | Hashed invitation token. Plaintext sent via email/notification. | | `token_prefix` | VARCHAR(10) | First characters for identification (e.g., `mc_inv_d8f...`). | | `message` | TEXT | Optional personal message from the inviter. | | `sent_at` | TIMESTAMPTZ | When the invitation was first sent. | | `last_sent_at` | TIMESTAMPTZ | When most recently sent (initial or resend). | | `send_count` | INTEGER DEFAULT 1 | Total times sent (initial + resends). | | `expires_at` | TIMESTAMPTZ | When the invitation expires. Reset on resend. | | `accepted_at` | TIMESTAMPTZ | When accepted. | | `resolved_person_id` | UUID | FK → identity.persons. The person who accepted. May be newly created (new signup) or pre-existing (existing user). Set upon acceptance. | | `resulting_member_id` | UUID | FK → org_members. If acceptance created a membership. | | `resulting_assignment_id` | UUID | FK → role_assignments. If acceptance created a role assignment. | | `declined_at` | TIMESTAMPTZ | When declined. | | `revoked_at` | TIMESTAMPTZ | When revoked. | | `revoked_by_person_id` | UUID | FK → identity.persons. Who revoked. | | `revocation_reason` | TEXT | Why revoked. | | `status` | VARCHAR(20) | `pending`, `sent`, `accepted`, `declined`, `expired`, `revoked`. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Constraints (invitee — inclusive OR):** ```sql CHECK (invitee_email IS NOT NULL OR invitee_person_id IS NOT NULL) ``` At least one of `invitee_email` or `invitee_person_id` must be set. Both may coexist — this is progressive identity, not an exclusive arc. An invitation starts with the information available at creation time and acquires richer references as the lifecycle progresses. **Constraints (scope — exclusive arc):** ```sql CHECK ( (org_id IS NOT NULL AND workspace_id IS NULL) OR (org_id IS NULL AND workspace_id IS NOT NULL) ) ``` Exactly one of `org_id` or `workspace_id` must be set. An invitation targets one organizational scope. **Uniqueness:** Partial unique indexes enforce at most one active (non-terminal) invitation per (invitee, scope) combination. **Token format convention:** `mc_inv_`. Token is regenerated on resend (old token becomes invalid, new hash stored). **Status lifecycle:** - `pending` → `sent`: Notification delivered. - `sent` → `sent`: Resend (resets `expires_at`, increments `send_count`). - `sent` → `accepted`, `declined`, `expired`, `revoked`: Terminal states, all irrevocable. - `pending` → `expired`, `revoked`: Terminal without delivery. **Relationships:** - `invitations` (0..*) → (0..1) `identity.persons` (via `invitee_person_id`): Invitations may be addressed to a known person. - `invitations` (0..*) → (0..1) `identity.persons` (via `resolved_person_id`): Accepted invitations record who accepted. - `invitations` (0..*) → (1) `identity.persons` (via `invited_by_person_id`): Invitations are created by a person. - Exclusive arc — scope (`org_id` / `workspace_id`): - `invitations` (0..*) → (0..1) `organizations`: Org-scoped invitations target an organization. - `invitations` (0..*) → (0..1) `workspaces`: Workspace-scoped invitations target a workspace. - `invitations` (0..*) → (1) `roles`: Invitations specify a role to grant. - `invitations` (0..*) → (0..1) `org_members` (via `resulting_member_id`): Accepted org-scoped invitations create a membership. - `invitations` (0..*) → (0..1) `role_assignments` (via `resulting_assignment_id`): Accepted workspace-scoped invitations create an assignment. --- ### workspaces A **workspace** is a container for resources within an organization. Workspaces provide isolation, organization, and optionally different access controls within a single organization. | Field | Type | Purpose | |-------|------|---------| | `workspace_id` | UUID | Primary key | | `org_id` | UUID | FK → organizations. The organization this workspace belongs to. | | `name` | VARCHAR(255) | Display name of the workspace. | | `slug` | VARCHAR(100) | URL-safe identifier. Unique within the organization. | | `description` | TEXT | Explanation of what this workspace is for. | | `environment` | VARCHAR(20) | Classification: `development`, `staging`, `production`. Informational. | | `settings` | JSONB | Workspace-level configuration. | | `created_by_person_id` | UUID | FK → identity.persons. Who created this workspace. | | `status` | VARCHAR(20) | State: `active`, `archived`, `deleted`. | | `archived_at` | TIMESTAMPTZ | When archived. | | `archived_by` | UUID | FK → identity.persons. Who archived. | | `deleted_at` | TIMESTAMPTZ | When deleted. | | `deleted_by` | UUID | FK → identity.persons. Who deleted. | | `created_at` | TIMESTAMPTZ | When this workspace was created. | | `updated_at` | TIMESTAMPTZ | Last modification timestamp. | **Constraints:** - `slug` is unique within an organization. The combination of `org_id` and `slug` is unique. **Relationships:** - `workspaces` (0..*) → (1) `organizations`: Workspaces belong to an organization. - `workspaces` (0..*) → (0..1) `identity.persons`: Workspaces track who created them. - `workspaces` (1) → (0..*) `role_assignments`: Workspaces may have scoped role assignments. - `workspaces` (1) → (1..*) `entitlements.pool_assignments`: Workspaces are assigned to one or more resource pools. - `workspaces` (1) → (0..*) `entitlements.usage_events`: Workspaces record resource consumption. - `workspaces` (1) → (0..*) `invitations`: Workspaces may have pending workspace-scoped invitations. --- ### role_assignments A **role_assignment** grants a role to an actor (person or service account) for a specific scope. This enables finer-grained access control than organization-level membership. Organization membership (via `org_members`) grants a baseline role to persons across the organization. Role assignments allow refinements and exceptions: - A person might be a viewer at org level but an admin for a specific workspace. - A contractor might have access to only one workspace, not the whole organization. - A service account might have access to exactly one workspace for CI/CD deployment. - Temporary access can be granted with an expiration date. Service accounts have no org membership — their permissions come entirely from role assignments. This enforces minimum privilege by design. | Field | Type | Purpose | |-------|------|---------| | `assignment_id` | UUID | Primary key | | `person_id` | UUID | FK → identity.persons. If the assignee is a person. NULL if service account. | | `service_account_id` | UUID | FK → service_accounts. If the assignee is a service account. NULL if person. | | `role_id` | UUID | FK → roles. The role being granted. | | `scope_org_id` | UUID | FK → organizations. If this assignment is organization-scoped. NULL otherwise. | | `scope_workspace_id` | UUID | FK → workspaces. If this assignment is workspace-scoped. NULL otherwise. | | `scope_pool_id` | UUID | FK → entitlements.resource_pools. If this assignment is pool-scoped. NULL otherwise. | | `granted_by_person_id` | UUID | FK → identity.persons. Who granted this role. For audit trail. | | `granted_at` | TIMESTAMPTZ | When this role was granted. | | `expires_at` | TIMESTAMPTZ | When this role expires. NULL means no expiration. | | `revoked_at` | TIMESTAMPTZ | When this role was revoked. | | `revoked_by_person_id` | UUID | FK → identity.persons. Who revoked. | | `status` | VARCHAR(20) | State: `active`, `revoked`, `expired`. | | `created_at` | TIMESTAMPTZ | When this assignment was created. | **Constraints (exclusive arc — actor):** ```sql CHECK ( (person_id IS NOT NULL AND service_account_id IS NULL) OR (person_id IS NULL AND service_account_id IS NOT NULL) ) ``` Exactly one of `person_id` or `service_account_id` must be set. **Constraints (exclusive arc — scope):** ```sql CHECK ( (scope_org_id IS NOT NULL AND scope_workspace_id IS NULL AND scope_pool_id IS NULL) OR (scope_org_id IS NULL AND scope_workspace_id IS NOT NULL AND scope_pool_id IS NULL) OR (scope_org_id IS NULL AND scope_workspace_id IS NULL AND scope_pool_id IS NOT NULL) ) ``` Exactly one of `scope_org_id`, `scope_workspace_id`, or `scope_pool_id` must be set. **Uniqueness:** The combination of actor (`person_id` or `service_account_id`), `role_id`, and scope (`scope_org_id` or `scope_workspace_id` or `scope_pool_id`) is unique. An actor cannot be assigned the same role to the same scope twice. **Relationships:** - Exclusive arc — actor (`person_id` / `service_account_id`): - `role_assignments` (0..*) → (0..1) `identity.persons`: Assignments may belong to a person. - `role_assignments` (0..*) → (0..1) `service_accounts`: Assignments may belong to a service account. - `role_assignments` (0..*) → (1) `roles`: Assignments grant a role. - Exclusive arc — scope (`scope_org_id` / `scope_workspace_id` / `scope_pool_id`): - `role_assignments` (0..*) → (0..1) `organizations`: Assignments may be scoped to an organization. - `role_assignments` (0..*) → (0..1) `workspaces`: Assignments may be scoped to a workspace. - `role_assignments` (0..*) → (0..1) `entitlements.resource_pools`: Assignments may be scoped to a pool. --- ## Permission System ### Permission String Format Permissions follow the format `resource:action`. Resources correspond to entity types and management surfaces. Actions are a small, consistent set. **Actions:** | Action | Meaning | |--------|---------| | `view` | Read/list the resource | | `create` | Create new instances | | `edit` | Modify existing instances | | `delete` | Remove instances | | `manage` | Full CRUD plus resource-specific administrative actions | ### Permission Vocabulary **Organization:** `org:view`, `org:edit`, `org:delete`, `org:transfer`, `org.members:view`, `org.members:manage`, `org.service_accounts:view`, `org.service_accounts:manage` **Workspaces:** `workspace:view`, `workspace:create`, `workspace:edit`, `workspace:delete`, `workspace.resources:view`, `workspace.resources:manage` **Entitlements:** `pool:view`, `pool:create`, `pool:edit`, `pool:delete`, `pool.assignments:view`, `pool.assignments:manage`, `pool.ondemand:view`, `pool.ondemand:manage` **Billing:** `billing:view`, `billing:manage`, `billing.subscriptions:view`, `billing.subscriptions:manage`, `billing.purchases:view`, `billing.purchases:create`, `billing.invoices:view` **Grants & Entitlement Rules:** `grants:view`, `grants:manage`, `entitlement_rules:view`, `entitlement_rules:manage` **Administration:** `roles:view`, `roles:manage`, `audit:view`, `tokens:manage` ### System Role Permission Sets **owner:** `org:view`, `org:edit`, `org:delete`, `org:transfer`, `org.members:view`, `org.members:manage`, `org.service_accounts:view`, `org.service_accounts:manage`, `workspace:view`, `workspace:create`, `workspace:edit`, `workspace:delete`, `workspace.resources:view`, `workspace.resources:manage`, `pool:view`, `pool:create`, `pool:edit`, `pool:delete`, `pool.assignments:view`, `pool.assignments:manage`, `pool.ondemand:view`, `pool.ondemand:manage`, `billing:view`, `billing:manage`, `billing.subscriptions:view`, `billing.subscriptions:manage`, `billing.purchases:view`, `billing.purchases:create`, `billing.invoices:view`, `grants:view`, `grants:manage`, `entitlement_rules:view`, `roles:view`, `roles:manage`, `audit:view` **admin:** Same as owner minus `org:delete`, `org:transfer`. **member:** `org:view`, `org.members:view`, `workspace:view`, `workspace.resources:view`, `workspace.resources:manage`, `pool:view`, `pool.assignments:view`, `billing.invoices:view` **billing:** `org:view`, `billing:view`, `billing:manage`, `billing.subscriptions:view`, `billing.subscriptions:manage`, `billing.purchases:view`, `billing.purchases:create`, `billing.invoices:view`, `pool:view`, `pool.ondemand:view` **viewer:** `org:view`, `org.members:view`, `workspace:view`, `workspace.resources:view`, `pool:view`, `pool.assignments:view`, `pool.ondemand:view`, `billing:view`, `billing.subscriptions:view`, `billing.purchases:view`, `billing.invoices:view`, `audit:view` **platform_admin:** All admin permissions plus `entitlement_rules:view`, `entitlement_rules:manage`. Assigned within the platform organization only. ### Permission Resolution Algorithm Permissions are **additive** (union) and **deny-by-default**. An actor can do nothing unless a permission explicitly allows it. There are no deny rules. **For persons:** ``` effective_permissions(person, context) = permissions_from_org_membership(person, context.org_id) ∪ permissions_from_scoped_assignments(person, context.scope) ``` If a person has `workspace.resources:view` from their org membership and `workspace.resources:manage` from a workspace-scoped assignment, they can manage resources in that workspace. **For service accounts:** ``` effective_permissions(service_account, context) = permissions_from_scoped_assignments(service_account, context.scope) ``` Service accounts have no org membership baseline. Their permissions come entirely from role assignments. **For personal access tokens:** ``` effective_permissions(token, context) = effective_permissions(token.person, context) ∩ token.scopes ``` When `scopes` is NULL, the PAT has the person's full permissions. When set, the intersection narrows the token's capabilities. --- ## Entitlements — `entitlements` schema Entitlements answers: "What capabilities are available, who provisioned them, and how are they distributed?" The resource pool is the bridge between the financial world (billing accounts, subscriptions, purchases) and the resource world (workspaces, entitlements, limits, quotas). It decouples "who pays" from "who uses" while maintaining a clear audit trail between them. ### resource_pools A **resource pool** aggregates entitlements from one or more provision sources. Workspaces draw capabilities from pools via pool assignments. Pools are the unit of resource sharing: multiple workspaces can share a pool (shared mode), or a pool can be dedicated to a single workspace. Pools are auto-created for simple cases and explicitly managed for complex ones: - When a billing account is created, a **default pool** is auto-created. - When a workspace is created, it is auto-assigned to its organization's default billing account's default pool. - Administrators can create additional pools for departmental isolation, client-funded projects, or cross-organization sponsorship. | Field | Type | Purpose | |-------|------|---------| | `pool_id` | UUID | Primary key | | `org_id` | UUID | FK → organization.organizations. The organization this pool belongs to (governance scope). | | `name` | VARCHAR(255) | Display name. Auto-generated for default pools. | | `slug` | VARCHAR(100) | URL-safe identifier. Unique within the organization. | | `pool_type` | VARCHAR(20) | `default` (auto-created with billing account), `shared` (explicit multi-workspace), `dedicated` (explicit single-workspace). | | `is_auto_managed` | BOOLEAN | If true, system manages this pool automatically. Default pools are auto-managed. | | `description` | TEXT | What this pool is for. | | `status` | VARCHAR(20) | `active`, `suspended`, `archived`. | | `suspended_at` | TIMESTAMPTZ | When most recently suspended. | | `archived_at` | TIMESTAMPTZ | When archived. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Constraints:** - `slug` is unique within an organization. **Relationships:** - `resource_pools` (0..*) → (1) `organization.organizations`: Pools belong to an organization (governance scope). - `resource_pools` (1) → (0..*) `pool_provisions`: Pools receive provisions from billing sources. - `resource_pools` (1) → (0..*) `pool_assignments`: Pools are assigned to workspaces. - `resource_pools` (1) → (0..*) `boolean_entitlements`: Pools hold binary capabilities. - `resource_pools` (1) → (0..*) `numeric_entitlements`: Pools hold numeric limits and quotas. - `resource_pools` (1) → (0..*) `pool_ondemand_config`: Pools may have on-demand usage configuration. --- ### pool_provisions A **pool_provision** records that a resource pool has been provisioned with capabilities from a specific source. The source may be a subscription (recurring agreement), a purchase (one-time transaction), or a grant (administrative/promotional access). Pool provisions are the uniform interface through which all access mechanisms connect to resource pools. The materialization pipeline queries provisions to determine what capabilities a pool should have. It does not need to know whether the provision came from a subscription, purchase, or grant. Every write to the position machinery — `pool_provisions`, `pool_provision_ladders`, `pool_provision_transitions` — flows through the enclosed five-member conferral function family (see *The conferral primitive* below); direct DML on these three tables is revoked from `entitlements_writer` (Decision 137). | Field | Type | Purpose | |-------|------|---------| | `provision_id` | UUID | Primary key | | `pool_id` | UUID | FK → resource_pools. The pool being provisioned. | | `billing_account_id` | UUID | FK → billing.billing_accounts. The financial source. NULL for grants that target an org or person directly. | | `subscription_id` | UUID | FK → billing.subscriptions. If this provision comes from a subscription. NULL otherwise. | | `purchase_id` | UUID | FK → billing.purchases. If this provision comes from a purchase. NULL otherwise. | | `grant_id` | UUID | FK → grants. If this provision comes from a grant. NULL otherwise. | | `product_id` | UUID | FK → billing.products. NOT NULL. Denormalized at conferral time from the source's product — the fact the position machinery and the reconcile diff dispatch on (Decision 137). With `entitlement_set_id`, one of the provision's two creation-time denormalizations. | | `entitlement_set_id` | UUID | FK → entitlement_sets. Which entitlement set's rules apply. Resolved at conferral time through the product. | | `quantity` | INTEGER | NOT NULL, DEFAULT 1, `CHECK (quantity >= 1)`. A uniform bound across all shapes (Decision 139): units multiply through `entitlement_set_rules.resource_per_unit` at materialization. The position machinery is quantity-blind — the junction carries no quantity column, so a five-seat plan holds *one* position at quantity 5. Where a set has no per-unit rules, a quantity above 1 is inert, not illegal. | | `status` | VARCHAR(20) | `active`, `suspended`, `ended`. Derived from source status. | | `activated_at` | TIMESTAMPTZ | When this provision became active. | | `suspended_at` | TIMESTAMPTZ | When suspended, if applicable. | | `ended_at` | TIMESTAMPTZ | When ended, if applicable. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Constraints (exclusive arc — source):** ```sql CHECK ( (subscription_id IS NOT NULL AND purchase_id IS NULL AND grant_id IS NULL) OR (subscription_id IS NULL AND purchase_id IS NOT NULL AND grant_id IS NULL) OR (subscription_id IS NULL AND purchase_id IS NULL AND grant_id IS NOT NULL) ) ``` Exactly one of `subscription_id`, `purchase_id`, or `grant_id` must be set. **Per-source live uniqueness (Decision 138):** ```sql CREATE UNIQUE INDEX uq_pool_provisions_live_grant ON entitlements.pool_provisions (grant_id) WHERE grant_id IS NOT NULL AND status <> 'ended'; CREATE UNIQUE INDEX uq_pool_provisions_live_purchase ON entitlements.pool_provisions (purchase_id) WHERE purchase_id IS NOT NULL AND status <> 'ended'; CREATE UNIQUE INDEX uq_pool_provisions_live_subscription_product ON entitlements.pool_provisions (subscription_id, product_id) WHERE subscription_id IS NOT NULL AND status <> 'ended'; ``` At most one live provision per grant, per purchase, and per (subscription, product): source→provision resolution is a deterministic join — `end_conferral(grant_id := g)` resolves its target by equality, never by searching for "current on ladder" (the durable finding-#25 fix) — and a retried purchase fulfillment or replayed subscription webhook is idempotent rather than double-provisioning. The indexes are deliberately *status-partial* so a source whose provision has ended can be honestly re-provisioned (`reapply_defaults`); an unconditional unique index would silently foreclose re-application. **Note:** `billing_account_id` is not constrained to the same `org_id` as the pool. This enables cross-organization provisioning. **How each source type creates provisions:** | Source | Trigger | Pool Provision Status | |--------|---------|----------------------| | Subscription (active/trialing) | Subscription item activated | `active` | | Subscription (past_due) | Payment failed, grace period | `active` (configurable) | | Subscription (unpaid/paused) | Retries exhausted or paused | `suspended` | | Subscription (canceled) | Agreement terminated | `ended` | | Purchase (completed) | Transaction completed | `active` (indefinitely) | | Purchase (refunded) | Full refund issued | `ended` | | Grant (active) | Grant valid_from reached | `active` | | Grant (expired) | Grant valid_until reached | `ended` | | Grant (revoked) | Admin revocation | `ended` | **Multi-item subscriptions:** A subscription with three items creates three pool_provision records — one per item. Each references a different product and activates that product's entitlement rules on the pool. **Relationships:** - `pool_provisions` (0..*) → (1) `resource_pools`: Provisions target a pool. - `pool_provisions` (0..*) → (0..1) `billing.billing_accounts`: Provisions may have a financial source. - Exclusive arc — source (`subscription_id` / `purchase_id` / `grant_id`): - `pool_provisions` (0..*) → (0..1) `billing.subscriptions`: Provisions may come from a subscription. - `pool_provisions` (0..*) → (0..1) `billing.purchases`: Provisions may come from a purchase. - `pool_provisions` (0..*) → (0..1) `grants`: Provisions may come from a grant. - `pool_provisions` (0..*) → (1) `billing.products`: Provisions carry their product (`product_id`, denormalized at conferral time). `FK → [billing] products` - `pool_provisions` (1) → (0..*) `pool_provision_ladders`: Active plan-type provisions record their ladder positions. `FK ← pool_provision_ladders.provision_id` --- ### pool_provision_ladders A **pool_provision_ladder** row records that an active pool provision currently occupies a position on a plan ladder. This junction carries the *catalog-shape fact* (this provision occupies this ladder rung) separately from the *commercial fact* (the provision itself), honoring the four-orthogonal-concerns factoring established in Doc 32 §6.1. One provision may produce multiple junction rows when the underlying product belongs to multiple ladders (the bundle case). The GiST exclusion constraint is the primary enforcement mechanism for mutual exclusivity: at any moment, at most one `active` provision per pool may occupy each ladder position. Trial state is carried on the source subscription (`billing.subscriptions.status = 'trialing'`), not on this junction — from the ladder's perspective, a trialing tier is an occupied tier (Doc 34). The denormalized columns (`pool_id`, `status`, `activated_at`, `ended_at`) are kept in sync with their authoritative source in `pool_provisions` by an AFTER UPDATE trigger on `pool_provisions`. The trigger shape: ```sql -- Trigger function: on UPDATE to pool_provisions, propagate pool_id, status, -- activated_at, and ended_at into the provision's junction rows — except rows -- already ended, whose ending is monotonic (Decision 138). CREATE OR REPLACE FUNCTION entitlements.sync_pool_provision_ladders() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE entitlements.pool_provision_ladders SET pool_id = NEW.pool_id, status = NEW.status, activated_at = NEW.activated_at, ended_at = NEW.ended_at WHERE provision_id = NEW.provision_id AND status <> 'ended'; -- junction-row ending is monotonic RETURN NEW; END; $$; CREATE TRIGGER pool_provisions_sync_ladders AFTER UPDATE OF pool_id, status, activated_at, ended_at ON entitlements.pool_provisions FOR EACH ROW EXECUTE FUNCTION entitlements.sync_pool_provision_ladders(); ``` The monotonic guard is the trigger's one Decision-138 amendment: a junction row ended *independently* of its provision — partial ladder retirement, enacted by `align_conferral_shape` — is never resurrected by subsequent provision updates; it exits the partial GiST index and stays historical. Re-occupancy of that ladder is a new conferral (a fresh junction row under the surrogate key below), not a reanimation. All other trigger semantics, including "INSERT is the creator's responsibility" (now discharged inside `confer` and `align_conferral_shape`), are unchanged. ```sql CREATE TABLE entitlements.pool_provision_ladders ( provision_ladder_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), provision_id UUID NOT NULL REFERENCES entitlements.pool_provisions(provision_id) ON DELETE CASCADE, plan_ladder_id UUID NOT NULL REFERENCES billing.plan_ladders(plan_ladder_id), -- Denormalized from pool_provisions; kept in sync by trigger: pool_id UUID NOT NULL, status VARCHAR(20) NOT NULL, activated_at TIMESTAMPTZ NOT NULL, ended_at TIMESTAMPTZ, CONSTRAINT one_active_provision_per_pool_per_ladder EXCLUDE USING gist ( pool_id WITH =, plan_ladder_id WITH =, tstzrange(activated_at, ended_at, '[)') WITH && ) WHERE (status = 'active') ); CREATE UNIQUE INDEX uq_pool_provision_ladders_live ON entitlements.pool_provision_ladders (provision_id, plan_ladder_id) WHERE status <> 'ended'; CREATE INDEX pool_provision_ladders_pool_idx ON entitlements.pool_provision_ladders (pool_id); ``` | Field | Type | Purpose | |-------|------|---------| | `provision_ladder_id` | UUID | Primary key — a surrogate junction identity (Decision 138). Every occupancy *episode*, including historical logically-ended ones, has permanent identity, so re-occupancy of a ladder a provision once held and lost is an ordinary INSERT rather than a collision with a retained ended row. Transitions reference this key. | | `provision_id` | UUID | FK → pool_provisions. The provision occupying this ladder position. | | `plan_ladder_id` | UUID | FK → billing.plan_ladders. Which ladder is occupied. | | `pool_id` | UUID | Denormalized from pool_provisions. Used by the exclusion constraint. | | `status` | VARCHAR(20) | Denormalized from pool_provisions: `active`, `suspended`, or `ended` — the sync trigger has always propagated the parent's ternary domain verbatim (Decision 118; the earlier "binary" description was drift, corrected per Doc 41 §7.3). Supersession and shape-alignment scan *non-ended* (`active` or `suspended`) incumbency, because a suspended row is invisible to the partial GiST index. Trial state is not extended here; it is carried on the source subscription (Doc 34). | | `activated_at` | TIMESTAMPTZ | Denormalized from pool_provisions. Left bound of the exclusion window. | | `ended_at` | TIMESTAMPTZ | Denormalized from pool_provisions. Right bound (open). NULL for currently active rows. | **Constraints:** - `uq_pool_provision_ladders_live` — partial unique index restating the invariant the old natural key carried, over the rows for which it is still true: at most one *non-ended* row per `(provision, ladder)` pair. Historical ended rows are retained, readable, and out of the way. - `one_active_provision_per_pool_per_ladder` — GiST exclusion constraint ensures no two `active` provisions for the same pool overlap on the same ladder within the same time range. Unaffected by the surrogate key: it is keyed on `(pool_id, plan_ladder_id)` over `status = 'active'` ranges. - Junction rows are **ended, never deleted** (Doc 31 §11 Scenario 3 as amended by Doc 41 §10.2): the occupancy FK from transitions forecloses deletion wherever history references the row, and the monotonic trigger guard keeps the logical end stable. **Relationships:** - `pool_provision_ladders` (0..*) → (1) `pool_provisions`: Junction rows are children of a provision. - `pool_provision_ladders` (0..*) → (1) `billing.plan_ladders`: Junction rows reference a ladder. `FK → [billing] plan_ladders` - `pool_provision_ladders` (1) → (0..*) `pool_provision_transitions`: Position transitions reference occupancy episodes. `FK ← pool_provision_transitions.provision_ladder_id` --- ### pool_provision_transitions A **pool_provision_transition** is the audit record of a ladder-position change affecting a pool provision — an initiation, upgrade, downgrade, transfer, or end. The table answers "what tier did this pool hold at time T, who put it there, and why?" — a question that cannot be reconstructed from `pool_provisions` and `pool_provision_ladders` timestamps alone once actor attribution and reason-for-change are required. It complements, rather than replaces, `billing.subscription_changes`: that table remains canonical for commercial mutations of a subscription (status transitions, period boundaries, amount changes); this table is canonical for plan-position history of a pool. Subscription-driven ladder attachments are recorded in both — intentionally — because they answer different questions (GLOSSARY §3). *(This section enters the master reference with v15. The table has existed in the design since the Doc 31 plan-enrollment era and was documented only in the now-retired module projection — a drift the Doc 41 §7.3 reconciliation pass closes; it joins the table count, 59 → 60.)* The table is **narrowed to position audit** (Decision 138): suspension and resumption are carried by `pool_provisions.status` (and, for subscription-driven suspensions, `subscription_changes`); off-ladder lifecycle is answered by the *sources* (`grants` revocation fields, purchase refunds, `subscription_changes`) — position history is the only lifecycle question the sources cannot answer, hence the one table that answers it, and only it. The formerly-permitted off-ladder rows (`plan_ladder_id` NULL) are unwritable going forward. | Field | Type | Purpose | |-------|------|---------| | `transition_id` | UUID | Primary key | | `pool_id` | UUID | FK → resource_pools. The pool whose ladder position changed. Denormalized for query convenience. | | `provision_id` | UUID | FK → pool_provisions. The provision whose ladder attachment this transition records. | | `plan_ladder_id` | UUID | FK → billing.plan_ladders. The ladder on which the transition occurred. NOT NULL going forward (`transitions_are_position_scoped`); NULL only in pre-v15 historical off-ladder rows. | | `provision_ladder_id` | UUID | FK → pool_provision_ladders. The specific occupancy episode this transition records (`transition_requires_occupancy`) — a position claim is expressible only against an occupancy the provision actually held. NOT NULL going forward; NULL only in historical rows. | | `from_rank` | INTEGER | The rank the pool held immediately prior. NULL on `initiate`. | | `to_rank` | INTEGER | The rank the pool holds immediately after. NULL on `end`, and on a `downgrade` that empties the ladder. | | `transition_type` | VARCHAR(50) | `initiate`, `upgrade`, `downgrade`, `transfer`, or `end`. `transfer` (Decision 138) names the rank-equal cross-source case: the same product — hence the same rank, by `UNIQUE (plan_ladder_id, rank)` — superseded by a different source row (the default-then-subscribe sequence; extend-as-replace). | | `actor_type` | VARCHAR(50) | `operator`, `system`, or `webhook`. | | `actor_id` | UUID | FK → identity.persons when `actor_type = 'operator'`. NULL-permitted for `system` and `webhook`. | | `reason` | TEXT | Human-readable explanation or system trigger code (e.g., "subscription_activated", "reapply_defaults"). | | `effective_at` | TIMESTAMPTZ | When the transition took effect in the business sense. | | `created_at` | TIMESTAMPTZ | When the row was recorded. Distinct from `effective_at` to accommodate backdated transitions (forward-dating raises: intent lives in `subscription_scheduled_changes`, Decision 123). | **DDL:** ```sql CREATE TABLE entitlements.pool_provision_transitions ( transition_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), pool_id UUID NOT NULL REFERENCES entitlements.resource_pools(pool_id), provision_id UUID NOT NULL REFERENCES entitlements.pool_provisions(provision_id), plan_ladder_id UUID REFERENCES billing.plan_ladders(plan_ladder_id), provision_ladder_id UUID, from_rank INTEGER, to_rank INTEGER, transition_type VARCHAR(50) NOT NULL CHECK (transition_type IN ('initiate', 'upgrade', 'downgrade', 'transfer', 'end')), actor_type VARCHAR(50) NOT NULL CHECK (actor_type IN ('operator', 'system', 'webhook')), actor_id UUID REFERENCES identity.persons(person_id), reason TEXT, effective_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT operator_actor_requires_id CHECK (actor_type != 'operator' OR actor_id IS NOT NULL) ); ALTER TABLE entitlements.pool_provision_transitions ADD CONSTRAINT transitions_are_position_scoped CHECK (plan_ladder_id IS NOT NULL AND provision_ladder_id IS NOT NULL) NOT VALID, ADD CONSTRAINT transition_rank_shape CHECK ( (transition_type = 'initiate' AND from_rank IS NULL AND to_rank IS NOT NULL) OR (transition_type = 'upgrade' AND from_rank IS NOT NULL AND to_rank IS NOT NULL AND to_rank > from_rank) OR (transition_type = 'downgrade' AND from_rank IS NOT NULL AND (to_rank IS NULL OR to_rank < from_rank)) OR (transition_type = 'transfer' AND from_rank IS NOT NULL AND to_rank IS NOT NULL AND to_rank = from_rank) OR (transition_type = 'end' AND to_rank IS NULL) ) NOT VALID, ADD CONSTRAINT transition_requires_occupancy FOREIGN KEY (provision_ladder_id) REFERENCES entitlements.pool_provision_ladders (provision_ladder_id) NOT VALID; CREATE INDEX pool_provision_transitions_pool_idx ON entitlements.pool_provision_transitions (pool_id, effective_at DESC); CREATE INDEX pool_provision_transitions_provision_idx ON entitlements.pool_provision_transitions (provision_id); ``` The `NOT VALID` posture on the three Decision-138 constraints is deliberate and honest: append-only history is not rewritten to satisfy a narrowed contract. Historical off-ladder rows remain readable; the constraints govern writes from cutover. Validation can complete if and when the audit-log absorption compacts history. **Rank conventions (`transition_rank_shape` enforces these; before v15 they were documentation convention only):** - `initiate`: `from_rank IS NULL`, `to_rank` populated — first occupancy of the ladder, including retroactive induction via `align_conferral_shape`. - `upgrade`: both ranks populated, `to_rank > from_rank` — strict inequality, now unrepresentable otherwise. - `downgrade`: `from_rank` populated; `to_rank < from_rank`, or NULL when the downgrade empties the ladder. - `transfer`: both ranks populated and equal — same product, different source row (cross-source supersession). Minted rather than overloaded onto `upgrade`/`downgrade` because a move that changes no position, only its underlying source, must not be misdescribed as a rank change. - `end`: `to_rank IS NULL` — the attachment ends. A supersession records separate rows (one `end` for every ladder the superseded provision actually occupied — including ladders outside the incoming shape — plus one incoming transition per ladder), never a conflated row. **View-shape contract.** The columns map directly onto a generic `audit.log` projection shaped as `{resource_type, resource_id, actor_type, actor_id, action, occurred_at, recorded_at, payload}`: `resource_type='pool_provision'`, `resource_id=provision_id`, `action=transition_type`, `occurred_at=effective_at`, `recorded_at=created_at`, `payload={pool_id, plan_ladder_id, provision_ladder_id, from_rank, to_rank, reason}`. When the generic audit module graduates from the backlog, absorption is mechanical rather than semantic. **Relationships:** - `pool_provision_transitions` (0..*) → (1) `resource_pools`: Every transition is scoped to a pool. - `pool_provision_transitions` (0..*) → (1) `pool_provisions`: Every transition is attributed to a specific provision. - `pool_provision_transitions` (0..*) → (0..1) `billing.plan_ladders`: Ladder context; NULL only in historical off-ladder rows. `FK → [billing] plan_ladders` - `pool_provision_transitions` (0..*) → (0..1) `pool_provision_ladders`: The occupancy episode the position claim is made against. - `pool_provision_transitions` (0..*) → (0..1) `identity.persons`: Operator transitions reference the responsible person. `FK → [identity] persons` --- ### The conferral primitive (function family) All writes to the position machinery flow through one enclosed, five-member `SECURITY DEFINER` function family (Decision 137), owned by `entitlements_owner`; the application-facing `entitlements_writer` role loses direct DML on the three position-machinery tables: ```sql REVOKE INSERT, UPDATE, DELETE ON entitlements.pool_provisions, entitlements.pool_provision_ladders, entitlements.pool_provision_transitions FROM entitlements_writer; ``` | Member | Signature (abridged) | Contract | |---|---|---| | `confer` | `(pool, product, exactly-one source, quantity, window, attribution) → (provision_id, outcome)` | The single conferral path for all four entry points. Reads the product's shape from `product_conferral_shapes`; derives — never accepts — set, ladder memberships, ranks, supersession, junction rows, and transitions. Supersedes non-ended (`active` or `suspended`) incumbents whole, under the pool lock and GiST backstop; idempotence is source-scoped (`noop` returns the incumbent). Named errors: `conferral_precedes_incumbent`, `conferral_shape_diverged`. | | `end_conferral` | `(source arc OR provision_id, attribution, effective_at) → SETOF provision_id` | Ends the live provisions reached by the source arc — a deterministic join through the per-source live indexes, never "current on ladder." Source-arc resolution finding nothing live returns the empty set (a decree that never delivered is still honestly revocable); an explicit `provision_id` naming an ended provision raises `conferral_already_ended`. Records an `end` transition per occupied (provision, ladder) pair; off-ladder targets record none. | | `sync_source_status` | `(status, exactly-one source, effective_at) → SETOF provision_id` | Propagates `suspended`/`active` onto the source's non-ended provisions. Never writes `'ended'`; never resurrects — resumption after supersession finds no non-ended target and returns the empty set (the cross-source residue, Issue 31). | | `align_conferral_shape` | `(provision_id, attribution, effective_at) → SETOF (plan_ladder_id, action)` | The catalog-evolution member: diffs a live provision's junction rows against its product's current conferral shape; attaches unoccupied ladders (fresh junction row + `initiate`) and logically ends departed ones (+ `end`). Scans non-ended incumbency first and raises `conferral_shape_collision` on a hit. Makes ladder induction and bundle-arm retirement writer-executable operations rather than definer-privileged migrations. | | `update_conferral_bounds` | `(provision_id, quantity?, ended_at?) → provision_id` | The bounds member: adjusts `quantity` (≥ 1) and/or the validity window's upper bound on a live provision. Emits **no** position transition — the position machinery is quantity-blind. The defined path for `subscription_changes.change_type = 'item_quantity_changed'`. A past `ended_at` raises: ending is `end_conferral`'s alone. Operator bounds changes on *grant-sourced* provisions go through extend-as-replace instead (a new lineaged grant), keeping the grants ledger answerable. | Each entry point performs its own bookkeeping and makes the same call: **operator grants** (decree-first: grant row, then `confer`), **subscription reconcile** (a diff keyed by product — missing → `confer`, removed → `end_conferral`, quantity delta → `update_conferral_bounds`), **purchases** (`confer` with `purchases.quantity`), and **system defaults** (a `grant_reason = 'default'` grant row, then `confer`). Expiry is shared, not a fifth entry point: the expiration workflow calls `end_conferral` by source. Entitlement materialization and expiry scheduling remain application concerns running against the returned `provision_id`. Full contracts, postconditions, and the migration sequence: Doc 41 §§5–6, 9. --- ### pool_assignments A **pool_assignment** links a workspace to a resource pool. Workspaces draw their capabilities — boolean entitlements, numeric entitlements, and on-demand access — from their assigned pools. Each workspace has exactly one **primary** pool assignment. Secondary assignments are optional and provide additional capabilities. | Field | Type | Purpose | |-------|------|---------| | `assignment_id` | UUID | Primary key | | `pool_id` | UUID | FK → resource_pools. | | `workspace_id` | UUID | FK → organization.workspaces. | | `is_primary` | BOOLEAN | Whether this is the workspace's primary pool. | | `status` | VARCHAR(20) | `active`, `suspended`. | | `suspended_at` | TIMESTAMPTZ | When most recently suspended. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Constraints:** - Each workspace has exactly one primary pool assignment (`is_primary` = true). - The combination of `pool_id` and `workspace_id` is unique. **Entitlement resolution across multiple pools:** - **Boolean entitlements:** Union semantics. If any assigned pool grants a capability, the workspace has it. - **Numeric entitlements (limits and quotas):** Primary pool is consumed first. If exhausted, secondary pools are checked in assignment order. - **On-demand usage:** Routed through the primary pool's on-demand configuration, with fallback to secondary pools. **Relationships:** - `pool_assignments` (1..*) → (1) `organization.workspaces`: Every workspace has at least one pool assignment. - `pool_assignments` (0..*) → (1) `resource_pools`: Assignments reference a pool. --- ### pool_ondemand_config A **pool_ondemand_config** defines on-demand resource pricing available through a pool. Unlike entitlements (which represent definite grants), on-demand resources are consumed without pre-commitment and billed after the fact. On-demand configuration is a billing-module concern: it defines pricing agreements, not capability grants. When a pool has both a numeric entitlement (quota) for a resource and an on-demand config for the same resource key, the quota is consumed first and the on-demand config handles overage. When a pool has only on-demand config and no entitlement, access is purely post-paid. | Field | Type | Purpose | |-------|------|---------| | `config_id` | UUID | Primary key | | `pool_id` | UUID | FK → resource_pools. | | `resource_key` | VARCHAR(100) | FK → resource_keys. Resource identifier (e.g., `api_calls`, `compute_minutes`, `bandwidth_bytes`). Shared namespace with entitlement resource keys (Decision 103). | | `provision_id` | UUID | FK → pool_provisions. Which provision authorized this on-demand configuration. NULL for manually configured entries. | | `billing_account_id` | UUID | FK → billing.billing_accounts. Who pays for on-demand consumption. | | `rate_plan_id` | UUID | FK → billing.prices. What rate applies to this on-demand resource. | | `soft_limit` | BIGINT | Optional spending alert threshold (smallest currency unit). NULL = no alert. | | `hard_limit` | BIGINT | Optional spending cap (smallest currency unit). NULL = unlimited. | | `status` | VARCHAR(20) | `active`, `suspended`. | | `suspended_at` | TIMESTAMPTZ | When most recently suspended. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Relationships:** - `pool_ondemand_config` (0..*) → (1) `resource_pools`: Config belongs to a pool. - `pool_ondemand_config` (0..*) → (0..1) `pool_provisions`: Config may be lifecycle-coupled to a provision. - `pool_ondemand_config` (0..*) → (1) `billing.billing_accounts`: Config designates a payer. - `pool_ondemand_config` (0..*) → (1) `billing.prices`: Config references a rate plan. - `pool_ondemand_config` (0..*) → (1) `resource_keys`: Resource key references the shared resource namespace. --- ### boolean_entitlements A **boolean entitlement** represents a binary capability that a resource pool has. Boolean entitlements are materialized from `rule_type = 'boolean'` product entitlement rules when pool provisions are activated. Toggle history (when `is_enabled` changes) is tracked exclusively through the audit log. | Field | Type | Purpose | |-------|------|---------| | `entitlement_id` | UUID | Primary key | | `pool_id` | UUID | FK → resource_pools. The pool that has this entitlement. | | `resource_key` | VARCHAR(100) | FK → resource_keys. Machine-readable feature identifier (e.g., `sites`, `custom_domains`, `api_access`). | | `is_enabled` | BOOLEAN | Whether this entitlement is currently active. | | `source_provision_id` | UUID | FK → pool_provisions. Which provision granted this entitlement. | | `valid_from` | TIMESTAMPTZ | When this entitlement became active. | | `valid_until` | TIMESTAMPTZ | When this entitlement expires. NULL means valid until provision ends. | | `metadata` | JSONB | Additional entitlement attributes. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **"Last funder standing" logic:** When a provision ends, the system checks whether any other active provision on the same pool grants the same boolean entitlement. If yes, the entitlement remains active. If no, it is deactivated. **Relationships:** - `boolean_entitlements` (0..*) → (1) `resource_pools`: Boolean entitlements belong to a pool. - `boolean_entitlements` (0..*) → (1) `pool_provisions`: Boolean entitlements are granted by a provision. - `boolean_entitlements` (0..*) → (1) `resource_keys`: Resource key references the shared resource namespace. --- ### numeric_entitlements A **numeric entitlement** represents a valued capability on a resource pool — either a static allocation (limit) or a renewable consumption budget (quota). The `numeric_entitlements` table stores the definition (what the limit is); mutable usage state is separated into `numeric_entitlement_usage`. Provenance — which provisions contribute to the effective limit — is tracked in `numeric_entitlement_contributions`. | Field | Type | Purpose | |-------|------|---------| | `entitlement_id` | UUID | Primary key | | `pool_id` | UUID | FK → resource_pools. The pool this entitlement belongs to. | | `entitlement_type` | VARCHAR(20) | `limit` (static allocation, no reset) or `quota` (renewable consumption rights, has reset period). | | `resource_key` | VARCHAR(100) | FK → resource_keys. Machine-readable identifier (e.g., `workspaces`, `storage_bytes`, `api_calls`). | | `resource_limit` | BIGINT | Effective limit. -1 means unlimited. Denormalized; recomputed from `numeric_entitlement_contributions` by the materialization pipeline. | | `reset_period` | VARCHAR(20) | For quotas: `daily`, `monthly`, `yearly`. NULL for limits. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Relationships:** - `numeric_entitlements` (0..*) → (1) `resource_pools`: Numeric entitlements belong to a pool. - `numeric_entitlements` (1) → (0..*) `numeric_entitlement_contributions`: Numeric entitlements receive contributions from provisions. - `numeric_entitlements` (1) → (1) `numeric_entitlement_usage`: Each numeric entitlement has a corresponding usage state record. - `numeric_entitlements` (0..*) → (1) `resource_keys`: Resource key references the shared resource namespace. --- ### numeric_entitlement_contributions A **numeric entitlement contribution** records a single provision's contribution to a numeric entitlement's effective limit. When a pool has multiple active provisions that grant the same resource, each provision creates a separate contribution. The entitlement's `resource_limit` is derived from its contributions using the applicable stacking policy. | Field | Type | Purpose | |-------|------|---------| | `contribution_id` | UUID | Primary key | | `entitlement_id` | UUID | FK → numeric_entitlements. The entitlement receiving this contribution. | | `provision_id` | UUID | FK → pool_provisions. The provision contributing. | | `contributed_value` | BIGINT | The value this provision contributes. For `resource_per_unit` rules, this is `resource_value × provision.quantity`. | | `stacking_policy` | VARCHAR(20) | The stacking policy that applies to this contribution. Denormalized from the rule at materialization time. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Constraints:** - The combination of `entitlement_id` and `provision_id` is unique. - Index on `entitlement_id` for the aggregation query. **Effective limit computation:** For `additive` stacking, the effective limit is `SUM(contributed_value)`. For `maximum`, it is `MAX(contributed_value)`. For `replace`, it is the contribution from the most recently activated provision. **Relationships:** - `numeric_entitlement_contributions` (0..*) → (1) `numeric_entitlements`: Contributions target a numeric entitlement. - `numeric_entitlement_contributions` (0..*) → (1) `pool_provisions`: Contributions originate from a provision. --- ### numeric_entitlement_usage A **numeric entitlement usage** record holds the mutable consumption state for a numeric entitlement, separated from the entitlement definition to eliminate write contention between the materialization pipeline (which updates definitions) and the usage increment path (which updates consumption). | Field | Type | Purpose | |-------|------|---------| | `usage_id` | UUID | Primary key | | `entitlement_id` | UUID | FK → numeric_entitlements. The entitlement definition this usage tracks. | | `pool_id` | UUID | FK → resource_pools. Denormalized from entitlement for indexed lookups. | | `resource_key` | VARCHAR(100) | FK → resource_keys. Denormalized from entitlement for indexed lookups without join. | | `current_usage` | BIGINT | Current usage against this entitlement in the current period. | | `current_period_start` | TIMESTAMPTZ | Start of current reset period. NULL for limits (non-resetting). | | `current_period_end` | TIMESTAMPTZ | End of current reset period. NULL for limits (non-resetting). | | `last_reset_at` | TIMESTAMPTZ | When usage was last reset. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Constraints:** - `entitlement_id` is unique (1:1 with numeric_entitlements, in a separate table for write isolation). - Composite index on `(pool_id, resource_key)` for the hot-path lookup. **Usage increment:** `UPDATE numeric_entitlement_usage SET current_usage = current_usage + $increment WHERE entitlement_id = $id AND current_usage + $increment <= (SELECT resource_limit FROM numeric_entitlements WHERE entitlement_id = $id)`. If no rows are returned, the limit would be exceeded. **Relationships:** - `numeric_entitlement_usage` (1) → (1) `numeric_entitlements`: Each usage record tracks one numeric entitlement. --- ### usage_events A **usage_event** records consumption of a resource by a workspace. Usage events are append-only observational data — they record *what happened*, not *what should be charged*. All consumption paths — quota decrements, credit deductions, and on-demand charges — generate usage events. Billing attribution flows through `pending_charge_usage_events` → `pending_charges` → `invoice_line_items` → `invoices`, preserving the append-only semantics of the events themselves. | Field | Type | Purpose | |-------|------|---------| | `event_id` | UUID | Primary key | | `workspace_id` | UUID | FK → organization.workspaces. Where consumption occurred. | | `pool_id` | UUID | FK → resource_pools. Which pool this was routed through. | | `resource_key` | VARCHAR(100) | FK → resource_keys. What was consumed (e.g., `api_calls`, `compute_minutes`). | | `quantity` | BIGINT | How much was consumed (in the resource's unit). | | `unit` | VARCHAR(50) | Unit of measurement (e.g., `call`, `second`, `byte`). | | `event_timestamp` | TIMESTAMPTZ | When consumption occurred. | | `resolution_path` | VARCHAR(20) | How this consumption was resolved: `quota`, `credit`, or `on_demand`. | | `billing_account_id` | UUID | FK → billing.billing_accounts. Resolved payer at time of event. NULL for quota-resolved events with no direct billing association. | | `metadata` | JSONB | Additional event attributes. | | `created_at` | TIMESTAMPTZ | | **Relationships:** - `usage_events` (0..*) → (1) `organization.workspaces`: Events occur in a workspace. - `usage_events` (0..*) → (1) `resource_pools`: Events are routed through a pool. - `usage_events` (0..*) → (0..1) `billing.billing_accounts`: Events may have a resolved payer (NULL for quota-resolved events). - `usage_events` (0..*) → (0..*) `billing.pending_charge_usage_events`: Events may be linked to the pending charges they contributed to. - `usage_events` (0..*) → (0..*) `billing.credit_transactions`: Events may be linked to real-time credit deductions (prepaid topology). - `usage_events` (0..*) → (1) `resource_keys`: Resource key references the shared resource namespace. --- ### resource_keys A **resource key** is a canonical identifier for a grantable or consumable resource. The `resource_keys` table is the shared namespace that both the entitlement system and the on-demand billing system reference. By making both sides FK into this table, a namespace mismatch (e.g., `api-calls` vs. `api_calls`) produces an FK violation instead of a silent failure in the overage bridge. Provider ownership is expressed by the nullable `provider` column (Decision 130), not by the key string: keys stay bare and globally unique, the dotted `.` form is retired from the contract, and the key serves three roles kept apart — the bare key for machines (the FK target everywhere), the `provider` column for grouping and filtering, and `display_name` for humans. The namespacing rule (Decision 130 §6.2, with boot registration as the backstop): the platform owns the unprefixed namespace (`seats`, `storage_bytes`); every provider-owned key is prefixed with its owner's slug (`fedwiki_sites`, `discourse_posting`), so cross-provider collision is structurally impossible. | Field | Type | Purpose | |-------|------|---------| | `resource_key` | VARCHAR(100) | Primary key. Machine-readable identifier (e.g., `api_calls`, `custom_domains`, `storage_bytes`). | | `provider` | VARCHAR(50) | FK → billing.providers. NULL for platform-owned resources. Encodes **fact-source cardinality** (Decision 130 §6.2): set ⟺ the resource's usage facts originate from exactly one provider; NULL ⟺ facts are pooled across providers or platform-native. Metering is an independent third axis — NULL-and-metered (a platform-owned quota summed from providers' reported facts) is deliberately expressible. | | `display_name` | VARCHAR(255) | Human-readable name ("Storage (pooled)", "Storage — FedWiki") — what a UI shows; never a dotted string. | | `description` | TEXT | What this resource represents. | | `unit` | VARCHAR(50) | Unit of measurement (e.g., `call`, `byte`, `seat`). NULL for boolean resources. | | `created_at` | TIMESTAMPTZ | | **Relationships:** - `resource_keys` (1) → (0..*) `entitlement_set_rules`: Rules reference resources. - `resource_keys` (1) → (0..*) `boolean_entitlements`: Boolean entitlements reference resources. - `resource_keys` (1) → (0..*) `numeric_entitlements`: Numeric entitlements reference resources. - `resource_keys` (1) → (0..*) `numeric_entitlement_usage`: Usage records reference resources (denormalized). - `resource_keys` (1) → (0..*) `pool_ondemand_config`: On-demand configs reference resources. - `resource_keys` (1) → (0..*) `usage_events`: Usage events reference resources. - `resource_keys` (0..*) → (0..1) `billing.providers`: Provider-owned keys name their owner. `FK → [billing] providers` --- ### grants A **grant** represents administrative, promotional, or exceptional access that is not backed by a commercial transaction. No money changes hands. Access is conferred by decree of an authorized person — or, for system-issued baselines, by no person at all. Grants are the non-financial provisioning source in the resource pool system — they answer "what does this entity have?" rather than "who pays?" (Decision 106). A grant names a **product** (`product_id` NOT NULL; Decision 137). The former direct-set arm (`entitlement_set_id`) is dropped: a bare-set grant is precisely a conferral whose catalog shape is undefined — no ladder answer, no lifecycle status, no display surface — which is how it silently escaped the plan-cardinality invariant. Ad hoc sets are conferred by minting an internal **wrap product** (`is_public = FALSE`, minted as `published`, deliberately priceless under the amended publication gate) that wraps the set: capability that enters a pool must pass through the catalog, where conferral can interrogate its shape. Grants confer through the uniform conferral primitive (`entitlements.confer`), **decree-first**: the grant row is written even when conferral no-ops, and revocation of a never-delivering decree succeeds with zero provision effect. The grant is immutable ledger — `status` carries lifecycle only, there is no `'superseded'` value, and delivery state is answered by joining `pool_provisions`, never read off the grant row. | Field | Type | Purpose | |-------|------|---------| | `grant_id` | UUID | Primary key | | `product_id` | UUID | FK → billing.products. NOT NULL. The product whose capability this grant confers; the conferred entitlement set resolves through it. Ad hoc sets are conferred via an internal wrap product (Decision 137). | | `extends_grant_id` | UUID | FK → grants. Extension lineage (extend-as-replace): a grant that replaces a prior grant with new bounds names it here, and the conferral primitive supersedes the prior provision on this evidence rather than treating the new decree as a duplicate. NULL for non-extension grants. No quantity stacking across lineage. | | `granted_to_billing_account_id` | UUID | FK → billing.billing_accounts. If the recipient is a billing account. NULL otherwise. | | `granted_to_org_id` | UUID | FK → organization.organizations. If the recipient is an organization. NULL otherwise. | | `granted_to_person_id` | UUID | FK → identity.persons. If the recipient is a person. NULL otherwise. | | `granted_by_person_id` | UUID | FK → identity.persons. Who authorized this grant. NULL exactly when `grant_reason = 'default'` — system-authored baselines have no human author (see `default_iff_system_authored`). | | `grant_reason` | VARCHAR(50) | NOT NULL. Classification: `manual`, `default`, `evaluation`, `promotional`, `complimentary`, `sponsored`, `board_decision`, `legacy` (Decision 139). No `'other'` — the free-text audit note belongs in `description`, and the CHECK keeps operator free text structurally out of the reason field. No `'trial'` or `'trial_extension'` — "trial" is vehicle-owned vocabulary (Decision 119 as partially amended); `'evaluation'` names operator-conferred time-boxed assessment access, and a grant extending a subscription's trial is an ordinary time-boxed grant classified by its substantive reason. | | `description` | TEXT | Human-readable explanation — the audit note. | | `valid_from` | TIMESTAMPTZ | When the grant becomes effective. | | `valid_until` | TIMESTAMPTZ | When the grant expires. NULL for indefinite. | | `status` | VARCHAR(20) | `active`, `expired`, `revoked`. | | `revoked_at` | TIMESTAMPTZ | When the grant was revoked, if applicable. | | `revoked_by_person_id` | UUID | FK → identity.persons. Who revoked it. | | `revocation_reason` | TEXT | Why it was revoked. | | `metadata` | JSONB | Additional grant attributes. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Constraints (exclusive arc — recipient):** ```sql CHECK ( (granted_to_billing_account_id IS NOT NULL AND granted_to_org_id IS NULL AND granted_to_person_id IS NULL) OR (granted_to_billing_account_id IS NULL AND granted_to_org_id IS NOT NULL AND granted_to_person_id IS NULL) OR (granted_to_billing_account_id IS NULL AND granted_to_org_id IS NULL AND granted_to_person_id IS NOT NULL) ) ``` Exactly one of `granted_to_billing_account_id`, `granted_to_org_id`, or `granted_to_person_id` must be set. **Constraints (reason domain and structural authorship — Decision 139):** ```sql ALTER TABLE entitlements.grants ADD CONSTRAINT grant_reason_domain CHECK (grant_reason IN ('manual', 'default', 'evaluation', 'promotional', 'complimentary', 'sponsored', 'board_decision', 'legacy')), ADD CONSTRAINT default_iff_system_authored CHECK ((grant_reason = 'default') = (granted_by_person_id IS NULL)); ``` The NOT NULL on `grant_reason` is load-bearing, not hygiene: under three-valued logic both CHECKs evaluate to NULL for a NULL reason — which a CHECK treats as permissive — so without it a row with `grant_reason NULL, granted_by_person_id NULL` would pass both constraints: an unauthored grant that is not classified `'default'`, precisely the state the biconditional makes unrepresentable. The biconditional structurally defines `'default'`: a default grant has no human author, and an unauthored grant can only be a default — an operator typing "default" and creating an unrevokable grant is unrepresentable. If a future automated creator needs a non-default reason, the constraint relaxes to the forward implication — a named, one-line amendment (Doc 41 §7.1). **Relationships:** - `grants` (0..*) → (1) `billing.products`: FK → [billing] products. A grant names the product whose capability it confers. - `grants` (0..*) → (0..1) `grants` (via `extends_grant_id`): Extension lineage (extend-as-replace). - Exclusive arc — recipient (`granted_to_billing_account_id` / `granted_to_org_id` / `granted_to_person_id`): - `grants` (0..*) → (0..1) `billing.billing_accounts`: FK → [billing] billing_accounts. Grants may target a billing account. - `grants` (0..*) → (0..1) `organization.organizations`: FK → [organization] organizations. Grants may target an organization. - `grants` (0..*) → (0..1) `identity.persons` (via `granted_to_person_id`): FK → [identity] persons. Grants may target a person. - `grants` (0..*) → (1) `identity.persons` (via `granted_by_person_id`): FK → [identity] persons. Grants are authorized by a person. - `grants` (1) → (0..*) `pool_provisions`: Grants create pool provisions. --- ### entitlement_sets An **entitlement set** is a named, reusable collection of entitlement rules — the canonical unit of capability specification. Entitlement sets decouple "what capabilities are conferred" from "how they are sold" (products) and "why they were given" (grants). Products reference a set to declare what a purchase, subscription, or grant confers; the former direct set arm on grants was dropped by Decision 137 — ad hoc capability is conferred by minting an internal wrap product that carries the set through the catalog, so conferral can always interrogate a catalog shape. | Field | Type | Purpose | |-------|------|---------| | `set_id` | UUID | Primary key | | `name` | VARCHAR(255) | Internal name (e.g., "Pro Capabilities", "API Access"). | | `description` | TEXT | What this set represents. | | `is_active` | BOOLEAN | Whether this set can be referenced by new products (including wrap products). | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Relationships:** - `entitlement_sets` (1) → (0..*) `entitlement_set_rules`: A set contains rules. - `entitlement_sets` (1) → (0..*) `billing.products`: Products reference a set. `FK ← [billing] products.entitlement_set_id` - `entitlement_sets` (1) → (0..*) `pool_provisions`: Provisions always resolve to a set (denormalized at conferral time, through the product). --- ### entitlement_set_rules An **entitlement set rule** defines a single capability within an entitlement set. Formerly `product_entitlement_rules` (Decision 107); re-parented from `product_id` to `set_id`. This table uses single-table inheritance: the `rule_type` discriminator determines which set of fields is populated. Four rule types exist: `boolean` (binary capabilities), `limit` (static numeric allocations), `quota` (renewable consumption budgets), and `credit` (included prepaid credits). | Field | Type | Purpose | |-------|------|---------| | `rule_id` | UUID | Primary key | | `set_id` | UUID | FK → entitlement_sets. The set this rule belongs to. | | `rule_type` | VARCHAR(20) | `boolean`, `limit`, `quota`, or `credit`. | | `resource_key` | VARCHAR(100) | FK → resource_keys. For boolean, limit, and quota rules: the resource identifier. NULL for credit rules. | | `resource_value` | BIGINT | For limit and quota rules: the amount granted. -1 for unlimited. NULL for boolean and credit rules. | | `resource_per_unit` | BOOLEAN | For limit and quota rules: whether the value is multiplied by the provision's quantity. NULL for boolean and credit rules. | | `stacking_policy` | VARCHAR(20) | For limit and quota rules: how this entitlement combines with contributions from other provisions. `additive` (sum all contributions), `maximum` (take the highest), `replace` (most recent provision wins). NULL for boolean and credit rules. Default: `additive`. | | `reset_period` | VARCHAR(20) | For quota rules: `daily`, `monthly`, or `yearly`. Required for quota rules, NULL for all other rule types. | | `tier_reduction_policy` | VARCHAR(20) NOT NULL DEFAULT 'defer' | How a downgrade below current allocation/usage is reconciled when this rule is re-materialized: `block` (disallow the reducing change), `defer` (grandfather the prior allocation to period or term end), `clamp` (lower the ceiling but preserve existing usage above it until it drains), or `force_reduce` (apply immediately, even below current usage). Meaningful for limit and quota rules; inert for boolean and credit rules. Distinct from §6.1 overage, which concerns forward post-paid consumption (Decision 125). `CHECK (tier_reduction_policy IN ('block','defer','clamp','force_reduce'))`. | | `credit_amount` | INTEGER | For credit rules: the credit amount in smallest currency unit. NULL for all other rule types. | | `credit_currency` | VARCHAR(3) | For credit rules: the currency (ISO 4217). NULL for all other rule types. | | `description` | TEXT | Human-readable explanation of what this rule grants. | | `is_active` | BOOLEAN | Whether this rule is currently in effect. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Constraints:** ```sql CHECK ( (rule_type = 'boolean' AND resource_key IS NOT NULL AND resource_value IS NULL AND stacking_policy IS NULL AND reset_period IS NULL AND resource_per_unit IS NULL AND credit_amount IS NULL AND credit_currency IS NULL) OR (rule_type = 'limit' AND resource_key IS NOT NULL AND resource_value IS NOT NULL AND reset_period IS NULL AND credit_amount IS NULL AND credit_currency IS NULL) OR (rule_type = 'quota' AND resource_key IS NOT NULL AND resource_value IS NOT NULL AND reset_period IS NOT NULL AND credit_amount IS NULL AND credit_currency IS NULL) OR (rule_type = 'credit' AND credit_amount IS NOT NULL AND credit_currency IS NOT NULL AND resource_key IS NULL AND resource_value IS NULL AND stacking_policy IS NULL AND reset_period IS NULL AND resource_per_unit IS NULL) ) ``` **Relationships:** - `entitlement_set_rules` (0..*) → (1) `entitlement_sets`: Rules belong to a set. (intra-module) - `entitlement_set_rules` (0..*) → (0..1) `resource_keys`: Boolean, limit, and quota rules reference a resource key. (intra-module) - Consumed by the materialization pipeline when pool provisions activate or deactivate. --- ## Billing & Value — `billing` schema Billing & Value answers: "Who pays, what do they pay for?" ### billing_accounts A **billing_account** is the entity that pays. It holds payment method references, receives invoices, and accumulates patronage. A billing account is separate from the organization that owns it. This separation matters because: - An organization may have multiple billing accounts (e.g., for different cost centers, clients, or funding sources). - Billing contacts and addresses may differ from organization contacts. - The payer (billing account) is the patron for cooperative purposes, which may differ from organizational structure. - A billing account can provision pools in other organizations (the GCP orthogonality principle). | Field | Type | Purpose | |-------|------|---------| | `billing_account_id` | UUID | Primary key | | `org_id` | UUID | FK → organization.organizations. The organization that owns this billing account. | | `name` | VARCHAR(255) | Display name (e.g., "Primary", "Marketing Budget", "Client: Acme"). | | `is_default` | BOOLEAN | Whether this is the default billing account for the organization. Each org has exactly one default. | | `billing_email` | VARCHAR(255) | Where to send invoices. May differ from user or org email. | | `billing_name` | VARCHAR(255) | Name to appear on invoices. | | `billing_address_line1` | VARCHAR(255) | Billing street address. | | `billing_address_line2` | VARCHAR(255) | Billing address line 2. | | `billing_city` | VARCHAR(100) | Billing city. | | `billing_state` | VARCHAR(100) | Billing state/province. | | `billing_postal_code` | VARCHAR(20) | Billing postal code. | | `billing_country` | VARCHAR(2) | Billing country (ISO 3166-1 alpha-2). | | `tax_id` | VARCHAR(100) | Tax identifier for this billing entity (for invoices, receipts). | | `tax_exempt` | BOOLEAN | Whether this billing account is tax exempt. | | `tax_exempt_reason` | VARCHAR(255) | Reason for tax exemption if applicable. | | `currency` | VARCHAR(3) | Preferred currency for billing (ISO 4217). | | `platform_billing_mode` | VARCHAR(20) | Platform-enforced billing mode. `postpaid`, `prepaid_only`, or `hybrid`. Default: `hybrid`. | | `customer_billing_mode` | VARCHAR(20) | Customer-selected billing mode preference. `prepaid_only` or `hybrid`. Default: `hybrid`. | | `auto_recharge_enabled` | BOOLEAN DEFAULT FALSE | Whether auto-recharge of credit balance is active. | | `auto_recharge_threshold` | INTEGER | Credit balance below which a recharge is triggered (smallest currency unit). | | `auto_recharge_amount` | INTEGER | Amount to purchase when auto-recharge triggers (smallest currency unit). | | `default_credit_disposition` | VARCHAR(10) NOT NULL DEFAULT 'ledger' | Account-level default routing for proration credits arising from mid-period downgrades: `ledger` (retain as credit balance) or `cash` (refund to original payment instrument). A scheduled change may override per-occurrence; NULL there inherits this default (Decision 124). `CHECK (default_credit_disposition IN ('ledger','cash'))`. | | `settings` | JSONB | Billing preferences and configuration. | | `status` | VARCHAR(20) | State: `active`, `suspended`, `closed`. | | `suspended_at` | TIMESTAMPTZ | When most recently suspended. | | `suspended_by` | UUID | FK → identity.persons. Who suspended. | | `closed_at` | TIMESTAMPTZ | When closed. | | `closed_by` | UUID | FK → identity.persons. Who closed. | | `created_at` | TIMESTAMPTZ | When this billing account was created. | | `updated_at` | TIMESTAMPTZ | Last modification timestamp. | **Constraints:** - Each organization has exactly one default billing account (`is_default` = TRUE). **Relationships:** - `billing_accounts` (0..*) → (1) `organization.organizations`: Billing accounts belong to an organization. - `billing_accounts` (1) → (0..*) `subscriptions`: Billing accounts have subscriptions. - `billing_accounts` (1) → (0..*) `purchases`: Billing accounts make purchases. - `billing_accounts` (1) → (0..*) `invoices`: Billing accounts receive invoices. - `billing_accounts` (1) → (0..*) `payments`: Billing accounts make payments. - `billing_accounts` (1) → (0..*) `entitlements.pool_provisions`: Billing accounts may fund pool provisions. - `billing_accounts` (1) → (0..*) `entitlements.pool_ondemand_config`: Billing accounts may be on-demand usage payers. - `billing_accounts` (1) → (0..*) `credit_grants`: Billing accounts may hold prepaid credit grants. - `billing_accounts` (1) → (0..*) `payment_methods`: Billing accounts have payment methods on file. - `billing_accounts` (1) → (0..*) `pending_charges`: Billing accounts accumulate pending charges. --- ### products A **product** represents something the cooperative sells. It is the commercial instrument — name, description, display category, visibility, and pricing — that wraps an entitlement set. The product defines how capabilities are packaged and sold; the entitlement set defines what capabilities are conferred. A product carries no unitary "kind": the questions the old kind taxonomy conflated are answered per consumer, each from its own structural surface (Decision 134) — position from `plan_ladder_tiers`, billing and consumption shape per price from `prices`, presentation from `display_category`, diagnostics from the `product_shape` view. | Field | Type | Purpose | |-------|------|---------| | `product_id` | UUID | Primary key | | `entitlement_set_id` | UUID | FK → entitlements.entitlement_sets. The entitlement set this product confers. | | `name` | VARCHAR(255) | Display name (e.g., "Pro Plan", "Premium Support", "Custom Domain"). | | `description` | TEXT | Marketing/explanatory description. | | `display_category` | VARCHAR(50), NULLable | Presentation-layer grouping (storefront sections, operator filters). Successor to `product_type`, renamed and demoted (Decision 136): carries no behavioral weight — no schema or application machinery may branch on it — and no domain CHECK constrains it, because the schema no longer vouches for its meaning. NULL is ordinary (drafts; internal wrap products). The publication gate is structural, read from `product_shape`: published requires `set_present`, and — for `is_public` products only — `billing_shape <> 'unpriced'` (Doc 41 §6.7). | | `lifecycle_status` | VARCHAR(20) | One of `draft`, `published`, `retired`. Orthogonal to kind: carries the editorial-intent axis (is this product definition complete and offered?) separately from the structural-kind axis (what role does the product play in the catalog?). Defaults to `draft`. | | `metadata` | JSONB | Additional product attributes. | | `is_active` | BOOLEAN | Whether this product can be purchased. | | `is_public` | BOOLEAN | Whether this product appears on public pricing pages. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Constraints:** ```sql ALTER TABLE billing.products ADD CONSTRAINT lifecycle_status_domain CHECK (lifecycle_status IN ('draft', 'published', 'retired')); COMMENT ON COLUMN billing.products.display_category IS 'Presentation-layer grouping (storefront sections, operator filters). ' 'Carries no behavioral weight; no schema or application machinery may branch on it.'; ``` No domain CHECK constrains `display_category` (Decision 136): the column is presentation-owned, and constraining its vocabulary would imply the schema vouches for its meaning. The `product_type_non_plan_domain` CHECK fell with the rename; the still-earlier bidirectional `product_type_plan_iff_ladder_member` CHECK (Doc 31 Amendment #1) was retired under Amendment #3. A product's status as a plan is a structural fact — the presence of rows in `plan_ladder_tiers` — and the `NonPlanTiers` divergence class is unrepresentable, because no label remains whose value purports to describe structure (Doc 41 §6.7, acceptance scenario 8). Should the presentation layer come to demand integrity for its vocabulary, the compatible mechanism is a presentation-owned reference table, not a resurrected domain CHECK. **Read surfaces (Decision 134):** the former `product_kinds` view is dropped — its governing question ("what kind is this product?") dissolved into per-consumer questions, each answered by its own surface, one authoritative read path per consumer. *Conferral's read path* — the only structural facts the conferral primitive consumes: ```sql CREATE VIEW billing.product_conferral_shapes AS SELECT p.product_id, p.entitlement_set_id, p.lifecycle_status, COALESCE(t.ladder_count, 0) AS ladder_count, t.ladder_ids AS ladder_ids, -- UUID[], NULL when off-ladder t.ladder_ranks AS ladder_ranks -- INTEGER[], positionally aligned with ladder_ids FROM billing.products p LEFT JOIN ( SELECT product_id, COUNT(*) AS ladder_count, array_agg(plan_ladder_id ORDER BY plan_ladder_id) AS ladder_ids, array_agg(rank ORDER BY plan_ladder_id) AS ladder_ranks FROM billing.plan_ladder_tiers GROUP BY product_id ) t USING (product_id); ``` *The diagnostics projection* — readiness checks, operator dashboards, catalog audit; each dimension in its own column with `'mixed'` as a first-class answer, and no cross-dimension precedence anywhere: ```sql CREATE VIEW billing.product_shape AS SELECT p.product_id, p.lifecycle_status, p.display_category, p.is_public, (p.entitlement_set_id IS NOT NULL) AS set_present, COALESCE(t.ladder_count, 0) AS ladder_count, CASE WHEN COALESCE(pr.price_count, 0) = 0 THEN 'unpriced' WHEN pr.recurring_count = pr.price_count THEN 'recurring' WHEN pr.recurring_count = 0 THEN 'one_time' ELSE 'mixed' END AS billing_shape, CASE WHEN COALESCE(pr.metered_count, 0) = 0 AND COALESCE(pr.licensed_count, 0) = 0 THEN 'none' WHEN COALESCE(pr.licensed_count, 0) = 0 THEN 'metered' WHEN COALESCE(pr.metered_count, 0) = 0 THEN 'licensed' ELSE 'mixed' END AS consumption_shape FROM billing.products p LEFT JOIN ( SELECT product_id, COUNT(*) AS ladder_count FROM billing.plan_ladder_tiers GROUP BY product_id ) t USING (product_id) LEFT JOIN ( SELECT product_id, COUNT(*) AS price_count, COUNT(*) FILTER (WHERE recurring_interval IS NOT NULL) AS recurring_count, COUNT(*) FILTER (WHERE usage_type = 'metered') AS metered_count, COUNT(*) FILTER (WHERE usage_type = 'licensed') AS licensed_count FROM billing.prices WHERE is_active GROUP BY product_id ) pr USING (product_id); ``` The full read-path assignment, one per consumer: | Consumer | Authoritative read path | |---|---| | Conferral primitive | `billing.product_conferral_shapes` | | Invoicing / pending-charges pipeline | `billing.prices` per-price columns (`recurring_interval`, `usage_type`) | | Presentation (storefront sections, member-UI filters) | `billing.products.display_category` | | Diagnostics / readiness / catalog audit | `billing.product_shape` | A product with tier rows *and* a non-recurring price is a **lifetime-license tier** — legal co-occurrence, explicitly classified: `product_shape` reports `ladder_count >= 1` and `billing_shape = 'one_time'` (or `'mixed'`) side by side, and conferral reads only the ladder facts. No `CASE` precedence exists to resolve — or hide — anything (Decision 138, Doc 41 §6.4). **Relationships:** - `products` (0..*) → (1) `entitlements.entitlement_sets`: Products reference an entitlement set. `FK → [entitlements] entitlement_sets` - `products` (0..*) → (0..*) `plan_ladders` (via `plan_ladder_tiers`): Plan products occupy one or more ladders through the junction; the junction is the sole representation of ladder membership for all topologies. - `products` (1) → (0..*) `prices`: Products have one or more prices. - `products` (1) → (0..*) `entitlements.grants`: Grants may optionally reference a product. `FK ← [entitlements] grants.product_id` --- ### prices A **price** defines the commercial terms for purchasing a product: how much, in what currency, on what schedule, with what trial period. | Field | Type | Purpose | |-------|------|---------| | `price_id` | UUID | Primary key | | `product_id` | UUID | FK → products. The product this price is for. | | `nickname` | VARCHAR(255) | Display name (e.g., "Monthly", "Annual", "Starter"). | | `currency` | VARCHAR(3) | Currency code (ISO 4217). | | `unit_amount` | INTEGER | Price in smallest currency unit (e.g., cents for USD). | | `billing_scheme` | VARCHAR(20) | How pricing works: `flat` (fixed amount), `per_unit` (multiplied by quantity), `tiered` (volume brackets). | | `recurring_interval` | VARCHAR(20) | Billing frequency: `month`, `year`, or NULL for one-time prices. | | `recurring_interval_count` | INTEGER | Number of intervals between billings. | | `trial_period_days` | INTEGER | Number of days in trial period. NULL for no trial. Trial specification is placed at the price layer by deliberate architectural choice: a ladder declares which products are alternatives, not which alternatives are free for N days; that concession belongs to the pricing instrument and the subscription that references it (Doc 34). | | `usage_type` | VARCHAR(20) | For usage pricing: `metered` or `licensed`. NULL for non-usage pricing. A per-price fact, never aggregated to a product-level kind (Decision 135). | | `metadata` | JSONB | Additional price attributes. | | `is_active` | BOOLEAN | Whether this price can be used for new transactions. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Constraints:** ```sql ALTER TABLE billing.prices ADD CONSTRAINT usage_pricing_requires_recurrence CHECK (usage_type IS NULL OR recurring_interval IS NOT NULL); ``` Metered or licensed billing presupposes a recurring period to bill against; a one-time metered price is incoherent and is unrepresentable at the grain where the facts live (Decision 135). "One-time" and "usage" are properties of *prices*, not of products: `recurring_interval IS NULL` is the one-time fact and `usage_type` the consumption fact, each read per price by the purchase, invoicing, and pending-charges paths. A product carrying a monthly recurring price and a discounted lifetime price is one product in two commercial postures; no product-level derivation forces one posture to win. **Relationships:** - `prices` (0..*) → (1) `products`: Prices belong to a product. - `prices` (1) → (0..*) `subscription_items`: Prices are referenced by subscription line items. - `prices` (1) → (0..*) `purchases`: Prices are referenced by purchases. - `prices` (1) → (0..*) `entitlements.pool_ondemand_config`: Prices may serve as on-demand rate plans. --- ### subscriptions A **subscription** is a stateful binding agreement between a billing account and one or more priced products, with a recurring billing lifecycle. It is an invoice generator: at each billing cycle, it produces an invoice. Its status determines whether the pool provisions it creates remain active. The `subscription_changes` table provides complete transition history; `paused_at` and `resumed_at` capture the most recent occurrence for hot-path queries. | Field | Type | Purpose | |-------|------|---------| | `subscription_id` | UUID | Primary key | | `billing_account_id` | UUID | FK → billing_accounts. The entity paying. | | `current_period_start` | TIMESTAMPTZ | Start of current billing period. | | `current_period_end` | TIMESTAMPTZ | End of current billing period. | | `trial_start` | TIMESTAMPTZ | When trial started, if applicable. | | `trial_end` | TIMESTAMPTZ | When trial ends, if applicable. | | `cancel_at_period_end` | BOOLEAN | Whether to cancel at end of current period. | | `cancel_at` | TIMESTAMPTZ | Scheduled cancellation time. | | `canceled_at` | TIMESTAMPTZ | When cancellation was requested. | | `cancellation_reason` | TEXT | Why the subscription was canceled. | | `ended_at` | TIMESTAMPTZ | When the subscription actually ended. | | `paused_at` | TIMESTAMPTZ | When most recently paused. | | `resumed_at` | TIMESTAMPTZ | When most recently resumed. | | `status` | VARCHAR(20) | State machine: `incomplete`, `trialing`, `active`, `past_due`, `unpaid`, `paused`, `canceled`. | | `commitment_end` | TIMESTAMPTZ | End of a minimum-term commitment, if one applies. NULL for at-will subscriptions. Modeling the term as data lets a committed-plan downgrade defer to this boundary rather than to a hardcoded exception (Decision 126). | | `commitment_renewal` | VARCHAR(20) | Disposition at `commitment_end`: `auto_renew` (begin a new term) or `expire` (revert to at-will). `CHECK (commitment_renewal IN ('auto_renew','expire'))`. | | `early_termination_policy` | VARCHAR(20) | Governs cancellation before `commitment_end`: `block` (disallow), `fee` (permit with an early-termination charge), or `allow` (permit without penalty). `CHECK (early_termination_policy IN ('block','fee','allow'))`. | | `metadata` | JSONB | Additional subscription attributes. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Constraints:** - `commitment_fields_coherent CHECK ((commitment_end IS NULL AND early_termination_policy IS NULL) OR (commitment_end IS NOT NULL AND early_termination_policy IS NOT NULL))` — a commitment boundary and its termination policy are present or absent together; neither is meaningful without the other (Decision 126). **Relationships:** - `subscriptions` (0..*) → (1) `billing_accounts`: Subscriptions belong to a billing account. - `subscriptions` (1) → (1..*) `subscription_items`: Subscriptions contain one or more items. - `subscriptions` (1) → (0..*) `subscription_changes`: Changes are tracked. - `subscriptions` (1) → (0..*) `subscription_scheduled_changes`: Forward-looking change intent awaiting its effective moment. - `subscriptions` (1) → (0..*) `invoices`: Subscriptions generate invoices. - `subscriptions` (1) → (0..*) `entitlements.pool_provisions`: Subscriptions create pool provisions (one per item). --- ### subscription_items A **subscription_item** is a line item within a subscription. Each item binds the subscription to a specific price (and by implication, a product) at a specific quantity. | Field | Type | Purpose | |-------|------|---------| | `item_id` | UUID | Primary key | | `subscription_id` | UUID | FK → subscriptions. | | `price_id` | UUID | FK → prices. The price (and thus product) for this line item. | | `quantity` | INTEGER | Number of units. | | `metadata` | JSONB | Additional item attributes. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Relationships:** - `subscription_items` (1..*) → (1) `subscriptions`: Items belong to a subscription. - `subscription_items` (0..*) → (1) `prices`: Items reference a price. --- ### subscription_changes A **subscription_change** records a modification to a subscription. This provides a complete audit trail of subscription lifecycle events with timestamps and actor attribution. | Field | Type | Purpose | |-------|------|---------| | `change_id` | UUID | Primary key | | `subscription_id` | UUID | FK → subscriptions. The subscription that changed. | | `change_type` | VARCHAR(50) | What kind of change: `created`, `item_added`, `item_removed`, `item_quantity_changed`, `renewed`, `trial_started`, `trial_ended`, `paused`, `resumed`, `canceled`, `reactivated`, `ended`. | | `item_id` | UUID | FK → subscription_items. Which item changed, if applicable. | | `previous_price_id` | UUID | Price before the change. | | `previous_quantity` | INTEGER | Quantity before the change. | | `previous_status` | VARCHAR(20) | Status before the change. | | `new_price_id` | UUID | Price after the change. | | `new_quantity` | INTEGER | Quantity after the change. | | `new_status` | VARCHAR(20) | Status after the change. | | `changed_by_person_id` | UUID | FK → identity.persons. Who made the change (NULL if system/automated). | | `reason` | TEXT | Explanation for the change. | | `effective_at` | TIMESTAMPTZ | When the change took/takes effect. | | `created_at` | TIMESTAMPTZ | | **Relationships:** - `subscription_changes` (0..*) → (1) `subscriptions`: Changes belong to a subscription. - `subscription_changes` (0..*) → (0..1) `subscription_items`: Changes may reference a specific item. - `subscription_changes` (0..*) → (0..1) `identity.persons`: Changes may be attributed to a person. --- ### subscription_scheduled_changes A **subscription_scheduled_change** is forward-looking *intent*: a deferred plan switch, quantity change, or cancellation that has been requested but whose effective moment lies in the future. It is the structural counterpart to `subscription_changes`, which is an append-only *record* of what has already happened. The distinction resolves a latent smell — `subscription_changes.effective_at` documented as "took **or takes** effect" — by giving not-yet-effective intent its own home: when a scheduled change fires, it produces a `subscription_changes` row and transitions to `applied`. Immediate changes bypass this table entirely. The table belongs to the subscription seam within `billing` rather than to a separate schema (Decision 127); cancellation scheduling (`subscriptions.cancel_at_period_end`, `cancel_at`) predates and remains alongside it (Decision 123). | Field | Type | Purpose | |-------|------|---------| | `scheduled_change_id` | UUID | Primary key | | `subscription_id` | UUID | FK → subscriptions. The subscription this intent applies to. | | `item_id` | UUID | FK → subscription_items. The item scoped to, if any. NULL for subscription-scoped changes (e.g., whole-subscription cancellation). | | `change_kind` | VARCHAR(30) | `plan_switch`, `quantity_change`, or `cancellation`. `CHECK (change_kind IN ('plan_switch','quantity_change','cancellation'))`. | | `target_price_id` | UUID | FK → prices. Destination price for a `plan_switch`. NULL otherwise. | | `target_quantity` | INTEGER | Destination quantity for a `quantity_change`. NULL otherwise. | | `effective_at` | TIMESTAMPTZ | When the intent is to take effect. | | `effective_trigger` | VARCHAR(20) | What fixes the effective moment: `at_date` (a specific instant), `period_end` (next billing boundary), or `term_boundary` (commitment end). `CHECK (effective_trigger IN ('at_date','period_end','term_boundary'))`. | | `credit_disposition` | VARCHAR(10) | Per-occurrence override of proration-credit routing: `ledger` or `cash`. NULL inherits `billing_accounts.default_credit_disposition`. `CHECK (credit_disposition IN ('ledger','cash'))`. | | `status` | VARCHAR(20) NOT NULL DEFAULT 'scheduled' | Lifecycle: `scheduled`, `applied`, `superseded`, `canceled`. `CHECK (status IN ('scheduled','applied','superseded','canceled'))`. | | `reason` | TEXT | Explanation for the scheduled change. | | `requested_by_person_id` | UUID | FK → identity.persons. Who requested it (NULL if system/automated). | | `applied_change_id` | UUID | FK → subscription_changes. The record produced when this intent fired. NULL until applied. | | `superseded_by` | UUID | FK → subscription_scheduled_changes. The later intent that replaced this one, if any (self-reference). | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Constraints:** - `uq_one_pending_scheduled_change` — partial unique index on `(subscription_id, COALESCE(item_id, '00000000-0000-0000-0000-000000000000'), change_kind) WHERE status = 'scheduled'`: at most one pending intent per subscription/item/kind, mirroring the at-most-one-active-provision invariant the ladder enforces by GiST exclusion. A new request for the same scope supersedes the prior one (`status = 'superseded'`, `superseded_by` set) rather than colliding. **Relationships:** - `subscription_scheduled_changes` (0..*) → (1) `subscriptions`: Intent belongs to a subscription. - `subscription_scheduled_changes` (0..*) → (0..1) `subscription_items`: Intent may scope to a specific item. - `subscription_scheduled_changes` (0..*) → (0..1) `prices`: A plan switch targets a price. - `subscription_scheduled_changes` (0..*) → (0..1) `subscription_changes`: An applied intent points to the record it produced. - `subscription_scheduled_changes` (0..*) → (0..1) `identity.persons`: Intent may be attributed to a requester. --- ### purchases A **purchase** represents a one-time transaction: a billing account pays for a product and receives access. There is no recurring billing, no state machine. It is a completed exchange. | Field | Type | Purpose | |-------|------|---------| | `purchase_id` | UUID | Primary key | | `billing_account_id` | UUID | FK → billing_accounts. Who paid. | | `price_id` | UUID | FK → prices. What they paid (must be a one-time price). | | `quantity` | INTEGER | Number of units purchased. | | `amount` | INTEGER | Total amount charged (smallest currency unit). | | `currency` | VARCHAR(3) | Currency of the transaction. | | `purchased_at` | TIMESTAMPTZ | When the purchase was completed. | | `status` | VARCHAR(20) | `completed`, `refunded`, `partially_refunded`. | | `refunded_at` | TIMESTAMPTZ | When refunded. | | `invoice_id` | UUID | FK → invoices. The invoice generated for this purchase. | | `metadata` | JSONB | Additional purchase attributes. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Relationships:** - `purchases` (0..*) → (1) `billing_accounts`: Purchases belong to a billing account. - `purchases` (0..*) → (1) `prices`: Purchases reference a price. - `purchases` (0..*) → (0..1) `invoices`: Purchases may generate an invoice. - `purchases` (1) → (0..*) `entitlements.pool_provisions`: Purchases create pool provisions. --- ### invoices An **invoice** is a billing document requesting payment from a billing account. Invoices may be generated from subscriptions (periodically), purchases (once), or metered usage (aggregated). Invoice address fields snapshot billing identity at issuance time — they record what appeared on the invoice regardless of subsequent billing account changes. | Field | Type | Purpose | |-------|------|---------| | `invoice_id` | UUID | Primary key | | `invoice_number` | VARCHAR(50) | Human-readable unique identifier. | | `billing_account_id` | UUID | FK → billing_accounts. The billing account being invoiced. | | `subscription_id` | UUID | FK → subscriptions. A subscription this invoice is for, if applicable. | | `purchase_id` | UUID | FK → purchases. A purchase this invoice is for, if applicable. | | `billing_name` | VARCHAR(255) | Snapshot of billing name at invoice time. | | `billing_email` | VARCHAR(255) | Snapshot of billing email at invoice time. | | `billing_address_line1` | VARCHAR(255) | Snapshot of billing street address at invoice time. | | `billing_address_line2` | VARCHAR(255) | Snapshot of billing address line 2 at invoice time. | | `billing_city` | VARCHAR(100) | Snapshot of billing city at invoice time. | | `billing_state` | VARCHAR(100) | Snapshot of billing state/province at invoice time. | | `billing_postal_code` | VARCHAR(20) | Snapshot of billing postal code at invoice time. | | `billing_country` | VARCHAR(2) | Snapshot of billing country (ISO 3166-1 alpha-2) at invoice time. | | `currency` | VARCHAR(3) | Currency for this invoice. | | `subtotal` | INTEGER | Amount before discounts and tax (smallest currency unit). | | `discount_amount` | INTEGER | Total discounts applied. | | `tax_amount` | INTEGER | Tax amount. | | `total` | INTEGER | Full financial obligation before credit application. | | `credit_applied` | INTEGER DEFAULT 0 | Total credit amount applied to this invoice from credit grants. | | `amount_paid` | INTEGER | Amount paid via payment method so far. | | `amount_due` | INTEGER | Remaining balance after credits. Equal to `total - credit_applied - amount_paid`. | | `invoice_date` | DATE | Date the invoice was issued. | | `due_date` | DATE | Date payment is due. | | `period_start` | DATE | Start of billing period. | | `period_end` | DATE | End of billing period. | | `status` | VARCHAR(20) | `draft`, `open`, `paid`, `void`, `uncollectible`, `refunded`. | | `paid_at` | TIMESTAMPTZ | When fully paid. | | `voided_at` | TIMESTAMPTZ | When voided. | | `invoice_pdf_url` | VARCHAR(500) | URL to downloadable PDF. | | `hosted_invoice_url` | VARCHAR(500) | URL to hosted invoice page. | | `memo` | TEXT | Notes or memo. | | `metadata` | JSONB | Additional invoice attributes. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Note on source FK non-exclusivity:** The `subscription_id` and `purchase_id` columns are intentionally not mutually exclusive. An invoice may reference a subscription, a purchase, both (in mixed-source invoicing scenarios), or neither (for invoices generated from usage charges alone). These are optional associative references, not a polymorphic pattern. **Relationships:** - `invoices` (0..*) → (1) `billing_accounts`: Invoices are sent to a billing account. - `invoices` (0..*) → (0..1) `subscriptions`: Invoices may be for a subscription. - `invoices` (0..*) → (0..1) `purchases`: Invoices may be for a purchase. - `invoices` (1) → (1..*) `invoice_line_items`: Invoices have one or more line items. - `invoices` (1) → (0..*) `payments`: Invoices may have payments applied. - `invoices` (1) → (0..*) `pending_charges`: Invoices may have pending charges swept into them. - `invoices` (1) → (0..*) `credit_transactions`: Invoices may have credits applied. --- ### payments A **payment** records money received from a billing account. | Field | Type | Purpose | |-------|------|---------| | `payment_id` | UUID | Primary key | | `billing_account_id` | UUID | FK → billing_accounts. The billing account that paid. | | `invoice_id` | UUID | FK → invoices. The invoice this payment is for, if applicable. | | `currency` | VARCHAR(3) | Currency of the payment. | | `amount` | INTEGER | Gross amount received (smallest currency unit). | | `processor_fee` | INTEGER | Fee charged by payment processor. | | `payment_method_type` | VARCHAR(50) | Type: `card`, `bank_transfer`, `crypto`, etc. | | `payment_method_details` | JSONB | Non-identifying descriptors only (e.g., `{"brand": "visa", "last4": "4242"}`). | | `status` | VARCHAR(20) | `pending`, `processing`, `succeeded`, `failed`, `refunded`, `partially_refunded`. | | `failure_code` | VARCHAR(100) | Error code if failed. | | `failure_message` | TEXT | Human-readable failure explanation. | | `paid_at` | TIMESTAMPTZ | When completed. | | `failed_at` | TIMESTAMPTZ | When failed. | | `refunded_at` | TIMESTAMPTZ | When refunded. | | `metadata` | JSONB | Additional payment attributes. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Relationships:** - `payments` (0..*) → (1) `billing_accounts`: Payments come from a billing account. - `payments` (0..*) → (0..1) `invoices`: Payments may be applied to an invoice. - `payments` (1) → (0..*) `cooperative.patronage_events`: Successful payments create patronage events. - `payments` (1) → (0..*) `refunds`: Payments may have refunds. - `payments` (1) → (0..*) `disputes`: Payments may have disputes. --- ### payment_methods A **payment_method** records that a billing account has a means of payment on file. This is a provider-agnostic domain projection: it answers "does this account have a payment method?" and "what kind?" without consulting any external service. Provider-specific details (Stripe's `pm_xxx` ID, fingerprint, vault token) live in integration schemas. Payment methods are synchronized inward from payment providers — the provider is authoritative for the payment method's existence and expiration. The core table captures the business-relevant subset needed for local decision-making (auto-collection eligibility, checkout flow routing, account health display). | Field | Type | Purpose | |-------|------|---------| | `payment_method_id` | UUID | Primary key | | `billing_account_id` | UUID | FK → billing_accounts. The account this payment method belongs to. | | `type` | VARCHAR(30) | Payment method type: `card`, `bank_account`, `sepa_debit`, etc. | | `card_brand` | VARCHAR(20) | Card brand: `visa`, `mastercard`, `amex`, etc. NULL for non-card types. | | `card_last4` | VARCHAR(4) | Last four digits. NULL for non-card types. | | `card_exp_month` | SMALLINT | Card expiration month. NULL for non-card types. | | `card_exp_year` | SMALLINT | Card expiration year. NULL for non-card types. | | `is_default` | BOOLEAN | Whether this is the default payment method for the billing account. | | `status` | VARCHAR(20) | `active`, `expired`, `revoked`. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Relationships:** - `payment_methods` (0..*) → (1) `billing_accounts`: Payment methods belong to a billing account. --- ### refunds A **refund** records a partial or full return of money from a payment. Refunds are separate from payments because a refund has its own amount (which may differ from the payment), reason, status, and timing. Modeling refunds as a status flag on payments (as v8 did) loses this information. In the cooperative model, a refund triggers a patronage reversal — the patronage event that credited value to the paying member must be partially or fully reversed. The `patronage_events` table references refunds via `source_type`. | Field | Type | Purpose | |-------|------|---------| | `refund_id` | UUID | Primary key | | `payment_id` | UUID | FK → payments. The payment being refunded. | | `amount` | INTEGER | Refund amount in smallest currency unit. May differ from payment amount (partial refund). | | `currency` | VARCHAR(3) | Currency of the refund. | | `reason` | VARCHAR(50) | Reason: `requested_by_customer`, `duplicate`, `fraudulent`, etc. | | `status` | VARCHAR(20) | `pending`, `succeeded`, `failed`. | | `refunded_at` | TIMESTAMPTZ | When the refund completed. | | `failed_at` | TIMESTAMPTZ | When the refund failed, if applicable. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Relationships:** - `refunds` (0..*) → (1) `payments`: Refunds apply to a payment. --- ### disputes A **dispute** (chargeback) records that a payment is being contested through the payment processor's dispute resolution process. Disputes affect revenue, patronage, and potentially account standing — they are business entities, not integration details. The dispute amount, status, reason, and resolution belong in the `billing` schema. Provider-specific dispute IDs, evidence submission details, and processor-specific reason codes live in per-provider integration schemas. | Field | Type | Purpose | |-------|------|---------| | `dispute_id` | UUID | Primary key | | `payment_id` | UUID | FK → payments. The payment under dispute. | | `amount` | INTEGER | Disputed amount in smallest currency unit. | | `currency` | VARCHAR(3) | Currency of the dispute. | | `reason` | VARCHAR(50) | Reason category: `fraudulent`, `product_not_received`, `unrecognized`, `duplicate`, `subscription_canceled`, `other`. | | `status` | VARCHAR(20) | `needs_response`, `under_review`, `won`, `lost`. | | `evidence_due_by` | TIMESTAMPTZ | Deadline for submitting evidence. NULL if not applicable. | | `resolved_at` | TIMESTAMPTZ | When the dispute was resolved (won or lost). | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Relationships:** - `disputes` (0..*) → (1) `payments`: Disputes apply to a payment. --- ### invoice_line_items An **invoice_line_item** is a single charge, credit, or adjustment on an invoice. Every invoice has at least one line item. Line items are the atoms of invoice computation — totals, discounts, and taxes are all expressed in terms of line items. The invoice's summary fields (`subtotal`, `discount_amount`, `tax_amount`, `total`) are denormalized aggregates maintained by the invoice generation process and verified against line items. | Field | Type | Purpose | |-------|------|---------| | `line_item_id` | UUID | Primary key | | `invoice_id` | UUID | FK → invoices. The invoice this line item belongs to. | | `line_type` | VARCHAR(30) | Classification: `subscription`, `usage`, `one_time`, `proration_credit`, `proration_charge`, `discount`, `adjustment`, `early_termination_fee` (charge levied when a committed subscription is canceled early under a `fee` policy — Decision 126). | | `description` | TEXT | Human-readable description displayed on the invoice. | | `product_id` | UUID | FK → products. The product this line item relates to. NULL for discounts and adjustments that are not product-specific. | | `price_id` | UUID | FK → prices. The price used for this line item. NULL for adjustments. | | `subscription_item_id` | UUID | FK → subscription_items. If this line item was generated from a subscription item. | | `metered_key` | VARCHAR(100) | If this line item represents metered usage, the usage key. | | `quantity` | INTEGER | Number of units. | | `unit_amount` | INTEGER | Per-unit amount in smallest currency unit. | | `amount` | INTEGER | Line total before tax (smallest currency unit). May be negative for credits, proration credits, and discounts. | | `discount_amount` | INTEGER DEFAULT 0 | Discount apportioned to this line item (positive value representing reduction). | | `tax_rate` | DECIMAL(7,4) | Tax rate applied (e.g., 0.0875 for 8.75%). NULL if not taxable. | | `tax_amount` | INTEGER | Tax amount for this line item (smallest currency unit). | | `period_start` | DATE | Start of the service period this line item covers. | | `period_end` | DATE | End of the service period. | | `metadata` | JSONB | Additional line item attributes. No PII. | | `created_at` | TIMESTAMPTZ | | **Sum invariant:** `SUM(amount) WHERE line_type != 'discount' = invoices.subtotal`. `invoices.total = subtotal - discount_amount + tax_amount`. **Relationships:** - `invoice_line_items` (1..*) → (1) `invoices`: Line items belong to an invoice. - `invoice_line_items` (0..*) → (0..1) `products`: Line items may reference a product. - `invoice_line_items` (0..*) → (0..1) `prices`: Line items may reference a price. - `invoice_line_items` (0..*) → (0..1) `subscription_items`: Line items may trace to a subscription item. - `invoice_line_items` (1) → (0..*) `discounts`: Line items may have discounts scoped to them. --- ### coupons A **coupon** defines the terms of a price reduction: how much, for how long, for which products, and under what constraints. This is the rule entity — the abstract definition of the discount logic. Coupons are distinct from credits: coupons reduce the obligation (the customer owes less), while credits reduce the collection (the customer has already paid). | Field | Type | Purpose | |-------|------|---------| | `coupon_id` | UUID | Primary key | | `org_id` | UUID | FK → organization.organizations. The organization that created this coupon. NULL for platform-level coupons. | | `name` | VARCHAR(255) | Internal name (e.g., "Launch Promotion", "Annual Loyalty"). | | `display_name` | VARCHAR(255) | Customer-facing name displayed on invoices. | | `discount_type` | VARCHAR(20) | `percentage` or `fixed`. | | `percentage_off` | DECIMAL(5,2) | Percentage to deduct. NULL if `discount_type = 'fixed'`. | | `amount_off` | INTEGER | Fixed amount to deduct (smallest currency unit). NULL if `discount_type = 'percentage'`. | | `currency` | VARCHAR(3) | Currency for `amount_off`. NULL if `discount_type = 'percentage'`. | | `duration` | VARCHAR(20) | `once`, `repeating`, `forever`. | | `duration_months` | INTEGER | For `repeating`: number of billing cycles. NULL otherwise. | | `applies_to_products` | UUID[] | Product IDs this discount is restricted to. NULL means all products. | | `max_redemptions` | INTEGER | Maximum total redemptions. NULL for unlimited. | | `redemption_count` | INTEGER DEFAULT 0 | Current redemption count. Denormalized. | | `valid_from` | TIMESTAMPTZ | Earliest redemption time. NULL for immediate. | | `valid_until` | TIMESTAMPTZ | Latest redemption time. NULL for no expiration. | | `minimum_amount` | INTEGER | Minimum invoice subtotal for the coupon to apply. NULL for no minimum. | | `minimum_amount_currency` | VARCHAR(3) | Currency for `minimum_amount`. | | `first_time_only` | BOOLEAN DEFAULT FALSE | Whether restricted to first-time subscribers/purchasers. | | `is_active` | BOOLEAN DEFAULT TRUE | Whether available for new redemptions. | | `created_by_person_id` | UUID | FK → identity.persons. Who created this coupon. | | `metadata` | JSONB | Additional attributes. No PII. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Constraints:** ```sql CHECK ( (discount_type = 'percentage' AND percentage_off IS NOT NULL AND amount_off IS NULL) OR (discount_type = 'fixed' AND amount_off IS NOT NULL AND percentage_off IS NULL) ) CHECK ( (duration = 'repeating' AND duration_months IS NOT NULL) OR (duration != 'repeating' AND duration_months IS NULL) ) ``` **Relationships:** - `coupons` (0..*) → (0..1) `organization.organizations`: Coupons may belong to an organization. - `coupons` (0..*) → (1) `identity.persons`: Coupons are created by a person. - `coupons` (1) → (0..*) `promotion_codes`: Coupons may have distribution codes. - `coupons` (1) → (0..*) `discounts`: Coupons may have active discount applications. --- ### promotion_codes A **promotion_code** is a customer-facing key that maps to a coupon with additional distribution constraints. One coupon can have many promotion codes (e.g., five influencer codes all mapping to the same "20% off" coupon, with per-code tracking). | Field | Type | Purpose | |-------|------|---------| | `promotion_code_id` | UUID | Primary key | | `coupon_id` | UUID | FK → coupons. The coupon this code provides access to. | | `code` | VARCHAR(100) | Customer-facing code (e.g., "LAUNCH50"). Case-insensitive. | | `max_redemptions` | INTEGER | Maximum redemptions for this code. NULL for unlimited. | | `redemption_count` | INTEGER DEFAULT 0 | Current redemptions through this code. Denormalized. | | `restrict_to_billing_account_id` | UUID | FK → billing_accounts. If set, restricted to a specific billing account. | | `first_time_only` | BOOLEAN DEFAULT FALSE | Whether restricted to first-time subscribers/purchasers. | | `valid_from` | TIMESTAMPTZ | Earliest time this code can be used. | | `valid_until` | TIMESTAMPTZ | Latest time this code can be used. | | `is_active` | BOOLEAN DEFAULT TRUE | Whether this code is currently usable. | | `created_by_person_id` | UUID | FK → identity.persons. Who created this code. | | `metadata` | JSONB | Additional attributes. No PII. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Constraints:** - `code` has a partial unique index: `UNIQUE (LOWER(code)) WHERE is_active = true`. **Relationships:** - `promotion_codes` (0..*) → (1) `coupons`: Codes map to a coupon. - `promotion_codes` (0..*) → (0..1) `billing_accounts`: Codes may be restricted to a billing account. - `promotion_codes` (0..*) → (1) `identity.persons`: Codes are created by a person. - `promotion_codes` (1) → (0..*) `discounts`: Codes may have active discount applications. --- ### discounts A **discount** records that a coupon has been applied to a specific billing relationship. It is the active state of a coupon on a billing account, subscription, or invoice line item. | Field | Type | Purpose | |-------|------|---------| | `discount_id` | UUID | Primary key | | `coupon_id` | UUID | FK → coupons. The coupon being applied. | | `promotion_code_id` | UUID | FK → promotion_codes. The code used, if any. NULL if applied administratively. | | `billing_account_id` | UUID | FK → billing_accounts. If billing-account-level discount. NULL otherwise. | | `subscription_id` | UUID | FK → subscriptions. If subscription-level discount. NULL otherwise. | | `invoice_line_item_id` | UUID | FK → invoice_line_items. If line-item-level discount. NULL otherwise. | | `applied_by_person_id` | UUID | FK → identity.persons. Who applied this discount. NULL if self-service. | | `code_used` | VARCHAR(100) | Snapshot of the code that was entered. | | `duration_remaining` | INTEGER | For `repeating`: billing cycles remaining. NULL for `once` and `forever`. | | `started_at` | TIMESTAMPTZ | When the discount was applied. | | `ends_at` | TIMESTAMPTZ | When the discount expires based on duration. NULL for `forever`. | | `ended_at` | TIMESTAMPTZ | When actually ended (if manually removed). | | `status` | VARCHAR(20) | `active`, `exhausted`, `expired`, `removed`. | | `removed_at` | TIMESTAMPTZ | When manually removed. | | `removed_by_person_id` | UUID | FK → identity.persons. Who removed it. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Constraints (exclusive arc — scope) ⚡:** ```sql CHECK ( (billing_account_id IS NOT NULL AND subscription_id IS NULL AND invoice_line_item_id IS NULL) OR (billing_account_id IS NULL AND subscription_id IS NOT NULL AND invoice_line_item_id IS NULL) OR (billing_account_id IS NULL AND subscription_id IS NULL AND invoice_line_item_id IS NOT NULL) ) ``` **Status lifecycle:** - `active`: Discount currently applied and reducing charges on future invoices. - `exhausted`: For `once` and `repeating` — all applicable cycles used. - `expired`: `ends_at` timestamp passed. - `removed`: Manually removed before natural expiration. **Relationships:** - `discounts` (0..*) → (1) `coupons`: Discounts reference a coupon. - `discounts` (0..*) → (0..1) `promotion_codes`: Discounts may reference a code. - `discounts` (0..*) → (0..1) `billing_accounts`: Billing-account-scoped. **⚡ Exclusive arc.** - `discounts` (0..*) → (0..1) `subscriptions`: Subscription-scoped. **⚡ Exclusive arc.** - `discounts` (0..*) → (0..1) `invoice_line_items`: Line-item-scoped. **⚡ Exclusive arc.** --- ### pending_charges A **pending_charge** represents a mutable, pre-invoice financial obligation. It bridges the gap between raw inputs (usage events, subscription cycles, ad hoc charges) and frozen outputs (invoice line items). Pending charges are the "invoice items" of the billing pipeline — they represent "we intend to charge for this" and can be edited or voided before they are swept into a draft invoice. | Field | Type | Purpose | |-------|------|---------| | `charge_id` | UUID | Primary key | | `billing_account_id` | UUID | FK → billing_accounts. Who will be charged. | | `subscription_id` | UUID | FK → subscriptions. If from a subscription cycle. NULL for ad hoc. | | `subscription_item_id` | UUID | FK → subscription_items. If from a specific subscription item. | | `product_id` | UUID | FK → products. The product this charge relates to. | | `price_id` | UUID | FK → prices. The price used. NULL for manual adjustments. | | `charge_type` | VARCHAR(30) | `subscription`, `usage`, `one_time`, `proration_credit`, `proration_charge`, `adjustment`. | | `description` | TEXT | Human-readable description. Carried forward to the invoice line item. | | `metered_key` | VARCHAR(100) | If metered usage, the usage key. | | `quantity` | INTEGER | Number of units. | | `unit_amount` | INTEGER | Per-unit amount (smallest currency unit). | | `amount` | INTEGER | Charge total. May be negative for credits. | | `currency` | VARCHAR(3) | Currency of the charge. | | `period_start` | DATE | Service period start. | | `period_end` | DATE | Service period end. | | `invoice_id` | UUID | FK → invoices. Set when swept into a draft invoice. | | `invoice_line_item_id` | UUID | FK → invoice_line_items. Set when materialized as a line item. | | `created_by_person_id` | UUID | FK → identity.persons. For manual charges. NULL for system-generated. | | `status` | VARCHAR(20) | `pending`, `invoiced`, `voided`. | | `invoiced_at` | TIMESTAMPTZ | When swept into an invoice. | | `voided_at` | TIMESTAMPTZ | When voided. | | `voided_by_person_id` | UUID | FK → identity.persons. Who voided. | | `metadata` | JSONB | Additional attributes. No PII. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Status lifecycle:** - `pending`: Awaiting invoice sweep. Mutable. Can be voided. - `invoiced`: Swept into a draft invoice. Immutable. - `voided`: Cancelled before invoicing. Terminal. **Relationships:** - `pending_charges` (0..*) → (1) `billing_accounts`: Charges belong to a billing account. - `pending_charges` (0..*) → (0..1) `subscriptions`: Charges may originate from a subscription. - `pending_charges` (0..*) → (0..1) `invoices`: Charges may be swept into an invoice. - `pending_charges` (0..*) → (0..1) `invoice_line_items`: Charges may materialize as a line item. - `pending_charges` (1) → (0..*) `pending_charge_usage_events`: Charges may link to contributing usage events. --- ### pending_charge_usage_events A **pending_charge_usage_event** links a usage event to the pending charge it contributed to. This join table preserves the append-only semantics of `usage_events` while providing full traceability from consumption to billing. | Field | Type | Purpose | |-------|------|---------| | `charge_id` | UUID | FK → pending_charges. | | `event_id` | UUID | FK → entitlements.usage_events. | | `created_at` | TIMESTAMPTZ | | **Constraints:** - Primary key on `(charge_id, event_id)`. - Index on `event_id` for "which charge includes this event?" queries. **Relationships:** - `pending_charge_usage_events` (0..*) → (1) `pending_charges`: Links target a charge. - `pending_charge_usage_events` (0..*) → (1) `entitlements.usage_events`: Links target an event. --- ### credit_grants A **credit grant** represents a discrete allocation of prepaid or promotional billing credits to a billing account. Multiple grants may be active simultaneously; they are consumed in priority order. Credits support two application topologies: invoice-time (credits reduce the amount due on a finalized invoice) and real-time (credits are deducted at the moment of consumption). The billing account's `platform_billing_mode` and `customer_billing_mode` fields determine which topology applies. | Field | Type | Purpose | |-------|------|---------| | `grant_id` | UUID | Primary key | | `billing_account_id` | UUID | FK → billing_accounts. Who holds these credits. | | `name` | VARCHAR(200) | Human-readable description (e.g., "January credit purchase", "Welcome bonus"). | | `category` | VARCHAR(20) | `paid` (customer purchased) or `promotional` (platform granted). Determines accounting treatment. | | `currency` | VARCHAR(3) | Currency of the grant. | | `initial_amount` | INTEGER | Original credit amount in smallest currency unit. | | `balance` | INTEGER | Current available balance. Denormalized from ledger; maintained by credit transactions. | | `priority` | SMALLINT | Application priority. 0 is highest. Lower numbers consumed first. Default: 50. | | `applies_to_products` | UUID[] | Product restrictions. NULL means unrestricted. | | `effective_at` | TIMESTAMPTZ | When credits become usable. | | `expires_at` | TIMESTAMPTZ | When unused credits expire. NULL means no expiration. | | `source_provision_id` | UUID | FK → entitlements.pool_provisions. Which provision triggered this credit grant. NULL for manually created or purchased grants. | | `funded_by_invoice_id` | UUID | FK → invoices. Purchase invoice for paid grants. NULL for promotional. | | `created_by_person_id` | UUID | FK → identity.persons. Who created this grant. | | `status` | VARCHAR(20) | `pending`, `active`, `exhausted`, `expired`, `voided`. | | `voided_at` | TIMESTAMPTZ | When voided. | | `voided_by_person_id` | UUID | FK → identity.persons. Who voided. | | `metadata` | JSONB | Additional attributes. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Constraints:** - Index on `(billing_account_id, status)`. - CHECK: `priority BETWEEN 0 AND 100`. - CHECK: `initial_amount > 0`. - CHECK: `balance >= 0`. **Status lifecycle:** - `pending`: `effective_at` in the future. Not yet usable. - `active`: Effective, positive balance, not expired. - `exhausted`: Balance fully consumed. Terminal. - `expired`: `expires_at` passed with remaining balance. Terminal. - `voided`: Administratively cancelled. Terminal. **Relationships:** - `credit_grants` (0..*) → (1) `billing_accounts`: Grants belong to a billing account. - `credit_grants` (0..*) → (0..1) `entitlements.pool_provisions` (via `source_provision_id`): Grants may be lifecycle-coupled to a provision. - `credit_grants` (0..*) → (0..1) `invoices` (via `funded_by_invoice_id`): Paid grants may reference the purchase invoice. - `credit_grants` (0..*) → (0..1) `identity.persons` (via `created_by_person_id`): Audit attribution. - `credit_grants` (1) → (0..*) `credit_transactions`: Grants have ledger entries. --- ### credit_transactions A **credit transaction** is an immutable ledger entry recording a credit (funds in) or debit (funds out) against a specific grant. The grant's `balance` field is the running total. The ledger is append-only: corrections are made by adding compensating transactions, never by modifying existing ones. | Field | Type | Purpose | |-------|------|---------| | `transaction_id` | UUID | Primary key | | `grant_id` | UUID | FK → credit_grants. Which grant this transaction affects. | | `billing_account_id` | UUID | FK → billing_accounts. Denormalized for account-level ledger queries. | | `type` | VARCHAR(20) | `credit` (funds added) or `debit` (funds consumed). | | `amount` | INTEGER | Transaction amount (smallest currency unit). Always positive; `type` determines direction. | | `balance_after` | INTEGER | Grant balance after this transaction. | | `source_type` | VARCHAR(30) | What triggered this: `initial_funding`, `top_up`, `invoice_application`, `usage_deduction`, `expiration`, `void`, `reinstatement`, `adjustment`, `proration` (credit arising from a mid-period downgrade routed to the ledger — Decision 124). | | `invoice_id` | UUID | FK → invoices. If applied to an invoice. | | `invoice_line_item_id` | UUID | FK → invoice_line_items. If applied to a specific line item. | | `usage_event_id` | UUID | FK → entitlements.usage_events. If for a real-time usage deduction. | | `description` | TEXT | Human-readable description. | | `effective_at` | TIMESTAMPTZ | When this transaction takes effect. | | `created_by_person_id` | UUID | FK → identity.persons. For manual adjustments. NULL for system-generated. | | `metadata` | JSONB | Additional attributes. | | `created_at` | TIMESTAMPTZ | Immutable. | **Constraints:** - Index on `grant_id` for per-grant ledger queries. - Index on `(billing_account_id, effective_at)` for account-level ledger queries. - Index on `invoice_id` for "which credits were applied to this invoice?" queries. - Immutability enforced by application logic. **Ledger invariant:** For any grant, `balance = initial_amount + SUM(credits) - SUM(debits)`. **Relationships:** - `credit_transactions` (0..*) → (1) `credit_grants`: Transactions belong to a grant. - `credit_transactions` (0..*) → (0..1) `invoices`: Debits may reference the invoice. - `credit_transactions` (0..*) → (0..1) `invoice_line_items`: Debits may reference the line item. - `credit_transactions` (0..*) → (0..1) `entitlements.usage_events`: Real-time debits may reference the usage event. --- ### plan_ladders A **plan ladder** is a named, catalog-shape declaration that a set of products is mutually exclusive within a commercial domain — the cooperative sells exactly one of these tiers at a time per provisioned pool. The ladder is a catalog primitive: it declares "these products are alternatives." It does not specify what capabilities each alternative confers (that is the province of `entitlement_sets`) nor which alternative is currently active for any given pool (that is the province of `entitlements.pool_provision_ladders`). ```sql CREATE TABLE billing.plan_ladders ( plan_ladder_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), ladder_key VARCHAR(64) NOT NULL UNIQUE, name VARCHAR(255) NOT NULL, description TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ``` | Field | Type | Purpose | |-------|------|---------| | `plan_ladder_id` | UUID | Primary key | | `ladder_key` | VARCHAR(64) | Stable machine identifier (e.g., `nextcloud`, `email_hosting`). Unique across the catalog. | | `name` | VARCHAR(255) | Display name (e.g., "Nextcloud Plans"). | | `description` | TEXT | Optional narrative description of the ladder's commercial domain. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Relationships:** - `plan_ladders` (1) → (0..*) `plan_ladder_tiers`: A ladder has one or more tier memberships. The junction is the sole representation of product-ladder membership (Doc 31 Amendment #3). - `plan_ladders` (1) → (0..*) `entitlements.pool_provision_ladders`: Active provisions record which rung they occupy. `FK ← [entitlements] pool_provision_ladders.plan_ladder_id` --- ### plan_ladder_tiers A **plan_ladder_tier** records a product's ordered position within a plan ladder. It is the authoritative and sole many-to-many junction for ladder membership: single-ladder plans, multi-ladder bundles, and every intermediate case express their ladder cardinality through rows in this table. A single-ladder plan is a plan whose tier-row set has cardinality one; no structural distinction separates it from a bundle beyond that cardinality, and the schema carries none. The `rank` column expresses relative tier level (lower rank = lower tier), enabling "upgrade" and "downgrade" semantics. Correspondingly, a product's status as a plan is read from the *presence* of rows here — a structural fact whose conferral-side read path is the `product_conferral_shapes` view (Decision 134) — rather than from any label the product carries (Doc 31 Amendment #3; Decision 136 retired the label vocabulary entirely). Note that "pricing tier" in the Stripe sense (volume pricing brackets on a price) is an entirely distinct concept from a ladder tier; see GLOSSARY §5.2 for the disambiguation. ```sql CREATE TABLE billing.plan_ladder_tiers ( plan_ladder_id UUID NOT NULL REFERENCES billing.plan_ladders(plan_ladder_id) ON DELETE CASCADE, product_id UUID NOT NULL REFERENCES billing.products(product_id), rank INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (plan_ladder_id, product_id), UNIQUE (plan_ladder_id, rank) ); ``` | Field | Type | Purpose | |-------|------|---------| | `plan_ladder_id` | UUID | FK → plan_ladders. The ladder this tier belongs to. | | `product_id` | UUID | FK → products. The product occupying this position. | | `rank` | INTEGER | Ordinal position within the ladder. Unique per ladder. Lower values conventionally denote lower tiers. | | `created_at` | TIMESTAMPTZ | | **Constraints:** - `UNIQUE (plan_ladder_id, rank)` — no two products may occupy the same rank within a ladder. - `PRIMARY KEY (plan_ladder_id, product_id)` — a product appears at most once per ladder. **Relationships:** - `plan_ladder_tiers` (0..*) → (1) `plan_ladders`: Tier memberships belong to a ladder. - `plan_ladder_tiers` (0..*) → (1) `products`: Tier memberships reference a product. --- ### providers A **provider** is an external service the platform talks to — the genus spanning payment (Stripe), provisioning (FedWiki, NextCloud, Discourse), notification, and tax services, discriminated by `provider_kind` (Decision 129). The registry promotes the soft provider strings the schema already carried (`webhook_events.provider`, `integration_outbox.target_provider`, the per-provider schemas of Decision 86) to a canonical, FK-targetable lookup; the slug primary key matches the pre-existing string values, making the promotion zero-migration. Together with `provider_operations` and `provider_states`, this table anchors the **provider/extension seam** (Doc 39 §5) — a cross-module seam spanning `billing`, `entitlements`, and `integration`, named rather than extracted per the seam doctrine (Decision 127). The `billing.` home is referential safety, not conceptual ownership: self-sufficiency (Decision 87) forbids `integration`, and an `entitlements` home would close a dependency cycle against Decision 114. The seam's *declarations* — what a provider is, owns, offers, and can do — are catalog-register facts; its *execution* lives in `integration` transport, the per-provider schemas, and member-console code. ```sql CREATE TABLE billing.providers ( provider VARCHAR(50) PRIMARY KEY, -- canonical slug; matches pre-existing soft-string values display_name VARCHAR(255) NOT NULL, provider_kind VARCHAR(30) NOT NULL CHECK (provider_kind IN ('payment', 'provisioning', 'notification', 'tax')), status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'retired')), suspended_at TIMESTAMPTZ, retired_at TIMESTAMPTZ, operator_surface_path VARCHAR(255), -- provisioning providers: manifest-declared operator route path created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ``` | Field | Type | Purpose | |-------|------|---------| | `provider` | VARCHAR(50) | Primary key — the canonical slug (`stripe`, `fedwiki`, `discourse`). VARCHAR-slug convention (as `resource_keys`), not the UUID convention for substantive entities. | | `display_name` | VARCHAR(255) | Human-readable name. | | `provider_kind` | VARCHAR(30) | `payment`, `provisioning`, `notification`, `tax`. The operator Integrations panel filters `WHERE provider_kind = 'provisioning'` — Stripe is a provider but never an "Integration". | | `status` | VARCHAR(20) | `active`, `suspended`, `retired` — soft-delete discipline: a retired provider's historical webhook rows stay referentially valid. | | `suspended_at`, `retired_at` | TIMESTAMPTZ | When the corresponding terminal/suspension transitions occurred. | | `operator_surface_path` | VARCHAR(255) | Registry data absorbed at ratification (Doc 44): the operator route path is manifest-declared; rendering and chrome remain member-console code. NULL for non-provisioning providers. | **Registration split (Decision 132 §6.3):** capability rows — a provider's `provider_operations`, `provider_states`, and owned `resource_keys` — are **boot-registered from a typed manifest** authored beside the behavior (the `ProviderSource` pattern), so declaration and implementation cannot drift apart silently; operational and commercial rows — `status`, eventual per-cooperative enablement — are **database-canonical and operator-editable**, and boot re-registration must not clobber them. A boot/lint conformance check (every declared operation resolves to an implemented activity; no orphan activity lacks a declaration) is normative; its as-built absence is card-tracked debt (Doc 44 §3). **Transport (Decision 131 as ratified):** transport is a per-provider capability decision. The outbox remains the default and is required where its transactional guarantee is load-bearing (payment-grade; Stripe); a provisioning provider whose workflows own their database writes end-to-end may dispatch directly via Temporal. Report-back is uniform: all providers report via `integration.webhook_events`. **As-built drift (card-tracked, Doc 44 §2):** the as-built registry currently lacks the status CHECK and the two lifecycle timestamps; the designed text deliberately retains them. **Relationships:** - `providers` (1) → (0..*) `provider_operations`: A provider declares its operation set. - `providers` (1) → (0..*) `provider_states`: A provider declares its lifecycle-state vocabulary. - `providers` (1) → (0..*) `entitlements.resource_keys`: Provider-owned resource keys name their owner. `FK ← [entitlements] resource_keys.provider` - Designed-deferred (Doc 39 open question 3; sequence with partition maintenance): FK promotion of `integration.webhook_events.provider` and `integration.integration_outbox.target_provider` onto this registry. --- ### provider_operations A **provider_operation** row declares one lifecycle verb a provider honors — making "which operations does FedWiki support?" a constrained, enumerable fact rather than tribal knowledge, so the operator UI renders only honored verbs and an outbox `event_type` can be validated against the declaration (normative; the as-built validation is deferred with the dispatch layer, Doc 44 §3). Rows are boot-registered from the manifest and reconciled (verbs removed from the manifest are deleted). ```sql CREATE TABLE billing.provider_operations ( provider VARCHAR(50) NOT NULL REFERENCES billing.providers(provider) ON DELETE RESTRICT, operation VARCHAR(40) NOT NULL CHECK (operation IN ('create', 'set_status', 'delete', 'list', 'describe')), PRIMARY KEY (provider, operation) ); ``` The verb set as ratified (Decisions 132–133, amended per Doc 44): a **write class** — `create`, `set_status`, `delete` — acting on an instance the caller holds, and a **read class** — `list`, `describe` — by which the platform enumerates a provider's instances and their states (bootstrap and reconciliation pull for the Decision 133 projection). `set_status` replaces per-nuance verbs (`suspend`, `set_readonly`): the target state is data, drawn from the provider's declared `provider_states` vocabulary — a closed verb set over an open state vocabulary, so the contract needs no new verb per lifecycle nuance. A provider needing a verb outside the set is a contract-completeness signal (the worked instance: set-shaped converged delivery, Issue 33). --- ### provider_states A **provider_state** row declares one lifecycle state in a provider's vocabulary — the value space `set_status` operates over, and the states the Decision 133 desired/observed projection renders. Declared in the manifest (`States`) and boot-registered, with state-set agreement validated at registration. The designed shape is the minimal child lookup; auxiliary columns, if any, follow the as-built registry. ```sql CREATE TABLE billing.provider_states ( provider VARCHAR(50) NOT NULL REFERENCES billing.providers(provider) ON DELETE RESTRICT, state VARCHAR(40) NOT NULL, PRIMARY KEY (provider, state) ); ``` **Per-instance state itself is not here** (Decision 131): canonical per-instance identity and state live provider-side (the per-provider schemas of Decision 86); the domain owns the aggregate count and, where a hot path demands it, a consumer-side desired/observed projection that is a cache, never canonical, and never a source for billing-grade facts (Decision 87). --- ## Cooperative Governance — `cooperative` schema **Status:** Early development — module structure defined; full accounting depth deferred to Issue 16. Cooperative Governance answers: "What value has this member contributed, and how does that participation translate into cooperative standing?" This module observes billing outcomes; it does not participate in billing execution. Patronage events are written via Temporal workflows triggered by payment settlement. Billing execution continues unaffected if this module is unavailable. ### patrons A **patron** is a billing account viewed through the lens of cooperative value contribution. Patron records materialize when the first patronage event is recorded. Moved from billing module per Decision 108. | Field | Type | Purpose | |-------|------|---------| | `patron_id` | UUID | Primary key | | `billing_account_id` | UUID | FK → billing.billing_accounts. The billing account this patron represents. One-to-one. | | `patron_number` | VARCHAR(50) | Human-readable identifier (e.g., "P-2024-0001"). | | `first_patronage_date` | DATE | Date of first patronage event. | | `lifetime_gross` | INTEGER | Total gross patronage (smallest currency unit). Denormalized. | | `lifetime_net` | INTEGER | Total net patronage (after fees). Denormalized. | | `status` | VARCHAR(20) | `active`, `inactive`. | | `deactivated_at` | TIMESTAMPTZ | When deactivated. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Relationships:** - `patrons` (0..*) → (1) `billing.billing_accounts`: FK → [billing] billing_accounts. One-to-one. - `patrons` (1) → (0..*) `patronage_events`: Patrons generate patronage events. (intra-module) --- ### patronage_events A **patronage_event** records a single contribution of value from a patron. Patronage is a cooperative governance concept, not an operational billing concept. Append-only; refunds produce compensating events. Moved from billing module per Decision 108. | Field | Type | Purpose | |-------|------|---------| | `event_id` | UUID | Primary key | | `patron_id` | UUID | FK → patrons. The patron who generated this event. | | `fiscal_year` | INTEGER | Fiscal year. Pre-computed. | | `fiscal_quarter` | INTEGER | Fiscal quarter (1-4). Pre-computed. | | `fiscal_month` | INTEGER | Fiscal month (1-12). Pre-computed. | | `event_type` | VARCHAR(50) | `subscription_payment`, `usage_charge`, `one_time_purchase`, `service_fee`, `refund`, `adjustment`, `credit`. | | `source_type` | VARCHAR(50) | What generated this: `payment`, `invoice`, `credit`, `manual`. Governs which source FK is populated. | | `source_payment_id` | UUID | FK → billing.payments. If this event was sourced from a payment. NULL otherwise. | | `source_invoice_id` | UUID | FK → billing.invoices. If this event was sourced from an invoice. NULL otherwise. | | `product_id` | UUID | FK → billing.products. Product this patronage is attributed to, if applicable. | | `currency` | VARCHAR(3) | Currency of the amounts. | | `gross_amount` | INTEGER | What the patron paid. | | `fees_amount` | INTEGER | Payment processor fees. | | `net_amount` | INTEGER | What the cooperative retained. | | `event_date` | DATE | Date of the event. | | `event_timestamp` | TIMESTAMPTZ | Exact time. | | `description` | TEXT | Human-readable description. | | `metadata` | JSONB | Additional event attributes. | | `created_at` | TIMESTAMPTZ | | **Constraints (partial exclusive arc — source):** ```sql CHECK ( (source_type = 'payment' AND source_payment_id IS NOT NULL AND source_invoice_id IS NULL) OR (source_type = 'invoice' AND source_payment_id IS NULL AND source_invoice_id IS NOT NULL) OR (source_type IN ('credit', 'manual') AND source_payment_id IS NULL AND source_invoice_id IS NULL) ) ``` **Relationships:** - `patronage_events` (0..*) → (1) `patrons`: Events belong to a patron. (intra-module) - `patronage_events` (0..*) → (0..1) `billing.products`: FK → [billing] products. Product attribution. - Exclusive arc — source: - `patronage_events` (0..*) → (0..1) `billing.payments`: FK → [billing] payments. - `patronage_events` (0..*) → (0..1) `billing.invoices`: FK → [billing] invoices. --- ## Audit — `audit` schema ### audit_logs An **audit_log** records actions taken within the system. It provides an immutable trail of who did what, when, and to what. The actor model distinguishes the *identity* (person or service account) from the *credential* (session, PAT, API key) used to authenticate. Serves as the complete history for repeated state transition cycles and entitlement toggle history. The table is partitioned by monthly RANGE on `created_at`. The composite primary key `(log_id, created_at)` is required because PostgreSQL enforces that unique constraints on partitioned tables must include all partition key columns. Per-partition uniqueness is acceptable — UUIDv7 collision probability is negligible, and audit records are never referenced by foreign keys from other tables. Events are classified at write time into one of five retention tiers. The tier determines how long the event is retained before archival or deletion: `critical` (20 years — financial/patronage), `security` (7 years — auth/access), `compliance` (7 years — SOC 2 evidence), `operational` (1 year — API calls, navigation), `debug` (90 days — diagnostic). Tier assignment is a write-time classification; changing the tier of an existing event is not expected. | Field | Type | Purpose | |-------|------|---------| | `log_id` | UUID | Composite PK with `created_at`. | | `created_at` | TIMESTAMPTZ | When the action occurred. Partition key. Composite PK with `log_id`. | | `actor_type` | VARCHAR(20) | Identity type: `person`, `service_account`, `system`. | | `actor_person_id` | UUID | FK → identity.persons. If actor is a person. | | `actor_service_account_id` | UUID | FK → organization.service_accounts. If actor is a service account. | | `actor_credential_type` | VARCHAR(20) | How they authenticated: `session` (browser), `pat` (personal access token), `api_key` (service account key), `oidc_client` (Keycloak client credentials), `system` (internal). | | `actor_credential_id` | UUID | ID of the credential used (token_id or key_id). NULL for session and system. | | `actor_ip` | INET | IP address of the actor. | | `actor_user_agent` | TEXT | Browser/client user agent string. | | `entity_type` | VARCHAR(50) | What kind of entity was affected. | | `entity_id` | UUID | ID of the affected entity. | | `org_id` | UUID | FK → organization.organizations. Organization context, for filtering. | | `action` | VARCHAR(50) | What happened (e.g., `create`, `update`, `delete`, `login`). | | `from_status` | VARCHAR(50) | Previous status value for status transition events. NULL for non-status-change actions. Materialized from `changes` JSONB for query performance — JSONB lacks column-level statistics and requires 2× storage overhead for expression indexes. | | `to_status` | VARCHAR(50) | New status value for status transition events. NULL for non-status-change actions. Materialized alongside `from_status`. | | `changes` | JSONB | Details of what changed. Must not contain PII. Status transitions also recorded here as `{"status": {"from": "X", "to": "Y"}}` for backward compatibility. | | `metadata` | JSONB | Additional context. Must not contain PII. | | `request_id` | VARCHAR(100) | Request correlation ID for log tracing. | | `tier` | VARCHAR(20) | Retention tier classification: `critical`, `security`, `compliance`, `operational`, `debug`. Assigned at write time. Determines retention duration via `audit_retention_policies`. | | `severity` | VARCHAR(20) | Event severity: `critical`, `high`, `medium`, `low`, `info`. | | `status` | VARCHAR(20) | Outcome: `success`, `failure`, `partial`. | **Constraints:** ```sql CHECK (severity IN ('critical', 'high', 'medium', 'low', 'info')) CHECK (tier IN ('critical', 'security', 'compliance', 'operational', 'debug')) ``` **Indexing budget (6 indexes):** - 3 composite B-tree: `(entity_type, entity_id, created_at)` for entity lookup; `(actor_person_id, created_at)` and `(actor_service_account_id, created_at)` for actor lookup; `(org_id, action, created_at)` for action filtering. - 1 BRIN: `created_at` for time-range scans (BRIN exploits the near-perfect correlation between insert order and timestamp on append-only tables). - 2 partial: `WHERE status = 'failure' AND action = 'login'` for failed authentication monitoring; `WHERE severity IN ('critical', 'high')` for high-severity event alerting. **Write architecture:** Governance-critical events (financial transactions, access control changes, authentication events) are written synchronously within the business transaction. Operational and debug events may use buffered batch inserts for throughput efficiency. The specific buffering implementation is deferred to build time. **Polymorphic association rationale:** This table contains two intentional bare polymorphic associations that are exceptions to the model's general preference for exclusive arcs: - **`entity_type` + `entity_id`**: The target entity can be any of the 59 tables in the system. Neither an exclusive arc (one nullable FK column per target table) nor class table inheritance is appropriate. The entity reference is a metadata annotation on an append-only record, not a navigable relational reference. Per the polymorphic pattern policy (Gate 0), metadata annotations on append-only observational tables may use bare polymorphic associations. - **`actor_credential_type` + `actor_credential_id`**: Only two of five credential types (`pat`, `api_key`) have table-backed FK targets. Sessions, OIDC client credentials, and system credentials do not have dedicated tables. The credential reference is informational metadata, not a relational concern. The actor *identity* side (`actor_person_id`, `actor_service_account_id`) uses proper FK columns because identity is a relational concern. **Relationships:** - `audit_logs` (0..*) → (0..1) `identity.persons`: Logs may reference an acting person. - `audit_logs` (0..*) → (0..1) `organization.service_accounts`: Logs may reference an acting service account. - `audit_logs` (0..*) → (0..1) `organization.organizations`: Logs may be scoped to an organization. --- ### audit_retention_policies An **audit_retention_policy** defines the lifecycle duration for each retention tier. This is a static configuration table, seeded at deployment and rarely changed. Each row maps a tier to its hot, warm, cold, and frozen durations, defining how long audit events in that tier remain at each storage temperature before transitioning to the next. | Field | Type | Purpose | |-------|------|---------| | `policy_id` | UUID | Primary key | | `tier` | VARCHAR(20) | The retention tier this policy governs. Unique. | | `description` | TEXT | Human-readable description of what this tier covers. | | `hot_duration_months` | INTEGER | Months in hot storage (primary PostgreSQL, full indexes). | | `warm_duration_months` | INTEGER | Months in warm storage (PostgreSQL, reduced indexes). NULL if tier transitions directly to cold. | | `cold_duration_months` | INTEGER | Months in cold storage (columnar files outside PostgreSQL). NULL if tier has no cold phase. | | `total_retention_months` | INTEGER | Total retention obligation in months. Computed from tier requirements (e.g., 240 for `critical`). | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Constraints:** - `tier` is unique. **Seed data:** | Tier | Hot | Warm | Cold | Total | |------|-----|------|------|-------| | `critical` | 12 | 72 | 156 | 240 (20yr) | | `security` | 12 | 36 | 36 | 84 (7yr) | | `compliance` | 12 | 36 | 36 | 84 (7yr) | | `operational` | 6 | 6 | — | 12 (1yr) | | `debug` | 3 | — | — | 3 (90d) | --- ### audit_legal_holds An **audit_legal_hold** records an active legal hold that blocks automated archival or anonymization of audit event data within scope. This is structurally parallel to `retention_holds` (which protects person PII from premature erasure) but scoped to audit event data by time range, tenant, actor, and entity. The key distinction: `retention_holds` protects **person PII** at the record level — a specific person's data cannot be anonymized while a hold is active. `audit_legal_holds` protects **audit event data** at the partition level — audit partitions within the hold's scope cannot be archived, anonymized, or dropped while a hold is active. Both are "hold" mechanisms from the same compliance family, with different targets. | Field | Type | Purpose | |-------|------|---------| | `hold_id` | UUID | Primary key | | `hold_name` | VARCHAR(255) | Human-readable name for this hold (e.g., "SEC Investigation 2026-Q3", "Member dispute #4412"). | | `legal_authority` | VARCHAR(100) | The legal basis or requesting authority (e.g., `sec_investigation`, `subpoena`, `internal_audit`, `member_dispute`). | | `description` | TEXT | Detailed explanation of what triggered the hold and what it protects. | | `scope_start` | TIMESTAMPTZ | Earliest `created_at` of audit events covered by this hold. | | `scope_end` | TIMESTAMPTZ | Latest `created_at` of audit events covered by this hold. NULL for open-ended holds. | | `scope_org_id` | UUID | FK → organization.organizations. If the hold is scoped to a specific organization. NULL for platform-wide holds. | | `scope_actor_person_id` | UUID | FK → identity.persons. If the hold is scoped to a specific actor. NULL for broader holds. | | `scope_entity_type` | VARCHAR(50) | If the hold is scoped to a specific entity type. NULL for all entity types. | | `scope_entity_id` | UUID | If the hold is scoped to a specific entity. NULL for broader holds. | | `hold_placed_at` | TIMESTAMPTZ | When the hold was placed. | | `hold_placed_by` | UUID | FK → identity.persons. Who placed the hold. | | `hold_expires_at` | TIMESTAMPTZ | When the hold is scheduled to expire. NULL if expiration depends on a future event. | | `hold_released_at` | TIMESTAMPTZ | When the hold was released. NULL if still active. | | `hold_released_by` | UUID | FK → identity.persons. Who released the hold. | | `release_reason` | TEXT | Why the hold was released. | | `status` | VARCHAR(20) | `active`, `released`, `expired`. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Behavioral contract:** The archival pipeline must check for active `audit_legal_holds` before archiving or dropping any partition. If any active hold's scope overlaps with the partition's time range (and optionally org/actor/entity scope), the partition must not be archived or dropped. This check operates at the partition level, unlike `retention_holds` which operates at the record level. **Relationships:** - `audit_legal_holds` (0..*) → (0..1) `organization.organizations` (via `scope_org_id`): Holds may be scoped to an organization. - `audit_legal_holds` (0..*) → (0..1) `identity.persons` (via `scope_actor_person_id`): Holds may be scoped to a specific actor. - `audit_legal_holds` (0..*) → (1) `identity.persons` (via `hold_placed_by`): Holds are placed by a person. --- ### audit_archive_manifest An **audit_archive_manifest** tracks the lifecycle state of each audit log partition as it transitions through storage tiers: hot → warm → cold → frozen. This is operational metadata for the archival pipeline — it records where each partition is, when it transitioned, and integrity verification status. | Field | Type | Purpose | |-------|------|---------| | `manifest_id` | UUID | Primary key | | `partition_name` | VARCHAR(255) | PostgreSQL partition name (e.g., `audit_logs_2026_01`). Unique. | | `partition_start` | TIMESTAMPTZ | Start of the partition's time range. | | `partition_end` | TIMESTAMPTZ | End of the partition's time range. | | `row_count` | BIGINT | Number of rows in the partition at time of last transition. | | `storage_tier` | VARCHAR(20) | Current storage tier: `hot`, `warm`, `cold`, `frozen`. | | `hot_at` | TIMESTAMPTZ | When the partition was created (entered hot tier). | | `warm_at` | TIMESTAMPTZ | When transitioned to warm tier. NULL if still hot. | | `cold_at` | TIMESTAMPTZ | When transitioned to cold tier (detached from PostgreSQL, exported to columnar files). NULL if not yet cold. | | `frozen_at` | TIMESTAMPTZ | When transitioned to frozen tier (long-term archival). NULL if not yet frozen. | | `dropped_at` | TIMESTAMPTZ | When the partition was dropped from PostgreSQL. NULL if still attached. | | `archive_path` | TEXT | Location of the archived partition file (e.g., S3 path, local path). NULL while hot/warm. | | `archive_checksum` | VARCHAR(128) | Integrity checksum of the archived file. NULL while hot/warm. | | `archive_verified_at` | TIMESTAMPTZ | When the archive integrity was last verified. | | `legal_hold_block` | BOOLEAN DEFAULT FALSE | Whether an active legal hold is preventing this partition from advancing. | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | **Constraints:** - `partition_name` is unique. - `storage_tier` CHECK: `IN ('hot', 'warm', 'cold', 'frozen')`. **Relationships:** - `audit_archive_manifest` is a standalone operational table. It does not have FK relationships to other tables — it references partitions by name and time range, not by relational join. --- ### audit_outbox An **audit_outbox** entry represents an audit event that needs to be delivered to an external consumer (SIEM, analytics pipeline, compliance reporting). The outbox INSERT is performed in the same transaction as the audit event INSERT, guaranteeing at-least-once delivery semantics without distributed transactions. | Field | Type | Purpose | |-------|------|---------| | `outbox_id` | UUID | Primary key | | `log_id` | UUID | The audit event this outbox entry refers to. Not an FK (audit_logs has a composite PK and is partitioned). | | `created_at` | TIMESTAMPTZ | When this outbox entry was created (matches the audit event's `created_at`). | | `destination` | VARCHAR(100) | Target system identifier (e.g., `siem`, `analytics`, `compliance_export`). | | `payload` | JSONB | Event payload for the external consumer. May be a projection of the full audit event. | | `status` | VARCHAR(20) | `pending`, `delivered`, `failed`, `expired`. | | `attempts` | INTEGER DEFAULT 0 | Number of delivery attempts. | | `last_attempt_at` | TIMESTAMPTZ | When delivery was last attempted. | | `delivered_at` | TIMESTAMPTZ | When successfully delivered. | | `error_message` | TEXT | Last delivery error, if any. | **Constraints:** - Index on `(status, created_at)` for the relay query pattern: `WHERE status = 'pending' ORDER BY created_at LIMIT $batch_size`. **Relay pattern:** An external relay process polls the outbox, delivers events to external consumers, and marks entries as `delivered`. The specific relay implementation (polling interval, batch size, consumer protocol) is deferred to build time. --- ## Integration Infrastructure — `integration` schema These tables support integration with external services (payment processors, infrastructure provisioning, tax computation, notification delivery). They live in the `integration` schema because they are shared across all providers — unlike provider-specific mapping tables, which live in per-provider schemas (e.g., `stripe`, `polar`). ### webhook_events A **webhook_event** records an inbound event received from an external provider. This table provides idempotency (deduplication by provider event ID), auditability, and queryable webhook history for debugging and replay. Processing of webhook events is handled by the application layer (e.g., Temporal workflows). The table is partitioned by monthly RANGE on `received_at`, matching the audit log partitioning pattern. | Field | Type | Purpose | |-------|------|---------| | `event_id` | UUID | Composite PK with `received_at`. | | `received_at` | TIMESTAMPTZ | When the event was received. Partition key. Composite PK with `event_id`. | | `provider` | VARCHAR(50) | Which provider sent this event: `stripe`, `fedwiki`, `discourse`, etc. Values match `billing.providers` slugs; the FK promotion is designed-deferred (Doc 39 open question 3 — sequence with partition maintenance). | | `provider_event_id` | VARCHAR(500) | The provider's unique identifier for this event. | | `event_type` | VARCHAR(100) | Event type as classified by the provider (e.g., `invoice.finalized`, `subscription.updated`). | | `payload` | JSONB | Full raw webhook payload. Must not contain PII per JSONB governance policy. | | `status` | VARCHAR(20) | Processing state: `received`, `processing`, `completed`, `failed`, `skipped`. | | `processing_started_at` | TIMESTAMPTZ | When processing began. | | `completed_at` | TIMESTAMPTZ | When processing completed. | | `error_message` | TEXT | Error detail if processing failed. | | `retry_count` | INTEGER | Number of processing attempts. | **Constraints and the dedup contract (corrected at v16 — Doc 44 §3):** - A global `UNIQUE (provider, provider_event_id)` **cannot exist on this table**: PostgreSQL requires every unique constraint on a partitioned table to include the partition key (the same rule that shaped `audit_logs`' composite PK), so the in-table constraint is `UNIQUE (provider, provider_event_id, received_at)` — a backstop against *same-instant* redelivery only. - **Cross-time idempotency is therefore a handler-level contract**, binding on every provider's webhook handler: insert guarded by existence over `(provider, provider_event_id)` (`INSERT … WHERE NOT EXISTS`), so a redelivery hours later is skipped rather than reprocessed. The earlier claim that the unique constraint alone enforced idempotency was drift; the constraint under-specified precisely the cross-time case that matters for replayed webhooks. **Relationships:** - `webhook_events` is a standalone operational table. `provider` matches `billing.providers` slugs (FK promotion designed-deferred); event type is text. --- ### integration_outbox An **integration_outbox** entry represents a domain state change that must trigger an action in an external system (e.g., provisioning a Nextcloud instance when an entitlement activates, syncing a product to Stripe when it's created). The outbox INSERT is performed in the same transaction as the domain state change, guaranteeing that no integration trigger is lost even if the application crashes after commit. A background process (e.g., a Temporal workflow) polls the outbox and executes the external action. This is the same transactional outbox pattern used by `audit_outbox` for audit event delivery. | Field | Type | Purpose | |-------|------|---------| | `outbox_id` | UUID | Primary key | | `aggregate_type` | VARCHAR(50) | The type of core entity that changed (e.g., `subscription`, `entitlement`, `invoice`). | | `aggregate_id` | UUID | ID of the core entity that changed. | | `event_type` | VARCHAR(100) | What happened (e.g., `entitlement.activated`, `invoice.finalized`, `product.created`). | | `target_provider` | VARCHAR(50) | Which provider should handle this event: `stripe`, `nextcloud`, `notify`, etc. | | `payload` | JSONB | Event payload for the handler. Must not contain PII per JSONB governance policy. | | `status` | VARCHAR(20) | `pending`, `processing`, `completed`, `failed`, `dead_letter`. | | `attempts` | INTEGER | Number of delivery attempts. | | `max_attempts` | INTEGER | Maximum delivery attempts before moving to dead letter. | | `next_attempt_at` | TIMESTAMPTZ | When the next attempt should be made. Supports exponential backoff. | | `last_error` | TEXT | Last delivery error, if any. | | `created_at` | TIMESTAMPTZ | | | `completed_at` | TIMESTAMPTZ | When successfully processed. | **Constraints:** - Index on `(status, next_attempt_at)` for the polling query pattern: `WHERE status IN ('pending', 'failed') AND next_attempt_at <= NOW() ORDER BY next_attempt_at LIMIT $batch_size`. **Relationships:** - `integration_outbox` is a standalone operational table. `aggregate_type` and `target_provider` are text fields, not FK references. --- ## Polymorphic Pattern Policy This model uses a decision heuristic to determine the appropriate pattern for each polymorphic relationship. The heuristic is applied per-relationship, not globally; different patterns coexist within the schema. Exclusive arcs are noted inline throughout this document. **Gate 0: Is this reference a relational concern or a metadata annotation?** If the reference is a metadata annotation — recorded for observability, not for relational navigation; the referencing table is append-only; the target may not exist by the time the reference is read — a bare polymorphic association (`type` + `id`) is acceptable. Document the rationale explicitly. If the reference is a relational concern — downstream logic depends on it, queries JOIN through it, referential integrity matters — proceed with the pattern selection heuristic: 1. Parent types are **not** subtypes of a meaningful abstraction → **Exclusive arc** 2. Multiple tables reference the polymorphic set → **Class table inheritance** 3. Type set is large (>5–8) or expected to grow → **Class table inheritance** 4. Queries frequently aggregate across all types → **Class table inheritance** 5. Otherwise → **Exclusive arc** (simpler, fewer moving parts) --- ## Index of Tables | # | Table | Module | Purpose | |---|-------|-------|---------| | 1 | `users` | Identity | Authentication identity (Keycloak) | | 2 | `persons` | Identity | Human/business identity | | 3 | `personal_access_tokens` | Identity | Programmatic credentials for persons | | 4 | `retention_holds` | Identity | Legal retention obligations on person PII | | 5 | `person_merges` | Identity | Duplicate person resolution tracking | | 6 | `organizations` | Organization | Top-level container | | 7 | `service_accounts` | Organization | Non-human actors | | 8 | `service_account_keys` | Organization | Credentials for service accounts | | 9 | `roles` | Organization | Permission definitions | | 10 | `org_members` | Organization | Organization membership | | 11 | `invitations` | Organization | Invitation lifecycle management | | 12 | `workspaces` | Organization | Resource containers | | 13 | `role_assignments` | Organization | Scoped permission grants | | 14 | `resource_pools` | Entitlements | Capability aggregation and distribution | | 15 | `pool_provisions` | Entitlements | Sources of pool capabilities | | 16 | `pool_provision_ladders` | Entitlements | Junction: which ladder rung each active provision occupies | | 17 | `pool_provision_transitions` | Entitlements | Append-only plan-position audit (initiate/upgrade/downgrade/transfer/end) | | 18 | `pool_assignments` | Entitlements | Pool-to-workspace links | | 19 | `pool_ondemand_config` | Entitlements | On-demand resource pricing configuration | | 20 | `boolean_entitlements` | Entitlements | Binary capabilities on pools | | 21 | `numeric_entitlements` | Entitlements | Numeric limits and quotas on pools (definition) | | 22 | `numeric_entitlement_contributions` | Entitlements | Per-provision contributions to numeric entitlement limits | | 23 | `numeric_entitlement_usage` | Entitlements | Mutable numeric entitlement consumption state | | 24 | `usage_events` | Entitlements | Resource consumption records | | 25 | `resource_keys` | Entitlements | Shared resource key namespace | | 26 | `grants` | Entitlements | Non-commercial access provisioning | | 27 | `entitlement_sets` | Entitlements | Named, reusable collections of entitlement rules | | 28 | `entitlement_set_rules` | Entitlements | Set-to-entitlement configuration (formerly product_entitlement_rules) | | 29 | `billing_accounts` | Billing | Payment entity | | 30 | `products` | Billing | Product catalog | | 31 | `prices` | Billing | Commercial terms | | 32 | `subscriptions` | Billing | Recurring billing agreements | | 33 | `subscription_items` | Billing | Subscription line items | | 34 | `subscription_changes` | Billing | Subscription audit trail (record) | | 35 | `subscription_scheduled_changes` | Billing | Forward-looking change intent (deferred plan switch, quantity change, cancellation) | | 36 | `purchases` | Billing | One-time transactions | | 37 | `invoices` | Billing | Billing documents | | 38 | `invoice_line_items` | Billing | Per-item invoice detail | | 39 | `payments` | Billing | Money received | | 40 | `payment_methods` | Billing | Payment methods on file | | 41 | `refunds` | Billing | Partial or full payment returns | | 42 | `disputes` | Billing | Payment chargebacks and disputes | | 43 | `coupons` | Billing | Coupon rule definitions | | 44 | `promotion_codes` | Billing | Customer-facing distribution codes | | 45 | `discounts` | Billing | Active discount applications | | 46 | `pending_charges` | Billing | Mutable pre-invoice financial obligations | | 47 | `pending_charge_usage_events` | Billing | Links usage events to pending charges | | 48 | `credit_grants` | Billing | Prepaid or promotional credit allocations | | 49 | `credit_transactions` | Billing | Immutable credit/debit ledger | | 50 | `plan_ladders` | Billing | Named mutual-exclusion sets declaring which products are alternatives | | 51 | `plan_ladder_tiers` | Billing | Ordered product membership within a plan ladder | | 52 | `providers` | Billing | Canonical provider registry (seam table: provider/extension seam) | | 53 | `provider_operations` | Billing | Boot-registered lifecycle verbs per provider (write + read classes) | | 54 | `provider_states` | Billing | Provider-declared lifecycle-state vocabularies | | 55 | `patrons` | Cooperative | Cooperative member identity overlay on billing accounts | | 56 | `patronage_events` | Cooperative | Append-only cooperative value contribution history | | 57 | `audit_logs` | Audit | Action history (partitioned) | | 58 | `audit_retention_policies` | Audit | Retention tier lifecycle configuration | | 59 | `audit_legal_holds` | Audit | Legal holds on audit event data | | 60 | `audit_archive_manifest` | Audit | Partition lifecycle and archival tracking | | 61 | `audit_outbox` | Audit | Transactional outbox for external consumers | | 62 | `webhook_events` | Integration | Inbound webhook events (partitioned) | | 63 | `integration_outbox` | Integration | Transactional outbox for external integrations |