Skip to content
CommonLayer
Documentation menu
On this page

Architecture overview

CommonLayer is a modular monolith: one Rust workspace of domain crates, composed into three long-running processes (API, worker, scheduler) and an operator CLI, plus a Next.js portal and a CNCF Distribution registry. PostgreSQL holds all state, including the job queue and the event outbox; S3-compatible object storage holds SBOMs, attestations, scan reports and registry blobs. This page describes the system as it is implemented; where something has only been tested locally or never against the real service, the implementation status says so.

Related pages: database schema, REST API, gRPC API, deployment, ledger specification, threat model, decisions.

Implementation state at a glance

PartState
Domain crates (identity, organizations, projects, artifacts, registry, security, maintenance, funding, GitHub, notifications)Implemented and tested per crate against real PostgreSQL
Composition root crates/commonlayer-appEvery domain composed: routers, jobs, subscribers, schedule, business gauges, cross-domain reactions; the development seed; acceptance tests of workflows A–E
commonlayer-api, -worker, -scheduler, clctlRun locally, in the Compose stack and (once) on kind; each process loads only the configuration and secrets it uses and connects as the least-privilege runtime database role. clctl has migrate, db check-role, config check, user create-admin, audit verify, ledger verify [--remote], secrets rotate, manifest validate, catalog sync, submit [--grpc], registry verify-preservation, seed, dev manifest-sync, permissions, openapi
gRPC12 services, 59 RPCs, served by the API process on :50051 (gRPC API)
Web portal apps/webPublic pages, dashboards, project and platform administration, documentation section
Platform deploymentContainer images, the Compose stack, a Helm chart (static tests; kind not yet green), OpenTofu modules (validated only), CI workflows; see deployment

System context

flowchart LR
  subgraph Clients
    B[Browser]
    K[docker / helm / kubelet]
    CI[Project CI on GitHub Actions]
    M[clctl and integrations]
  end
  subgraph CommonLayer
    W[web: Next.js]
    A[api: Axum]
    WK[worker]
    S[scheduler]
    R[registry: CNCF Distribution]
    PG[(PostgreSQL)]
    V[(Valkey, optional)]
    O[(S3-compatible storage)]
  end
  subgraph External
    GH[GitHub: OAuth, App, webhooks, Actions OIDC, GHCR]
    PP[Payment provider]
    VF[OSV, EPSS, KEV, NVD]
    SM[SMTP]
  end
  B --> W --> A
  B -->|/api same origin| A
  M -->|REST| A
  CI -->|submission with OIDC or API token| A
  K -->|anonymous pull| R
  R -->|token realm| A
  R -->|notifications| A
  R --> O
  A --> PG & V & O
  WK --> PG & O & R
  WK --> GH & VF & SM & PP
  S --> PG
  GH -->|webhooks| A
  PP -->|webhooks| A

GHCR (or another source registry) is where project CI pushes its builds. It is not a trusted distribution channel: the worker fetches a submitted digest from it, verifies it, and copies it into the CommonLayer registry. Only the worker's promoter identity can push to the CommonLayer registry (ADR-0009).

Runtime processes

