ADR-055 — The system status: one registry of health checks, a narrow /healthz, a backup report¶
Status¶
Accepted (2026-09-30, decisions of the product owner). Delivers N-OPS-03
(the internal health page) and the "active real-time connections" of
N-OPS-02, as the requirement F-ADMIN-07. Built on the system module and
the scheduled tasks of merge task M2-05 (D10). Amends
ADR-009 §4 (the observability it
announced) and completes ADR-028 (the
deploy gate reads /healthz, whose contract this ADR fixes).
Delivered with @quiz/domain/health (the thresholds),
apps/api/src/modules/system/health.ts (the registry), the route
GET /app/api/admin/system, the SystemStatus and HealthResponse
schemas of @quiz/contracts, the "System status" tab of the Administration
page, and the backup report written by the backup service of
compose.prod.yml. §5's mail followed (2026-09-30, branch
ops/health-alerts): @quiz/domain/healthAlert, modules/system/alerts.ts,
the table health_check_states (migration health_check_states) and the
notification kind system_alert
(ADR-030, addendum of 2026-09-30).
Amended the same day (branch ops/health-services) by §6 (the external
services, and the administrator's test e-mail) and §7 (the HTTP metrics of
N-OPS-02): apps/api/src/serviceHealth.ts, apps/api/src/httpMetrics.ts,
serviceStatus and serverErrorsStatus of @quiz/domain, the route
POST /app/api/admin/system/test-mail (TestMailResult) and the audit
action system.test_mail. No migration.
Extended on 2026-10-09 (#235) by a second report beside the dump's: §4
gains the offsite check, which §2's coarse backup word now covers (below).
Extended by ADR-058 §7: LLM health reads gateway state and the
llm.budget check; it does not make a provider call.
Amended 2026-10-11 (security audit L21), scope: §2. The public answer
is status and attention alone (HealthProbe): the deploy gate reads the
status code, the external probe its "attention":false. The coarse words
(checks) and uptimeSeconds (HealthResponse) are served to the
METRICS_TOKEN bearer (compared in constant time) and to an
administrator's session. The probe's work runs at most once every
HEALTHZ_CACHE_MS (5 s) of the server's clock, shared by every caller, so
the public route is no lever on the database or the runner.
Context¶
The operator is one teacher. Before an exam, and when something feels
wrong, the question is always the same: can I run an exam now, and does
anything need me? Until now the answer took an SSH session: docker
compose logs, df -h, ls backups/, a psql on the pgboss schema. And
the one thing that must never stop — the ticker that closes attempts at
deadline + 3 s (invariant 5) — had no symptom anywhere but a student still
typing after the end.
/healthz existed, answered 503 only when the database was down, and was
already read by three parties: the container HEALTHCHECK, the deploy gate
of ADR-028 (up -d --wait, and the CI's wait on staging) and an external
uptime probe. It is public.
The logical dumps live in /srv/quiz/backups, 30 days of them, next to the
database they protect. They hold data that a deletion (N-DATA-03) removed
from the live database up to 30 days ago.
Decision¶
1. One registry of checks, thresholds in the domain¶
A check is a named async function (HealthCheck { key, section, run })
returning { status: ok | warn | fail | unknown, value?, cause?, details? }.
The keys are the closed list SYSTEM_CHECK_KEYS of @quiz/contracts, and
the causes the closed list CHECK_CAUSES, so the screen names every check
and words every cause in both languages or does not compile. Values are
typed (count, duration, bytes, share, at) and formatted by the
client in its locale. A check reports counts and sizes, never who: no
student, no title, no log line (the page is not a log viewer; logs hold
personal data).
The thresholds that turn a measure into a verdict are pure functions of
@quiz/domain/health (invariant 8): the ticker is dead past 10 s (or three
periods); an attempt or an evaluation still open a minute past the
moment the ticker should have closed it fails; a disk warns under 15 % free
and fails under 5 %; the last dump warns after 26 h and fails after 50 h; a
scheduled task warns when its last run failed or it has had no success for
two periods; SELECT 1 warns past 250 ms; connections warn at 80 % and
fail at 95 % of max_connections; a queue warns on a failure in the last
day or a job ready and waiting for ten minutes.
unknown is not a verdict: it is a check that cannot measure here — no
ticker in a WORKER_MODE=web process, pg-boss statistics on the in-process
development queue, no backup report configured. It never counts as a
failure.
The checks, in two sections:
- Live exam readiness: the ticker's lag (the time since its last
completed pass, kept in memory by
startTicker); the outcome checks, which catch a dead clock whatever the process layout — attempts stillin_progresspast their own deadline plus the grace plus a minute, and evaluations stillrunningpast the moment step 4 closes them; the scheduled tasks; pg-boss per queue (waiting, failed in 24 h, oldest wait); the runner (F-ADMIN-02's reachability); the live evaluations and the open SSE streams, as information. - Data and storage: the database's response time, its size and its five largest tables, the connections in use; the free disk under the question images and under the backup report; the last backup.
The outcome checks do not re-derive "overdue". live/ticker.ts extracts
the predicates of steps 1 and 4 (dueAttempts, dueToClose), and
overdueLiveWork asks them about now − 60 s. An attempt's deadline_at
already holds its accommodation, its extensions, its reopening and every
pause shift (ADR-025, F-ORG-07), and a paused evaluation is excluded exactly
as the ticker excludes it; so the check cannot disagree with the ticker
about what is due, only notice that it did not run.
A third section, Deployment, is identity rather than a check: the
commit (the Dockerfile now passes COMMIT_SHA/COMMIT_DATE to the API's
runtime, not only to the SPA's build), the last migration applied (its tag
in drizzle's journal), the process start, Node's version, WORKER_MODE,
NODE_ENV and the public host.
2. /healthz stays narrow¶
/healthz is public and the deploy gate reads its status code. It keeps
503 only when the database is down, exactly as before. It gains coarse
words, computed by the registry's checks that touch no table (the ticker,
the disk, the backup report, the runner's /health — memory, two small
file reads and one HTTP call per probe), run side by side and bounded at
1 s all together: the container healthcheck gives the route 5 s and
the deploy gates on it. A check past the bound reads as its worst word.
checks.ticker: up | stale | none, checks.disk: ok | low | unknown,
checks.backup: ok | stale | unknown, and one aggregate,
attention: true | false — true when the ticker is stale, the disk low,
the backup stale, failed or unreadable, or a configured runner down. The
rule lives in one place, coarseHealth. No path, no size, no name, no
number (the uptime aside) is added: the details are on the admin
endpoint only.
3. The admin endpoint runs the registry on demand¶
GET /app/api/admin/system (the admin guard, schema SystemStatus) runs
every check side by side, each bounded at 3 s (a hung check is unknown,
cause check.failed, and never holds the others). The result is cached for
20 s per process and shared by concurrent callers; ?fresh=1 — the
screen's Refresh — bypasses it. The
screen polls every 30 s while visible. On the production VM (1 vCPU) a full
run is a handful of small queries.
The pg-boss statistics read the pgboss schema directly (no pg-boss API
counts failures over a window); a job's wait runs from its start_after.
That SQL cannot run on PGlite, so health.pg.test.ts runs it against a
real PostgreSQL when TEST_PG_URL is set. Should a pg-boss upgrade reshape
pgboss.job, the query throws and the check degrades to unknown
(check.failed); nothing else is affected.
4. The backup service writes a report; the app never reads a dump¶
The dumps are not mounted into the application container: the
internet-facing process must not be able to read a file holding data
deleted up to 30 days ago. Instead the backup service writes, after each
dump, a one-line JSON report — finished_at, ok, exit_code, file,
size_bytes — into a small directory of its own, ./backup-status,
through a temporary file and a rename. That directory alone is mounted
read-only into app, whose BACKUP_STATUS_FILE points at the report.
Without the variable (development, staging, which has no backup service)
the check is unknown, "not configured"; with it and no file yet, warn.
The page says, on the backup line, that the copy is local only and no
off-site copy is configured: open question 10 of
docs/spec/06-questions-ouvertes.md stays open, and this ADR does not
settle it.
Amendment (2026-10-09, #235). The off-site copy exists
(ADR-009, amendment of decision 3), and
the note above is gone. Its unit, srv's quiz-offsite-backup, writes a
report of the dump's shape as offsite.json beside the dump's, in the
same mounted directory (the runbook, §6, has the format), so it needs no
variable and no mount of its own: the check offsite (section storage)
derives its path from BACKUP_STATUS_FILE, and the two reports are judged
by one function with the same thresholds (backupStatus) and the same
causes under their own prefix (offsite.not_configured, .missing,
.failed, .stale). /healthz gains no field: its coarse
checks.backup is the worse of the two reports, so a night without an
off-site archive raises attention and the external probe notices it
within a day.
5. The alarm: an external probe first, a mail second¶
The primary alarm is the product owner's external uptime probe,
alerting on a non-200 and on a body without "attention":false
(docs/development/deployment.md §7): it also sees what the process cannot
report about itself (a VM down, a crash loop, a certificate or DNS gone
wrong).
The secondary alarm is the application's own. A health.checks task in
the scheduled catalog (D10, every five minutes by default) runs the same
runChecks and keeps, per check, what a transition needs — the status of
the last run, since when and for how many runs it holds, the last notice
sent — in health_check_states, a table of the system module, so a
restart or another process sees the same streak. The rule is pure,
nextCheckState of @quiz/domain:
- alert once a check has been
failon two consecutive runs: one slow run, one restart, one blip of the runner is not news; - recovery once it is
okagain after an alert, and only then: a failure nobody was told about recovers silently; - a reminder when it is still failing a day after the last notice;
- nothing on
warn(the page shows it; a mail per late dump would teach the reader to ignore the mail) and nothing onunknown, which is not a verdict; never the same notice twice while nothing changed.
The notices of one run are grouped by kind (failing, still failing,
recovered) into one notification each, of the kind system_alert, sent
through notifyMany to every non-anonymized account whose role is admin
(ADR-030, addendum of 2026-09-30): the bell, and an e-mail by default,
never Teams. It names the checks, in the recipient's language, and the
platform's host, so production and staging cannot be confused, and links to
the System status, where their causes and measures are: the mail says what
to look at, the page says why. Nothing in it is about a person.
The states are stored before the notifications are sent: a notice is sent
at most once, and a send that fails is the task's error on the scheduled
tasks screen, not a retry.
The page shows, under a failing check, when the task first saw it fail
(SystemCheck.failingSince).
This alarm shares the process's fate: the task runs on the ticker's claim and the mail needs the job queue, so a dead VM, a crash loop, a ticker dead in every process or a queue down silences it. That is why it is second.
6. The external services, judged from real traffic¶
A third section of checks, External services, through the same
registry: service.mail (Scaleway TEM), service.signin (the OIDC
callback's exchange with the identity provider), service.teams,
service.llm and service.github (the App's installation-token fetch).
Nothing is probed: each is judged from the calls the process already makes.
Recording. Each transport has one call site — the mailer's send, the
Teams client's notify, the LLM service createLlm returns,
installationClient, the callback's completeLogin — and it goes through
one helper, tracked(name, run) of serviceHealth.ts, which keeps in
memory, per process, the last success, the last failure, its CLASS and the
failures in a row since the last success. The class comes from a closed
vocabulary (timeout, a registered OAuth code such as invalid_grant —
openid-client's .error, which for the callback comes from the browser's
query string, so anything unregistered is oauth_error —, http_<status>,
network_<code>, error): never a message, which may echo an address, and
never a body. Chosen over the existing tables because none holds it: a mail
delivery is a pg-boss job whose completion says nothing kept past a day,
and the audit log has the logins but not the refused exchanges. In memory
costs no migration and no write on a hot path, and "is the provider
answering us now?" is a question about the process that calls it. What it
costs: a restart forgets it (unknown until the next call), and in
WORKER_MODE=web the web process never calls the mailer, Teams nor the
LLM, so those rows read unknown there (production runs all).
A refusal about one recipient is not the service failing: a permanent
Teams refusal (the app not installed for them) counts as an answer. A
missing login state (a stale browser tab), the staging allowlist and
access_denied (the person cancelling at the provider) are not the
provider's failing and do not count.
Verdict. serviceStatus of @quiz/domain: unknown when never used
since the start; ok when the last call succeeded; fail when the calls
have kept failing for more than half an hour since the first failure, with
at least two failures (three for sign-in, where a code used twice is the
person's doing); warn short of that. A service that is not configured —
no Scaleway credentials (mail.dry_run), no Teams application,
LLM_PROVIDER=none, no GitHub App — is unknown, never a failure; so is
sign-in in development, where the persona picker never calls the provider
(service.unused). The services are the closed list SERVICE_NAMES of
@quiz/domain, from which the check keys service.<name> and the
registry's rows derive; the policy is one default with an override per
service (servicePolicy). A fail feeds the
health.checks alerts of §5 like any check; the mail about a failing mail
may itself not arrive, which the page and the external probe cover.
The test e-mail. The e-mail row carries a secondary action, "Send me a
test e-mail": POST /app/api/admin/system/test-mail (the admin guard)
renders a short en/fr message in the caller's language and sends it to the
caller through the mailer alone — no notification row, no preference, no
queue, and no notification-settings footer, since no preference chose it —
and answers sent, dry_run or error with its class. One per minute per
admin, through the preview's in-memory Budget (now budget.ts, shared;
a guard against a double click, not a quota), 429 rate_limited
otherwise; an account without an address gets 409 no_email; audited system.test_mail with the outcome,
never the address.
7. The HTTP metrics (N-OPS-02)¶
A hook registered on the root instance before any route feeds
quiz_http_requests_total{method, route, status} and
quiz_http_request_duration_seconds{method, route} (buckets 25 ms to 10 s)
on /metrics. route is Fastify's route template (routeOptions.url),
unmatched when no route matched; never the URL, which carries ids and,
for some routes, one-time secrets. status is the class (2xx…5xx).
The same hook keeps, per process, the 5xx of the last 24 hours in hourly
buckets by route template; the Server errors (24 h) check of the live
section shows their count and the three templates that answered most of
them, and warns from five (serverErrorsStatus), never fails: an error
page is a reason to read the logs, not an alarm. No log line, no URL, no
user reaches the page.
The per-process values the checks read (the ticker's last pass, this
window) are kept by perApp, which the admin routes' plugin instance —
a child of the root — reaches through its prototype; a plain WeakMap on
the root missed it, and the ticker's row read "not in this process" on the
page of a process that ran it.
Consequences¶
- The operator answers "can I run an exam now?" from the Administration page, in both languages, without SSH, and a dead ticker has a symptom within a minute, whether the process running it is this one or not.
/healthzstill means "the process is up and its database answers" for every party that reads its status; the external probe gets one more, coarse, signal from its body.- The backup service's command grew a report, and production needs one more
directory,
./backup-status(deployment.md §6). Staging reports "not configured", which is true. - Every new check is a function in
health.ts, a key inSYSTEM_CHECK_KEYS, its causes inCHECK_CAUSES, a threshold in the domain if it judges, and its two names in the web's dictionaries. - The pg-boss statistics read the
pgbossschema directly (§3); a pg-boss upgrade that reshapespgboss.jobturns that one checkunknown(check.failed), nothing else. - A new check also needs its name in the mail's dictionary (
templates.ts,check.<key>), beside the web's. - An administrator gets at most one mail per run and per kind of notice,
about ten minutes after a check starts failing; a check that flaps
between
failandokon every run never alerts, and one that flaps every two runs alerts and recovers each time — the page, not the mail, is where a flapping check is read.
Alternatives considered¶
- Mount
./backupsread-only intoappand list it. Simple, and the file sizes and dates would be exact. Rejected: the internet-facing process would hold 30 days of dumps, including data deleted under N-DATA-03; a report of a few bytes gives the same answer. - Detailed values in
/healthz. One endpoint for everything, and the probe could read the numbers. Rejected: the route is public, and paths, sizes and counts describe the installation to anyone. - Make
/healthzanswer 503 on a stale ticker or a full disk. The probe would need no keyword. Rejected: the deploy gate and the container healthcheck read that status, and a restart or a refused deploy is the wrong answer to a disk filling up or a backup a day late. - Grafana, Prometheus and a log viewer. The metrics endpoint exists for a Prometheus that may come; a dashboard of charts answers "what happened?", not "can I run an exam now?", and a log viewer would put personal data on a screen. Rejected for this page.
- Recompute on every request, no cache. Simpler, but two open admin tabs polling would double the load at the worst moment; 20 s of staleness costs nothing for a page of this kind, and Refresh bypasses it.