On this page
Database schema
CommonLayer keeps all state in one PostgreSQL database (version 16 or later; developed
against 18): 121 tables and 2 views in 18 migration files (schema version 21) under
crates/commonlayer-database/migrations/, embedded into the binaries and applied with
clctl migrate, which also grants the least-privilege runtime role its privileges. This page explains the schema by domain and lists the invariants the
database enforces by itself, independent of application code.
The column-level definition is the SQL in the migration files; this page does not repeat every column. Where the implementation added tables or columns beyond the original plan, that is noted.
Conventions
- Primary keys are UUIDv7 generated by the application, unless stated.
- Enumerations are
textwith aCHECK (col IN (…))constraint, never PostgreSQL enum types, so adding a value is an ordinary migration. - Money is
<name>_minor bigintpluscurrency char(3); nevernumericmoney, never floating point (ADR-0014). - Digests are
text CHECK (digest ~ '^sha256:[a-f0-9]{64}$'). - Case-insensitive identifiers (
email,username,slug) usecitext. updated_atis maintained by the shared triggerset_updated_at().- Financial, audit and artifact-history rows are never cascade-deleted (
ON DELETE RESTRICT). - Extensions:
citext,pg_trgm,btree_gist,pgcrypto.
Custom SQLSTATEs
Triggers raise stable error codes that the application maps to API error codes
(commonlayer_core::Error::from(sqlx::Error)):
| SQLSTATE | Meaning | API code |
|---|---|---|
CL001 | An internal ledger account would go below zero | funding.insufficient_funds |
CL002 | Update, delete or truncate of an append-only table | database.append_only |
CL003 | An immutable value changed (digest identity, immutable tag, permanent SLA fact) | database.immutable |
CL004 | An organization would lose its last admin | organizations.last_admin |
CL005 | Direct write to ledger_balances | — (internal) |
CL006 | Somebody decides about themselves (reward approval) | authz.self_decision |
CL007 | Parts do not sum to the whole (allocation slots, contribution items) | database.sum_mismatch |
CL008 | Ledger entry currency differs from its accounts | funding.currency_mismatch |
Migrations and ownership
Each migration file belongs to the domain crate that owns its tables. Only that crate writes those tables.
| File | Domain (crate) | Tables |
|---|---|---|
0001_foundation.sql | platform (commonlayer-database) | jobs, domain_events, scheduler_runs, platform_settings, idempotency_keys |
0002_identity.sql | identity (commonlayer-auth) | users, user_identities, sessions, user_tokens, api_tokens, oauth_states |
0003_organizations.sql | organizations | organizations, organization_members, organization_invitations, billing_profiles, organization_notes |
0004_audit.sql | audit | audit_logs, audit_checkpoints |
0005_projects.sql | projects | project_categories, projects, project_members, project_repositories, project_supported_versions, project_documents, project_follows, migration_candidates |
0006_artifacts.sql | artifacts | artifacts, artifact_versions, artifact_digests, artifact_architectures, artifact_tags, artifact_tag_history, artifact_signatures, artifact_attestations, artifact_sboms, sbom_components, helm_charts, helm_chart_versions, build_runs, verification_policies, artifact_submissions, artifact_submission_steps, artifact_verifications, release_history |
0007_registry.sql | registry | registry_events, artifact_pull_stats, retention_plans, retention_plan_items, preservation_checks |
0008_security.sql | security | vulnerabilities, vulnerability_aliases, security_digest_index, vulnerability_scans, vulnerability_occurrences, vulnerability_findings, vulnerability_finding_events, vulnerability_finding_deadlines, vulnerability_exceptions, vex_statements, security_advisories, security_advisory_sequences, security_reports, security_report_messages, security_incidents, security_incident_updates, vulnerability_feed_state |
0009_maintenance.sql | maintenance | maintainers, maintenance_agreements, maintenance_agreement_acceptances, maintenance_slas, maintainer_assignments, maintainer_assignment_events, maintenance_tasks, eligibility_evaluations, eligibility_appeals, maintainer_suspensions, contributions |
0010_funding.sql | funding | ledger_accounts, ledger_balances, ledger_transactions, ledger_entries, project_treasuries, project_funding_controls, allocation_policies, allocation_slots, funding_campaigns, funding_subscriptions, funding_subscription_items, funding_contributions, funding_contribution_items, funding_receipt_sequences, payment_methods, payment_webhooks, funding_cycles, funding_cycle_lines, community_rewards, community_reward_approvals, payout_obligations, payout_runs, payouts, infrastructure_allocations, infrastructure_expenses, reconciliation_runs |
0011_github.sql | GitHub | github_installations, github_repositories, github_webhook_deliveries, github_pull_requests, github_pushes, github_releases |
0012_notifications.sql | notifications | webhook_endpoints, notifications, notification_preferences, notification_deliveries, notification_github_channels |
0013_authz_views.sql | identity | views effective_project_roles, effective_grants |
0014_runtime_role.sql | platform | no tables: the two trigger functions that write ledger_balances and artifact_tag_history become SECURITY DEFINER with a pinned search_path, so the runtime role needs no write privilege on those tables (ADR-0036) |
0015_assessment_confirmation.sql | security | vex_statement_confirmations; vex_statements.pending; CHECK decider ≠ author (ADR-0037) |
0016_repository_ids.sql | projects | repository ids unique among verified links only; one link per repository and project (security review MEDIUM-5) |
0020_funding_hardening.sql | funding | funding_contribution_status_tokens, funding_payment_aliases, funding_refund_holds, funding_provider_withdrawals; unique index community_rewards_one_per_contribution and the proposer trigger; payout status unknown, resolved_by, resolved_at, destination snapshot |
0021_abuse_reports.sql | organizations | abuse_reports |
Numbers 0017–0019 are unused (0020 and 0021 were numbered to leave room for parallel work).
Foreign keys point only to tables in the same or an earlier file. Up to 0013 owners edited
their own file in place (no database existed that had to be migrated); from 0014 on every
change is a new file, because development, test and stack databases already exist. A
process refuses to start when an embedded migration below the newest applied one is
missing (Db::ensure_schema_current).
Privileges. The table owner is the migration role. The runtime role that the
processes use gets, per table and derived from the catalogue: DML on ordinary tables,
INSERT only on append-only tables, INSERT and UPDATE (dispatched_at) on
domain_events, no DELETE on artifact_digests, SELECT on ledger_balances (plus the
UPDATE (updated_at) that row locks need; the guard trigger refuses every direct write),
read-only on artifact_tag_history and _sqlx_migrations, and never TRUNCATE,
TRIGGER, REFERENCES or CREATE (crates/commonlayer-database/src/roles.rs).
Platform foundation
jobs: the job queue (ADR-0004). Partial unique index onunique_keyfor pending and running jobs; claim index on(queue, priority DESC, run_at)for pending jobs. Extra columns beyond the plan:unique_until_started(lets the outbox dispatcher be re-queued while it runs),trace_parent,updated_at.domain_events: the outbox, with aseq bigserial, routing columns (project, organization, subject user), actor anddispatched_at. Append-only except for settingdispatched_at.scheduler_runs:(task, scheduled_for)primary key; an insert that conflicts means another scheduler replica already enqueued that slot.platform_settings: runtime-tunable settings with a version.idempotency_keys: stored responses for theIdempotency-Keyheader, scoped by caller scope and principal.
Identity and organizations
usersholds humans and service accounts (kind).CHECKs: humans have an email; a service account has no password, neverplatform_admin, exactly one owner (organization or project) and asa_rolematching that owner (column added by WP-02).sessions,api_tokens,user_tokens(verification, reset, email change) andoauth_statesstore only SHA-256 hashes of their secrets. The PKCE verifier inoauth_statesis sealed withSecretBox.user_identitieslinks external logins (github,oidc:<name>), unique per(provider, subject).organization_memberswith rolesmember,billing_manager,org_admin; a deferred constraint trigger keeps at least oneorg_adminper organization that is not deleted (CL004).billing_profilesbelong to exactly one organization or one user. Billing data is never written to the audit log.- Views
effective_project_roles(project members plus active maintainer assignments,security_reviewerassignments as their own role) andeffective_grants(platform role, organization roles, project roles and service-account roles, with scope slugs and names) give the authentication middleware all grants of a principal in one query.
Audit
audit_logshas no foreign keys (rows outlive their subjects), is append-only and indexed by project, organization, actor, resource and action.changesis a redacted top-level diff: keys matching the secret-name denylist show***.audit_checkpointsform a hash chain over batches of audit rows, written by theaudit.checkpointjob (rows older than five minutes) and verified byclctl audit verify. This gives tamper evidence for the audit log; it is stored in the same database, so it detects changes by someone who did not also rewrite the checkpoints (see the threat model).
Projects
projectscarries three independent state columns on purpose:lifecycle_state(proposed,in_review,active,underfunded,maintenance_degraded,unmaintained,deprecated,archived,rejected),funding_state(not_configured,fully_funded,partially_funded,underfunded,surplus,paused) andsecurity_state(not_scanned,no_known_issues,issues_within_sla,issues_overdue,stale_scan). A funded project can have overdue vulnerabilities and an unfunded one can be clean. A generatedtsvectorcolumn with a GIN index and a trigram index onnameserve search. Extra cached columns:funding_target_minor,funding_runway_bps,artifact_kinds,manifest_public_keys(WP-10).project_categoriesare seeded with the slugs the manifests use (ADR-0017).project_repositories: one primary repository per project (partial unique index);statusbecomesverifiedwhen the repository'scommonlayer.yamlnames the project.project_membersholdscontributorandproject_admin; the maintainer role is not stored there, it derives from active assignments.migration_candidatesis the Bitnami migration catalog loaded by catalog sync.
Artifacts and registry
artifact_digestsare never deleted;digest,media_typeandsize_bytesare immutable (trigger,CL003). Rejected submissions keep a digest row in staterejectedso the public digest page can explain the failure.artifact_tags: a trigger refuses to move, delete or make mutable a tag flagged immutable (<version>-r<revision>); another trigger writes every change toartifact_tag_history(append-only), so history cannot be skipped.artifact_submissionsandartifact_submission_steps(added by WP-11) record the pipeline; each step has a status, attempts, error and the evidence a resumed run continues from.artifact_verificationsstore the policy result and the effective policy snapshot.verification_policies: the platform default (project_idNULL, seeded) and optional stricter project policies; one active version per scope.release_historyis append-only.registry_events(pull and push notifications from Distribution) roll up intoartifact_pull_stats;retention_plans/retention_plan_itemsandpreservation_checksrecord retention and preservation.
Security
vulnerabilitieskeyed by a canonical id (CVE preferred, then GHSA, then the source id), withvulnerability_aliases; records later found to be the same are merged (merged_into).vulnerability_occurrencesbelong to one digest forever: publishing a fixed image never changes the occurrences of an older digest.vulnerability_findingsare the unit of triage per(project, vulnerability), with episodes for reopening.vulnerability_finding_deadlines(added by WP-12) hold one row per deadline per episode; a trigger refuses to clear a breach or an approaching notice (CL003) (ADR-0029).vulnerability_finding_eventsis the append-only timeline.vulnerability_exceptions:CHECK (decided_by IS DISTINCT FROM requested_by)and a unique index allowing one active exception per finding.security_reportsandsecurity_report_messagesstore bodies encrypted withSecretBox(body_enc).security_advisoriesgetCLSA-YYYY-NNNNidentifiers on publication fromsecurity_advisory_sequences(gapless per year among published advisories).security_digest_indexmaps digest strings to ids for the public digest routes, because the artifacts port has no lookup by digest value (WP-12 deviation).
Maintenance
maintainer_assignments: one activeprimaryand one activesecondaryper project (partial unique index, I7); an exclusion constraint (btree_gist) forbids two non-rejected assignments for the same(project, slot_key)with overlappingtstzrange(starts_at, ends_at)(I8). No column references a balance: replacing a maintainer touches no financial row.maintainer_assignment_eventsis append-only.eligibility_evaluationsstoreinputsandrule_resultsverbatim so a decision can be re-derived;override_statekeeps an administrator's override next to the computed state.CHECKs refuse self-override; appeals cannot be resolved by the appellant; only one open appeal per evaluation.- Further self-dealing
CHECKs: a maintainer cannot approve or decide their own application, waive their own task request, create or lift their own suspension, or review their own contribution. contributionshere means community work (pull requests, reviews, fixes), not money.
Funding
The funding tables are specified in detail in the ledger specification. In short:
ledger_accounts,ledger_transactions,ledger_entriesandproject_treasuriesare append-only.ledger_balancesis written only by the entry trigger, which enforces currency agreement (CL008) and non-negative internal balances (CL001); a guard refuses any other writer (CL005).allocation_slotsof an active policy must sum to its monthly target andfunding_contribution_itemsmust sum to their contribution (deferred constraint triggers,CL007).community_reward_approvalsis append-only; a trigger refuses an approver who is the recipient (CL006).payout_runs:CHECK (mode = 'dry_run' OR approved_by IS DISTINCT FROM initiated_by).payment_webhooksare unique per(provider, event_id).- Added by WP-14:
project_funding_controls(pause state),funding_receipt_sequences(gapless receipt numbers per year), and extra columns on contributions, obligations, payouts and payout runs.
GitHub and notifications
github_webhook_deliveriesare keyed by GitHub's delivery id, so a redelivery is acknowledged and counted but never processed twice.webhook_endpointshave exactly one owner (user, organization or project) and store their signing secret sealed withSecretBox.notification_deliveriesare unique per(event, channel, recipient).notification_github_channels(added by WP-15) configure which public events open an issue in a project's repository.
Invariants enforced by the database
These hold for any client that connects with the application's credentials, whatever
the application code does. They do not hold against a database superuser or table owner
who disables triggers; see the open items in the threat model.
crates/commonlayer-database/tests/it/invariants.rs tests them.
| # | Invariant | Mechanism |
|---|---|---|
| I1 | Ledger rows are never changed or removed | forbid_mutation triggers on ledger_accounts, ledger_transactions, ledger_entries, project_treasuries (CL002) |
| I2 | Every entry moves money between two different accounts of one currency | CHECK (debit_account_id <> credit_account_id), entry trigger (CL008) |
| I3 | Internal accounts never go negative | entry trigger (CL001) |
| I4 | A ledger transaction is applied at most once | idempotency_key UNIQUE |
| I5 | Active allocation slots sum to the monthly target | deferred constraint trigger (CL007) |
| I6 | Contribution items sum to the contribution | deferred constraint trigger (CL007) |
| I7 | One active primary and one active secondary maintainer per project | partial unique index |
| I8 | A slot is never occupied by two assignments at once | exclusion constraint |
| I9 | Nobody decides their own exception, approves their own reward or approves their own payout run | CHECKs and trigger (CL006) |
| I10 | An immutable tag never moves; a digest row never changes identity or disappears | triggers (CL003, CL002) |
| I11 | A provider event or GitHub delivery is processed at most once | unique keys |
| I12 | An organization always has an admin | deferred constraint trigger (CL004) |
| I13 | Audit rows are append-only and checkpointed | trigger plus checkpoint hash chain |
| — | Ledger balances are written only by the ledger trigger | guard trigger (CL005) |
| — | A breached SLA deadline stays breached | trigger (CL003) |
Working with the schema
make up # start PostgreSQL and the other development services
make db-check # apply every migration to an empty scratch database, twice
make migrate # apply migrations to the development database (DEV_DATABASE_URL, as its owner)
make db-reset # drop, recreate and migrate the development database
clctl migrate applies pending migrations with database.migration_url (the owner; falls
back to database.url when unset, as in plain development) and grants the role named by
database.runtime_role; clctl db check-role then proves that database.url is least
privilege. make dev-backend does both with a local runtime role. Production runs them as
a separate step (configuration refuses database.migrate_on_start = true in
production).