Architecture
============
This page shows how Adriano fits together and why it is shaped this way.
It is written for architects who evaluate fit and risk,
and for developers who need the big picture before changing code.
The architecture was fixed with the product owner on 2026-10-19
and last revisited on 2026-10-21.
The reasoning behind each choice is recorded in :doc:`decisions/index`;
the domain model it serves is described in :doc:`domain/index`.
System context
--------------
Adriano indexes architecture decision records (Markdown files with YAML
frontmatter) that live in other systems,
keeps its own copy in PostgreSQL,
and runs an approval workflow on top of that copy.
.. mermaid::
flowchart LR
reader["Reader or reviewer
(browser)"]
operator["Operator"]
ci["CI pipeline"]
idp["OIDC identity provider"]
subgraph adriano["Adriano"]
spa["Frontend
Vue SPA, served by the API"]
api["Backend API
FastAPI, stateless"]
poll["Polling
CLI job or operator trigger"]
end
db[("PostgreSQL")]
gitlab["GitLab projects"]
fs["ADR folder on disk"]
reader -- "loads the app, calls /api" --> spa
reader -- "signs in: Auth Code + PKCE" --> idp
spa -- "same-origin /api calls" --> api
api -- "fetches discovery and JWKS" --> idp
ci -- "pushes candidates with a push token" --> api
operator -- "schedules or triggers" --> poll
poll -- "reads the published ref" --> gitlab
poll -- "reads the published tree" --> fs
poll -- "writes published rows" --> db
api -- "reads and writes catalog data" --> db
Readers and reviewers
Use the single-page app in a browser.
They sign in at the organisation's OIDC provider;
the SPA sends the access token as a bearer token to the API.
Operators
Deploy Adriano, write ``catalog.yaml``,
schedule ``adriano poll`` (cron or a Kubernetes CronJob)
and can trigger a poll from the Sources page.
See :doc:`/operator/index`.
CI pipelines
Push candidate ADRs from feature branches through
``PUT`` or ``POST /api/v1/candidates``,
authenticated with a per-registration push token.
GitLab and the filesystem
Are the two source types.
Adriano only ever reads them, and only from the polling code path.
OIDC provider
Issues RS256 access tokens.
The backend is a resource server: it validates tokens against the
provider's JWKS and never takes part in the login flow.
PostgreSQL
Holds everything Adriano knows: registrations, record rows,
immutable content snapshots, decision events and intake runs.
In the bundled ``docker-compose.yml`` stack the single ``adriano``
container is the only published entry point.
It serves the SPA and the API from one process and applies the
migrations when it starts.
Concern map and layering
------------------------
The backend is one deployable (a modular monolith).
Each concern lives in its own package,
and calls flow only downward:
.. mermaid::
flowchart TD
ui["frontend/
Vue 3 + Vuetify SPA"]
routers["app.routers
HTTP boundary, schemas"]
auth["app.auth
OIDC, dev bypass, push tokens, RBAC"]
services["app.services
ingest, polling, intake,
search, decisions"]
domain["app.domain
pure parsing, hashing,
links, approval"]
sources["app.sources
adapter port, filesystem, gitlab"]
storage["app.storage
store port, PostgreSQL implementation"]
db[("PostgreSQL")]
ui -- "bearer token, JSON" --> routers
routers -- "authenticates and authorizes" --> auth
routers -- "calls" --> services
services -- "uses pure functions" --> domain
services -- "reads and writes through the port" --> storage
services -. "polling only" .-> sources
storage -- "SQL" --> db
:mod:`app.domain`
Pure logic with no I/O: frontmatter parsing and validation,
the canonical digest, relationship resolution,
approval-state evaluation, erratum body parsing.
It imports neither FastAPI, SQLAlchemy nor httpx,
so it is tested with plain unit tests.
:mod:`app.services`
Use cases.
They take a ``CatalogStore`` from :mod:`app.storage.port` and plain
values, and return plain dataclasses.
They never import FastAPI.
:mod:`app.storage`
:mod:`app.storage.port` defines the DB-neutral ``CatalogStoreProvider``
and ``CatalogStore`` protocols and their value types;
:mod:`app.storage.postgres` is the only implementation.
No ORM object crosses the port.
:mod:`app.sources`
:mod:`app.sources.port` defines the provider-neutral ``SourceAdapter``
protocol; :mod:`app.sources.filesystem` and :mod:`app.sources.gitlab`
implement it.
:mod:`app.sources.registry` maps a source type to its settings validator
and adapter factory.
Everything GitLab-specific stays inside the ``gitlab*`` modules.
:mod:`app.routers` and :mod:`app.schemas`
The HTTP boundary: request parsing, Pydantic response models,
permission dependencies.
Domain errors from :mod:`app.errors` are mapped to HTTP statuses with a
flat ``{detail, code}`` body.
:mod:`app.auth`
Token validation (:mod:`app.auth.tokens` picks the validator by the
token's ``alg``), claim mapping, RBAC and push-token checks.
:mod:`app.composition`
The composition root.
It is the only module that names concrete implementations
(the PostgreSQL store, the default source registry)
and the only one that hands the process environment to the
configuration loader.
Both the FastAPI lifespan and the ``adriano`` CLI build their object
graph here.
Two rules follow from this layering and are enforced by tests
(see :doc:`invariants`):
* Browse, search, detail, diff and sources requests reach
``services → storage → PostgreSQL`` only.
They never construct or call a source adapter.
* Source adapters are opened only by :mod:`app.services.polling`.
Push intake never calls an adapter.
Data flows
----------
Poll path
~~~~~~~~~
Polling reads each registration's configured published ref
and keeps the published rows in step with it.
.. mermaid::
sequenceDiagram
participant T as cron, CronJob or operator
participant P as PollingService
participant A as Source adapter
participant S as CatalogStore (PostgreSQL)
T->>P: poll_all() or poll_source(id)
P->>S: start intake_run (own unit of work)
P->>A: list_projects()
A-->>P: projects, complete flag
P->>A: list_documents(project, stored tokens)
A-->>P: documents with change tokens, complete flag
P->>A: fetch() changed documents only
A-->>P: exact bytes, source URL
P->>P: prepare_document(): parse, validate, hash, render
P->>S: lock_registration(source_id)
P->>S: upsert published rows, refresh tokens
P->>S: delete rows missing from a complete listing
P->>S: finish intake_run, set last_status (own unit of work)
Registrations are polled one after another in configuration order,
because the first registration that yields a ``(project, path)`` owns
it; later ones skip it with an ``ownership_conflict`` warning.
Each registration runs in three phases:
1. **Run log.** An ``intake_run`` row is started in its own unit of
work, so a rolled-back write never loses the log.
2. **Collect.** No transaction and no lock are held while the adapter
lists and fetches, with bounded concurrency.
Documents whose change token is unchanged are not fetched at all.
3. **Apply.** Under the registration's advisory lock, in one
transaction: ingest the fetched documents, rewrite moved change
tokens and reconcile deletions.
Nothing is deleted unless the project listing was complete,
and within a project nothing is deleted unless its document listing was
complete.
A failed or partial poll therefore keeps all prior content.
Polling never deletes candidates.
Push path
~~~~~~~~~
CI submits candidates from branches other than the published ref.
.. mermaid::
sequenceDiagram
participant CI as CI job
participant R as candidates router
participant I as intake service
participant G as ingest (shared with polling)
participant S as CatalogStore (PostgreSQL)
CI->>R: PUT or POST /api/v1/candidates, push token
R->>I: submit_candidate()
I->>I: authenticate token, check body, scope and ref
I->>G: prepare_document(): parse, validate, hash, render
I->>I: document UUID must equal adr_uuid
I->>S: lock_registration(source_id)
I->>G: ingest_document() as CANDIDATE
G->>S: put revision, upsert candidate row
I->>S: record intake_run
R-->>CI: 200 or 201 with server digest and issues
Push intake and polling share :mod:`app.services.ingest`:
``prepare_document`` parses, validates, hashes and renders,
``ingest_document`` stores the snapshot and upserts the row.
Push always writes a candidate row, so it can never replace or delete a
published row, and it never calls a source adapter.
The full check order and error codes are in the :mod:`app.services.intake`
docstring.
Read path
~~~~~~~~~
.. mermaid::
sequenceDiagram
participant B as Browser (SPA)
participant R as records router
participant Q as search / record_detail services
participant S as CatalogStore (PostgreSQL)
B->>R: GET /api/v1/records?q=...&filters
R->>Q: search_records()
Q->>S: count + one page query (FTS, filters, approval state)
S-->>Q: rows
Q-->>R: page of items
R-->>B: JSON
B->>R: GET /api/v1/records/{id}
R->>Q: get_record_detail()
Q->>S: record, siblings, related rows, errata, decisions
S-->>Q: rows
Q-->>R: assembled detail
R-->>B: JSON
A search is a count query plus one page query,
with the approval state computed in SQL
(plus one lookup for the titles of the decisions that erratum hits
correct).
A detail request runs a handful of targeted queries whose number is
bounded by the record's own links and errata, never by catalog size.
No read ever reaches a source.
See :doc:`performance` for the measured cost.
Decision path
~~~~~~~~~~~~~
.. mermaid::
sequenceDiagram
participant B as Reviewer (SPA)
participant R as decisions router
participant D as decisions service
participant S as CatalogStore (PostgreSQL)
B->>R: POST /api/v1/records/{id}/decisions
R->>D: record_decision(action, comment, expected_digest)
D->>S: load record and identity events
D->>D: policy, eligibility, approvable, self-approval, digest
D->>S: append_decision() with ON CONFLICT DO NOTHING
alt no earlier decision for identity and digest
S-->>D: event stored
D-->>R: decision
R-->>B: 201 with the event
else another decision won the race
S-->>D: DecisionExistsError
R-->>B: 409 already_decided
end
The decision binds to the record's logical identity and the digest of
its current content.
The server sets the event id and timestamp.
See :doc:`domain/approval` for the rules.
Storage and concurrency
-----------------------
PostgreSQL (psycopg 3, SQLAlchemy 2, Alembic) is the only stateful
service.
It covers both durable storage and full-text search:
``record.search`` is a weighted ``tsvector`` maintained by a trigger and
indexed with GIN.
Alembic owns the schema; tables are never created from the ORM models
outside tests.
See :doc:`domain/schema` for the tables.
Concurrent writers are made safe by three mechanisms:
Per-registration advisory lock
All writes for one registration (the apply phase of a poll, a push)
run inside ``CatalogStoreProvider.lock_registration``, which takes
``pg_advisory_xact_lock(namespace, hashtext(source_id))``.
The lock lives exactly as long as the transaction,
so it is released on commit or rollback and works across processes
and replicas.
Two pollers, or a poll racing a push, for the same registration
serialize instead of interleaving.
``ON CONFLICT`` upserts
Record rows are upserted against partial unique indexes
(published by location, candidates by identity and ref).
Revisions are inserted with ``ON CONFLICT DO NOTHING``,
since a digest names the same content forever.
Unique constraints as the arbiter
``uq_decision_event_identity_digest`` allows one decision per
identity and digest.
A losing concurrent decision gets HTTP 409, never a silent
overwrite.
``POST /api/v1/candidates`` and ``delivery_id`` deduplication use
the same pattern.
Stateless replicas
~~~~~~~~~~~~~~~~~~
The backend holds no state between requests:
identity comes from the bearer token,
catalog data from PostgreSQL,
and configuration from ``catalog.yaml`` and the environment at startup.
Any number of replicas and Uvicorn workers can run against one database
(the container default is four workers).
Scheduled polling runs as a separate one-shot process,
never as a timer inside a web worker,
so adding replicas never multiplies polls.
What we deliberately do not run
-------------------------------
No distributed queue or event bus
Polling is a periodic, idempotent batch job,
serialized per registration by the advisory lock.
A queue would add an operational dependency without solving a
problem this workload has.
No separate search cluster
PostgreSQL full-text search meets the latency target at the
catalog's scale (thousands of records, not millions);
see :doc:`performance`.
Elasticsearch or OpenSearch would double the stateful services to
operate and back up.
No microservices
The package boundaries above already separate parsing, sources,
intake, persistence and the web layer.
Network boundaries would add latency, versioning and partial-failure
modes with no benefit for one team at this scale.
The trade-off is explicit: one database is a single failure domain for
the whole catalog.
Backup, restore and retention of the audit data are operator concerns,
documented in :doc:`/operator/index`.
Frontend architecture
---------------------
The frontend is a Vue 3 single-page app built with Vite and TypeScript,
using Vuetify 4 for components and theming
(see :doc:`decisions/0017-vuetify-first`).
Routes
``/`` (search landing page), ``/records/:id`` (record or erratum
page), ``/sources`` (registration status),
``/auth/callback`` (OIDC redirect target) and a not-found route.
State
No global store.
Composables (``useSearchQuery``, ``useRecordDetailView``,
``useDecision``, ``useMe`` and others) hold view state;
search terms, filters and sort live in the URL query so they can be
shared.
API access
``src/api/index.ts`` is a small ``fetch`` wrapper with a pluggable
token provider and 401 handling.
Response types are generated from the backend's OpenAPI schema
into ``src/types/openapi.ts`` with ``npm run gen:types``.
Authentication
``oidc-client-ts`` runs Authorization Code + PKCE as a public client.
Tokens are kept in memory only, so a full page reload signs the user
out (see :doc:`security`).
Runtime configuration
The API process renders ``/config.js`` from its settings (OIDC
settings; the API is always on the same origin) and adds the
Content-Security-Policy with the identity provider's origin to every
response.
The dev-login switch is a build-time literal and is never part of
runtime configuration.
Rendered Markdown
The API returns HTML that was sanitized with nh3 at ingest;
only that field is rendered with ``v-html``.