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
| Part | State |
|---|---|
| Domain crates (identity, organizations, projects, artifacts, registry, security, maintenance, funding, GitHub, notifications) | Implemented and tested per crate against real PostgreSQL |
Composition root crates/commonlayer-app | Every domain composed: routers, jobs, subscribers, schedule, business gauges, cross-domain reactions; the development seed; acceptance tests of workflows A–E |
commonlayer-api, -worker, -scheduler, clctl | Run 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 |
| gRPC | 12 services, 59 RPCs, served by the API process on :50051 (gRPC API) |
Web portal apps/web | Public pages, dashboards, project and platform administration, documentation section |
| Platform deployment | Container 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
| Process | Binary | Listens (defaults) | Responsibility |
|---|---|---|---|
| API | commonlayer-api (apps/api) | server.http_addr :8080; server.grpc_addr :50051; server.ops_addr :9090 | REST 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. |
| Worker | commonlayer-worker (apps/worker) | ops :9090 | Runs jobs from the PostgreSQL queue: scanners, cosign, registries, GitHub, feeds, payouts and payment webhooks, SMTP and outbound webhooks. Horizontally scalable. |
| Scheduler | commonlayer-scheduler (apps/scheduler) | ops :9090 | Enqueues periodic jobs exactly once per slot and publishes business gauges. Safe with more than one replica. |
| CLI | clctl (apps/cli) | — | Operator and CI tool. |
| Portal | apps/web | :3000 | Next.js server; proxies /api/* to the API in development. |
| Registry | registry:3 | :5000 | OCI 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-coreperforms 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-observabilityto 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
usersandprojects. 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 anActorwith its grants (one query against theeffective_grantsview). 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
publicbefore 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,ForbiddenandUnauthenticatederrors are terminal (failed); other errors retry with exponential backoff and jitter untilmax_attempts, thendead. A reaper re-queues jobs whose heartbeat stopped; shutdown releases unfinished jobs without counting an attempt. events.dispatchreads undispatched events inseqorder and enqueues one job per subscriber with the unique keyevent:<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.
| Domain | Job kinds provided |
|---|---|
| artifacts / registry | artifacts.process_submission, registry.verify_preservation, registry.plan_retention, registry.aggregate_pulls |
| security | security.scan_digest, security.scan_supported, security.rematch_sboms, security.sync_feeds, security.check_deadlines; subscriber security.on_release |
| maintenance | maintenance.generate_tasks, maintenance.evaluate_eligibility, maintenance.finalize_eligibility; subscriber maintenance.on_finding |
| funding | funding.process_webhook, funding.open_cycles, funding.settle_cycles, funding.reconcile, funding.renew_simulated_subscriptions, funding.execute_payout_run; subscriber funding.on_eligibility_changed |
| projects | projects.refresh_states (also a subscriber), projects.sync_manifest |
| GitHub / notifications | github.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.