Files
member-console/design/data-model.md
T
cgalo5758 ac2760a0fe Add model catalog cards and ratify Doc 44
Write the eight domain-model cards under `docs/models/`, mark M10 10i
Done, and update the design docs to v16. Also records Doc 44, which
ratifies Decisions 129-133 as amended, and the Doc-39 conformance
check that satisfied its gate.
2026-08-22 03:11:19 -05:00

197 KiB
Raw Blame History

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, 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:

  • pendingactive: Invitation accepted, account established.
  • activeinactive: Administrative deactivation. Reversible.
  • activepartially_erased: Erasure requested but retention holds prevent full anonymization. Non-retained PII scrubbed. Not reversible.
  • active or inactiveanonymized: Full PII destruction. Irrevocable terminal state.
  • partially_erasedanonymized: All retention holds released. Remaining PII scrubbed. Irrevocable.
  • activemerged: 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_<random> — 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_idtarget_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_<random> (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):

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):

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_<random>. Token is regenerated on resend (old token becomes invalid, new hash stored).

Status lifecycle:

  • pendingsent: Notification delivered.
  • sentsent: Resend (resets expires_at, increments send_count).
  • sentaccepted, declined, expired, revoked: Terminal states, all irrevocable.
  • pendingexpired, 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):

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):

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):

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):

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:

-- 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.

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:

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:

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 §§56, 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_eventspending_chargesinvoice_line_itemsinvoices, 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 <provider>.<resource> 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):

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):

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:

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:

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:

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:

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:

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:

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) :

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).

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.

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.

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).

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 132133, amended per Doc 44): a write classcreate, set_status, delete — acting on an instance the caller holds, and a read classlist, 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.

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):

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:

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)

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 (>58) 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