Architecture¶
How the seven repositories fit together as one system: what is shared, what is federated, and where the boundaries are.
Scope
The mechanisms below are the same in all six tool repositories. The per-repository examples — which files are vendored where, which workflows call what — are given for page-classification and the translator.
Hub and spokes¶
There is no monorepo and no git submodule. The seven repositories are deliberately separate, because the tools' dependency graphs cannot share an environment: page-classification needs PyTorch and a vision-model stack, ocr-postprocess is CPU heuristics, keyword-extract needs GPU LLM inference for its controlled kind. One environment for all of them would satisfy none.
What holds them together is the hub, atrium-project, and it does so in two ways only — it
hands out copies of code, and it runs CI on their behalf.
flowchart TB
subgraph HUB["atrium-project — the hub"]
SHARED["docs/templates/shared/<br/>24 canonical files + MANIFEST.json"]
REUSE["8 reusable workflows<br/>pinned by callers to a commit"]
E2E["3 end-to-end workflows"]
SITE["this site"]
end
subgraph TOOL["each tool repository"]
VEND["vendored copies<br/>byte-identical"]
CALL["thin caller workflows"]
CODE["tool code · README · CONTRIBUTING"]
end
GHCR[("GHCR<br/>atrium-<tool> · atrium-<tool>-api")]
SHARED -- "revendor_shared.sh copies" --> VEND
CALL -- "uses: …@sha # v1" --> REUSE
REUSE -- "para-drift diffs every byte" --> VEND
REUSE -- "builds, gates, publishes" --> GHCR
GHCR -- "pulled by image tag" --> E2E
Everything else — the tool's behaviour, its README, its release history — belongs to the tool repository, and the hub never touches it.
Anatomy of a tool¶
Every tool repository has the same shape, whatever it does. One Dockerfile builds two images from one code base; both run the same processing code, and both speak the same record and paradata contracts through the vendored shared modules:
flowchart LR
subgraph REPO["a tool repository"]
CODE["tool code<br/>(the part that differs)"]
SHARED["vendored shared modules<br/>record · paradata · licences · vocabulary · service"]
CFG["config.txt · para_config.txt<br/>.env.example"]
end
CODE --- SHARED
CFG --- CODE
REPO -- "Dockerfile, stage base" --> CLI["atrium-<tool><br/>batch CLI"]
REPO -- "Dockerfile, stage api" --> API["atrium-<tool>-api<br/>HTTP service on :8000"]
IN[/"input files<br/>+ optional record"/] --> CLI
IN --> API
CLI --> OUT[/"output files · record · paradata"/]
API --> OUT
- The batch CLI reads a file or a directory, writes result files, and — when given
--document-json-out— an updated document record. It exits non-zero when a run fails, so it can be scheduled as a job. - The HTTP service wraps the same processing in FastAPI, adds
/info,/healthand/ready, drains in-flight requests on SIGTERM, and returns the updated record in the response when one is sent in. - Paradata — a JSON file per run: tool version, run id, configuration, statistics, and the licence the run resolved to from the components it actually used.
- An Agent Skill on a separate
agent-skillbranch wraps the service for coding agents; see Agent skills.
What is canonical, and what is vendored¶
The hub does not publish the shared code as a package. It enforces it by copy: 24
canonical files live under docs/templates/shared/, each tool repository carries a copy at a
fixed path, and CI fails the tool repository if a single byte differs.
MANIFEST.json is the one registration point — one row per file, naming the canonical file,
its destination in a tool repository, and whether it has a --selftest that CI must also run.
| Canonical file | Destination in a tool repo | Selftest | What it is |
|---|---|---|---|
atrium_document.py |
atrium_document.py |
the document record: ownership, accretion, validation | |
atrium_document.schema.json |
atrium_document.schema.json |
its JSON Schema | |
atrium_document.schema.doc-schema-v1.json |
atrium_document.schema.doc-schema-v1.json |
the same schema frozen as tag doc-schema-v1; never edited |
|
atrium_paradata.py |
atrium_paradata.py |
the per-run paradata logger | |
para_licenses.py |
para_licenses.py |
licence normalisation and most-restrictive merging | |
atrium_vocab.py |
atrium_vocab.py |
✓ | the SKOS controlled-label registry |
atrium_vocab.schema.json |
atrium_vocab.schema.json |
schema for the registry's JSON-LD export | |
atrium_rocrate.py |
atrium_rocrate.py |
✓ | the RO-Crate exporter |
check_version.py |
check_version.py |
the release gate: tag = CITATION.cff = para_config.txt |
|
atrium_limits.py |
atrium_limits.py |
✓ | every service limit as a setting, and the limits_applied record |
atrium_openapi.py |
atrium_openapi.py |
✓ | the committed service/openapi.json: export, check, compare with the last release |
atrium_service.py |
service/atrium_service.py |
the shared HTTP service contract — /info, /health, /ready, drain |
|
healthcheck.py |
service/healthcheck.py |
the container healthcheck probe | |
test_para_licenses.py |
tests/test_para_licenses.py |
||
test_document_originators.py |
tests/test_document_originators.py |
block ownership for both originators | |
test_atrium_vocab.py |
tests/test_atrium_vocab.py |
||
test_atrium_rocrate.py |
tests/test_atrium_rocrate.py |
||
test_logging_contract.py |
tests/test_logging_contract.py |
one log format, no basicConfig() in library code |
|
test_env_contract.py |
tests/test_env_contract.py |
.env.example must list every variable the service reads |
|
test_dockerfile_security_layer.py |
tests/test_dockerfile_security_layer.py |
pins the apt-get upgrade layer that keeps the release gate passable |
|
test_schema_freeze.py |
tests/test_schema_freeze.py |
this repo's schema against the frozen copy: nothing removed, every change since registered | |
test_atrium_limits.py |
tests/test_atrium_limits.py |
||
test_limits_contract.py |
tests/test_limits_contract.py |
every limit is a documented setting that /info reports |
|
test_openapi_contract.py |
tests/test_openapi_contract.py |
the committed spec is current; typed responses; one error body |
test_env_contract.py additionally needs a repo-local tests/env_contract_data.py — the
list of variables that particular service reads — which each repository keeps and which is
deliberately not vendored.
Repository-local helpers¶
Next to the shared files, each tool keeps its own glue, which is not part of the canon and
may differ freely: atrium_document_adapter.py in page-classification and
atrium_test_support.py in the translator wrap the shared modules for that tool, and each
repository's tests/test_paradata.py tests its own paradata configuration.
How a change to shared code lands¶
- Edit the canonical file in the hub.
scripts/revendor_shared.shcopies it into every sibling checkout (--checkverifies only;--repo NAMEnarrows it), re-diffs exactly as CI will, and runs each--selftestagainst the vendored copy.- Commit the tool-repository copies and the hub edit in one window, then move the hub's
v1tag — never before the copies have landed, or every tool repository's CI goes red at once.
Who owns each shared component (including the files one tool copies from another, and the
vocabulary), how a consumer records the revision it adopted, which changes need AMČR's agreement,
and the pull request checklist are in the hub's
shared_components.md.
The script exits 0 only when CI would pass. A sibling checkout it cannot find is reported as
SKIP and still exits 1: parity is unknown for it, not clean.
The CI federation¶
Each tool repository keeps a set of thin caller workflows; the real logic lives in eight
reusable workflows in the hub. Since the #72 sweep (2026-10-25) the callers reach them at a commit of
the hub's v1 tag, written @<sha> # v1, which only a reviewed pin change moves; v1 itself is
moved deliberately, and test is the hub's pre-release channel.
| Hub reusable | What it does for a tool repository |
|---|---|
docker-tool.reusable.yml |
fast test lane, container smoke test, image build, CVE gate, publish to GHCR |
api-contract.reusable.yml |
the service meta-contract: /info envelope, /health, /ready, OpenAPI validity |
para-drift.reusable.yml |
byte-parity of all 24 vendored files, plus their selftests |
security.reusable.yml |
version consistency, and a scheduled Trivy scan of the published image |
codeql.reusable.yml |
CodeQL static analysis |
pre-commit.reusable.yml |
pre-commit run --all-files, blocking |
workflow-lint.reusable.yml |
checks the caller's own workflows: SHA-pinned write-scoped actions, @v1 refs, timeouts, concurrency, permission grants that cover what the callee asks for |
skill-validate.reusable.yml |
the Agent Skill branch contract — see Agent skills |
Both documented tools call the same seven on their default branches (skill-validate runs only
on their agent-skill branches), and each keeps a few workflows of its own:
| Workflow | Kind | Triggers |
|---|---|---|
docker.yml |
→ docker-tool |
push to vit, test, clip, master; PRs |
api-contract.yml, para-drift.yml, pre-commit.yml, workflow-lint.yml |
→ hub | push and PR on the same four branches |
codeql.yml |
→ hub | push, PR, weekly |
security.yml |
→ hub | push, PR, tags, weekly |
release.yml |
→ docker-tool |
tags v* — version gate; the images, built, probed, scanned and promoted (release-gate, #81); then the release with its zip and images.json |
scheduled-smoke.yml |
local | daily — checks a published model revision is reachable, runs the slow tests |
gpu-inference.yml |
local | manual only — runs on a self-hosted GPU runner |
| Workflow | Kind | Triggers |
|---|---|---|
docker.yml |
→ docker-tool |
push to test, master; PRs |
api-contract.yml, para-drift.yml, pre-commit.yml, workflow-lint.yml |
→ hub | push and PR on test, master |
codeql.yml |
→ hub | push, PR, weekly |
security.yml |
→ hub | push, PR, weekly |
release.yml |
→ docker-tool |
tags v*.*.* — version gate; the images (release-gate, #81); then the release with its bundle and images.json |
integration.yml |
local | a stub-backend lane on every push; a live-LINDAT lane on manual dispatch only |
scheduled-smoke.yml |
local | daily — the full suite in a fresh environment, to catch dependency drift |
lindat-daily.yml |
local | daily — 24 identical requests to live LINDAT, failing on a bad reply (#78) |
The hub runs its own workflows without @v1 — they use ./… so they test the commit under
review — and three end-to-end workflows that exercise the published images together:
all-repos-smoke.yml (each tool's fast suite, on its default branch), e2e-pipeline-smoke.yml
(the scanned-document chain, four stages and a keyword-extract smoke) and e2e-digital-smoke.yml
(the born-digital chain, one stage). See Pipelines.
Where the boundaries are¶
A tool repository never edits a vendored file. A one-byte difference fails para-drift. The
formatter is fenced off too: each tool's ruff.toml excludes the vendored files with
force-exclude = true, because on one occasion ruff format split
test_document_originators.py into two variants across repositories.
The hub never owns tool behaviour. It owns the record's contract, the service contract and the CI; what a tool does is decided in the tool repository.
There is no cross-repository token. A push to a tool repository cannot trigger anything in the hub. The hub re-reads on schedules and on manual dispatch instead.
At run time, the only contract between tools is the record. No tool imports another tool's
code. Stages meet in the atrium_document JSON — see
The document contract — and in three directories of files.
Per-repository files¶
These start from hub templates but belong to each repository, which adapts them to its own code; CI does not compare them across repositories:
| File | What it is |
|---|---|
CONTRIBUTING.md |
filled in from the hub skeleton docs/templates/CONTRIBUTING.md. See Contributing standards |
ruff.toml |
started from a hub template; each repository names its own first-party modules for import sorting, and always excludes the vendored files |
.pre-commit-config.yaml |
the hooks pre-commit.reusable.yml runs — whitespace, YAML and ruff |
CITATION.cff, LICENSE, para_config.txt |
per repository by design — they are what the release gate compares |
Sources¶
This table records provenance: what this page was written from, not a build instruction.
| Source | What was taken from it |
|---|---|
atrium-project/docs/templates/shared/MANIFEST.json |
the 24 files and their destinations |
atrium-page-classification@adee922, atrium-translator@71feaef |
vendored destinations, repository-local helpers, Dockerfile stages |
atrium-project/scripts/revendor_shared.sh |
the re-vendoring procedure and its exit semantics |
atrium-project/.github/workflows/*.reusable.yml and the hub-own workflows |
the federation |
each tool's .github/workflows/*.yml at its default branch |
the per-tool tables |
each tool's ruff.toml and .pre-commit-config.yaml |
tool-owned configuration |
atrium-project/agent_dev_logs/digests/project_state_2706.md §3 |
the reason there is no monorepo — rewritten from, not quoted |