ProcessBinaryListens (defaults)Responsibility
APIcommonlayer-api (apps/api)server.http_addr :8080; server.grpc_addr :50051; server.ops_addr :9090REST under /api/v1, gRPC, webhooks, registry token realm, registry events. Never runs long work inline; it enqueues jobs. A few short outbound calls are synchronous: the payment provider for checkout, refunds and subscription changes, OAuth/OIDC providers at login, and GitHub for repository verification and manifest sync on request.
Workercommonlayer-worker (apps/worker)ops :9090Runs jobs from the PostgreSQL queue: scanners, cosign, registries, GitHub, feeds, payouts and payment webhooks, SMTP and outbound webhooks. Horizontally scalable.
Schedulercommonlayer-scheduler (apps/scheduler)ops :9090Enqueues periodic jobs exactly once per slot and publishes business gauges. Safe with more than one replica.
CLIclctl (apps/cli)—Operator and CI tool.
Portalapps/web:3000Next.js server; proxies /api/* to the API in development.
Registryregistry:3:5000OCI distribution; token authentication delegated to the API.

Every process exposes /healthz (alive), /readyz (database reachable and schema at the expected version; the API also checks object storage and, if configured, the cache; the worker reports ready only while its runner polls) and /metrics on its ops port. When several processes run on one host, give each its own COMMONLAYER__SERVER__OPS_ADDR.

Workspace crates and dependency rules

flowchart TB
  core[commonlayer-core<br/>ids, Money, errors, authz, events, ports]
  config[commonlayer-config]
  obs[commonlayer-observability]
  db[commonlayer-database<br/>pool, migrations, jobs, outbox, scheduler]
  audit[commonlayer-audit]
  net[commonlayer-net<br/>SafeClient]
  httpkit[commonlayer-httpkit]
  storage[commonlayer-storage]
  subgraph domains[Domain crates: never depend on each other]
    auth[auth] --- orgs[organizations] --- projects[projects] --- artifacts[artifacts] --- registry[registry]
    security[security] --- maintenance[maintenance] --- funding[funding] --- github[github] --- notifications[notifications]
  end
  app[commonlayer-app<br/>composition root]
  bins[apps: api, worker, scheduler, cli]
  config --> core
  obs --> config
  db --> obs
  audit --> db
  net --> obs
  httpkit --> db
  storage --> obs
  domains --> audit & httpkit & net & storage
  app --> domains
  bins --> app

Arrows point from a crate to what it depends on. The rules, and how they hold in code:

  • commonlayer-core performs no I/O. It defines the kernel types, the permission matrix, every event payload (src/events/) and every port trait (src/ports/).
  • Domain crates never depend on each other. Cross-domain reads go through ports; cross-domain reactions go through events (ADR-0002).
  • Domain crates may depend on commonlayer-observability to record catalog metrics (ADR-0018); the maintenance crate does not.
  • A crate writes only the tables it owns (listed per migration in the database schema). Read models may join the public identity columns of users and projects.
  • commonlayer-testkit (dev-only) provides fixtures and a fake for every port so that each domain is tested without the others.

Inside a domain crate the layers are domain/ (pure, no I/O) → repo/ → service/ (transactions, authorization, audit, events) → http/ (handlers without business logic), plus adapters/ for outbound integrations, ports.rs, jobs.rs and events.rs.

Request path

sequenceDiagram
  participant C as Client
  participant MW as Middleware stack
  participant H as Handler (http/)
  participant S as Service (service/)
  participant DB as PostgreSQL
  C->>MW: HTTP request
  Note over MW: request id, trace and metrics, panic catcher,<br/>security headers, body limit, timeout, CORS,<br/>authentication + global rate limit, CSRF,<br/>authentication gate
  MW->>H: request with Actor
  H->>S: one service call
  S->>DB: BEGIN
  S->>DB: load just enough to know the scope
  S->>S: actor.require(permission, scope)
  S->>DB: write state, audit entry, outbox event, jobs
  S->>DB: COMMIT
  S-->>H: result
  H-->>C: JSON or problem+json
  • Authentication turns a session cookie or a clt_ API token into an Actor with its grants (one query against the effective_grants view). Other bearer values (a GitHub Actions OIDC token) leave the actor anonymous for the owning endpoint to verify.
  • The global rate limit runs right after authentication because limits are per principal (ADR-0019).
  • CSRF is enforced for unsafe methods authenticated by cookie.
  • The authentication gate answers 401 to credential-less requests for operations not tagged public before the body is parsed (ADR-0023).
  • Authorization happens in the service, never only in the handler or the UI.
  • Webhooks (/webhooks/github, /webhooks/payments/{provider}), the registry token realm (/registry/token) and registry notifications (/internal/registry/events) sit outside /api/v1. They authenticate themselves (signature over the raw body, Basic credentials, bearer secret) and are exempt from CSRF, the gate and the global API buckets.

Every state-changing service method writes an audit entry in the same transaction.

Jobs, events and the scheduler

flowchart LR
  subgraph tx[One database transaction in a service]
    W1[state change] --> E1[INSERT domain_events]
    W1 --> J1[INSERT jobs]
    W1 --> A1[INSERT audit_logs]
  end
  E1 --> D[job events.dispatch]
  D -->|one job per matching subscriber| SJ[subscriber jobs]
  J1 --> WK[worker claims with FOR UPDATE SKIP LOCKED]
  SJ --> WK
  SC[scheduler tick] -->|INSERT scheduler_runs ON CONFLICT DO NOTHING| J2[periodic jobs]
  J2 --> WK
  WK -->|success| OK[succeeded]
  WK -->|retryable error| RT[retry with backoff]
  WK -->|terminal error| F[failed]
  RT -->|attempts exhausted| DEAD[dead]
  • Queues: default, scans, mirror, notifications, finance (crates/commonlayer-database/src/jobs/mod.rs). Finance handlers take a per-project advisory lock.
  • Handlers are idempotent. Validation, NotFound, Forbidden and Unauthenticated errors are terminal (failed); other errors retry with exponential backoff and jitter until max_attempts, then dead. A reaper re-queues jobs whose heartbeat stopped; shutdown releases unfinished jobs without counting an attempt.
  • events.dispatch reads undispatched events in seq order and enqueues one job per subscriber with the unique key event:<id>:<subscriber>; subscribers match exact kinds, prefix.* or *.
  • The scheduler claims each periodic slot by inserting into scheduler_runs; only the replica whose insert succeeds enqueues the job.

The job kinds each domain provides are listed in its crate (src/jobs.rs); the composition root registers all of them with their timeouts and queues (crates/commonlayer-app/src/jobs.rs), the subscribers (src/subscribers.rs) and the schedule (src/schedule.rs); a test checks that the three tables agree.

DomainJob kinds provided
artifacts / registryartifacts.process_submission, registry.verify_preservation, registry.plan_retention, registry.aggregate_pulls
securitysecurity.scan_digest, security.scan_supported, security.rematch_sboms, security.sync_feeds, security.check_deadlines; subscriber security.on_release
maintenancemaintenance.generate_tasks, maintenance.evaluate_eligibility, maintenance.finalize_eligibility; subscriber maintenance.on_finding
fundingfunding.process_webhook, funding.open_cycles, funding.settle_cycles, funding.reconcile, funding.renew_simulated_subscriptions, funding.execute_payout_run; subscriber funding.on_eligibility_changed
projectsprojects.refresh_states (also a subscriber), projects.sync_manifest
GitHub / notificationsgithub.process_delivery, github.sync_repository, notifications.on_event (subscriber to *), notifications.deliver, notifications.cleanup

The submission and promotion pipeline

The most important flow of the platform: how a digest built by project CI becomes a trusted artifact.

stateDiagram-v2
  [*] --> received: CI submits a digest
  received --> validating: artifacts.process_submission starts
  validating --> rejected: a required check fails
  validating --> verified: policy passes
  verified --> mirroring
  mirroring --> promoted: copied, re-verified, tags moved
  mirroring --> failed: transport errors exhausted retries
  failed --> validating: same digest resubmitted (resumes)
  promoted --> [*]

Steps (implemented in crates/commonlayer-artifacts/src/service/pipeline.rs): fetch the manifest by digest from the source registry and re-hash it; discover signatures and attestations; verify the signature with cosign against the identity in the project's manifest; verify SBOM and provenance attestations; store SBOMs, statements and bundles in object storage and index SBOM components; request a scan and wait for it; evaluate the verification policy; mirror into the CommonLayer registry; re-verify at the destination; promote in one transaction (digest state, version row, tag moves with history, release history, event artifact.release.published).

Each step records its outcome in artifact_submission_steps, so the job resumes where it stopped. A missing adapter (no cosign, no scanner, no registry) leaves the submission in place with a *_not_configured reason; it never skips a check.

Configuration

commonlayer-config::Settings loads embedded defaults (crates/commonlayer-config/config/default.toml), then an optional TOML file named by COMMONLAYER_CONFIG, then environment variables COMMONLAYER__<SECTION>__<KEY> (nested keys joined with __). Validation refuses to start on unsafe combinations, for example in production: the simulated payment provider, the development GitHub (github.provider = "development", also refused in staging), http public URLs, a missing cache.url, missing secrets.encryption_key (also in staging), missing registry promoter password or token signing key, or database.migrate_on_start = true. clctl config check prints the effective configuration with secrets masked. The deployment guide lists what production needs.

Observability

Every metric is declared once in crates/commonlayer-observability/src/metrics/catalog.rs; the generated metric catalog is checked by a test so it cannot drift. Logs are structured (JSON in production) with the request id; secrets, tokens, cookies, payment payloads and security-report contents are never logged. Optional OTLP tracing is enabled by observability.otlp_endpoint.