Conormis status

The manual

The instance config: config/status.yaml

One YAML file per deployment. The engine (src/) is estate-agnostic: everything it knows about an estate — the probed surfaces, the vantages, the site identity, the mail declaration — lives in this one file. Adding a monitored service is a config edit, never an engine change.

The instance model: one checkout + one wrangler.toml + one YAML = one estate's status site. npm run gen (which rides every gate) parses and validates the YAML through scripts/load-config.ts and emits src/generated/config.ts, consumed by the engine through the single import point src/config.ts. An invalid config fails gen — a bad instance never reaches a deploy.

CONFIG=config/fixtures/minimal.yaml npm run gen   # build any instance
CONFIG=<path> npm run dev                         # run any instance locally

The doctrine the schema enforces (every rule names itself in its error):

The blocks

site — the identity the chrome renders

Field Type Rule
name string, required The shell's wordmark and the <title> suffix; the mail subjects carry it.
base_url https URL, required The canonical public URL (the wrangler STATUS_BASE_URL var's twin; the var wins at run time).
description string, required The page's meta description and the board's one-line intro.
logo.light / logo.dark https URLs, optional (as a pair) The light/dark logo pair, swapped on the .dark class. Canonical URLs, never vendored. Absent: the wordmark alone.
links[] {label, href}, optional The chrome links (footer; the first is also the header's front-door icon).
source_url https URL, optional Where the service's source lives (the incident rows' home).
fonts_url https URL, optional The font stylesheet; absent = the house stack. The whitelabel font knob.
docs_url https URL, optional The externally hosted manual's home — linked from the public footer and the admin header when set.
theme map, optional Design-token overrides (--accent, --ink, ...). Keys are CSS custom-property names; values hex or rgb()/rgba() colors ONLY — anything that could carry CSS is refused. Applied after the compiled stylesheet.
uptime_windows 1-3 day counts, optional The windows the uptime bars + summary's uptime<N>d keys render (each 1-365; default [30, 90]). The history retention becomes max(90, largest window).
probe_log_hours integer, optional The probe leg log's hour window (default 24; 0 turns the log off; the sane ceiling is 168). The per-service rows (leglog:<id>, hard-capped at 720 entries) feed the console's Probe log register — what the prober saw, per leg and vantage. Each round writes one row per service: on the free plan's write budget, set 0.
locale BCP-47 tag, optional The declared locale, rendered as <html lang> on the public page and the admin (default en-US). Display only.
fonts_url https URL, optional The stylesheet override. ABSENT (the flagship posture) the compiled stylesheet serves the SELF-HOSTED estate fonts (OFL woff2 under /fonts/, wrangler assets): zero third-party font requests, robust everywhere.
theme (font keys) font stacks, optional Exactly --font-sans, --font-serif, --font-mono may carry font stacks (family names, quotes, commas, spaces only — anything else could carry CSS and is refused); every other theme key stays hex/rgb-only. The flagship instance sets the Ribose identity: the cyan brand scale, Source Sans 3, Inconsolata.
strings map, optional Chrome-word overrides for the FIXED key set — level.operational/degraded/down/unknown, phase.investigating/identified/monitoring/resolved/ongoing, feed.maintenancePrefix. Unknown keys are refused. Testids and API enums stay canonical English: display only, never the contract.

admin — the SaaS surface's presence (optional)

admin: false (or admin: {enabled: false}) closes the admin interface; absent = enabled. session_ttl_hours (default 8, at most 168) sets the admin session cookie's lifetime. With no oidc block the gate is the OPS_TOKEN secret — unset, the whole surface stays closed (404) whether or not the flag is set. See docs/api.md for the surface itself, and docs/manual/03-admin-guide.md for the operating guide.

admin.oidc moves the HUMAN sign-in to an OpenID Connect provider (the Ribose identity service in our estate, any conformant OP for a whitelabel deployment) — by protocol, never hardcoded endpoints:

Field Type Rule
issuer https URL, required The ONLY OP fact in config; every endpoint (authorization, token, JWKS) comes from /.well-known/openid-configuration at run time, cached 10 minutes.
client_id string, required The public client the operator registered with the OP. PKCE S256 covers it — no client secret exists here.
admins_claim claim name, optional The ID-token claim the OP marks administrators with (truthy = admitted). Omit when the subjects list is the whole truth.
subjects list of sub values, optional The explicit allowlist — an admitted fallback that needs no claim.

Access = the verified ID token's sub is listed, or its admins_claim is truthy. With oidc set the login page offers "Sign in with the identity service" (the token form hides); the write API's Bearer stays OPS_TOKEN either way — machines have no browser. The redirect URI to register at the OP is exactly <base_url>/admin/oidc/callback.

org — the tenant this deployment serves (optional; the SaaS org model)

Declares WHO this deployment belongs to and boots the membership model. Requires admin.oidc (memberships are keyed by the identity service's sub; an org without the identity login is meaningless — the loader refuses it).

Field Type Rule
id kebab-case The tenant id; also this deployment's slug in the page registry (page:<id>, self: true).
name string The display name on the admin's org card.
owners list of sub values, non-empty The BOOTSTRAP FLOOR: these identity-service subjects are always owners — KV rows can never lock them out; removal is a config edit, never an admin click.

With org set, admin access = membership: a config-floor owner, a member row, or a pending invite whose email matches the ID token's email claim WHILE the OP asserts email_verified: true (a signed token carrying an unverified email never redeems — the only identity-matching act, on verified claims only). Roles gate the surface: operators publish (drafts, incidents, the update trail); owners administer (members + invites, the page registry, backup/restore, subscriber deletes). The org's rows (org:, the member/invite rows, page:) ride in backups. See docs/manual/03-admin-guide.md.

groups — the ordered display groups

A non-empty list of unique strings. Every service's group must resolve here; the page renders the groups in this order.

vantages — every location the estate probes FROM

Field Type Rule
relay_stale_ms positive integer, required A relay silent past this window reads UNKNOWN on every card, never green (fail-closed).
list[].id kebab-case, unique Stable id; the home vantage's id must be exactly home.
list[].kind home | relay Exactly one home (this worker's own probes). Relays report over the guarded /api/vantage-report.
list[].label / list[].description strings The card's vantage strip copy. Geography is observed (the relay's self-reported colo), never claimed.

A relay that is not deployed stays commented out — never expect a report that cannot come.

services[] — the probed surfaces

Field Type Rule
id kebab-case, unique The KV row key is svc:<id>.
name / group / description strings group resolves in groups. The description names what the probe PROVES.
url https URL The probed endpoint.
request.method GET | POST, optional Absent: the plain GET. A deep-route probe carries the method + the same-site headers a user agent would send.
request.headers string map, optional Literal headers (content-type, origin, ...).
request.secretHeaders[] {name, header}, optional name = the env var NAME (UPPER_SNAKE_CASE); header = the header it rides. A value key here is REFUSED — secret values never live in config. An unset var omits the header (the honest degradation, never a probe failure).
request.body string, optional What a user agent would post (e.g. a known-nonexistent credential).
assert mapping, required The content assertion; see below.
warnLatencyMs positive integer A passing probe slower than this marks the service degraded.
slo.availability30d fraction in (0, 1] The 30-day availability target (0.999 = 99.9%).
slo.latencyP95Ms positive integer The 30-day p95 latency objective.

The assertion types (the registry lives in src/assertions.ts; adding a spec there — the loader and the probe consume it, no other engine change):

assert.type Fields Proves
json-equals path (dotted), value (a STRING — quote it in YAML) The body parses as JSON and the path equals the value.
body-includes needles[] (non-empty) The body carries every needle.
route-answers none The EXERCISED deep-route verdict: any answered sub-500 response within the 10s deadline is up (the healthy answer to a probe posting a known-nonexistent credential IS a fast 401); a 5xx or the timeout is DOWN. Requires a request posture — never on a shallow GET.

mail — the declarative half of the mailer (optional)

Field Type Rule
from address, required when mail is present The From address for both real postures.
provider_url https URL, optional A Resend-shaped HTTPS provider; the KEY is the MAIL_PROVIDER_KEY Worker secret, never here.

The runtime postures stay env-resolved (src/mail.ts): the send_email binding (a wrangler.toml concern), else the provider URL

webhooks — the machines' subscription (optional)

A list of receivers that hear about every published incident (both sources; drafts never):

Field Type Rule
url https URL, required The POST target. A Slack/Teams incoming webhook URL works as-is — the payload is JSON.
secret env var NAME, optional UPPER_SNAKE_CASE; names a Worker secret. Set and resolved → deliveries carry X-Status-Signature: sha256=<hmac-sha256(body)>. A secret VALUE here is refused.

The payload: {event: "incident.published", instance: <base_url>, incident: <the Incident>}. A 2xx delivers once; failures retry on later rounds up to 10 attempts, then the receiver is abandoned and named in the log.

incident_templates — the composer prefills (optional)

Up to 10 templates, each {name, kind?, title?, impact?, update_status?, update_body?, body?} (prose only; the standing secret-refusing rules apply). The admin renders one prefill link per template (/admin?template=<name>); nothing is forced — the operator edits before publishing.

probe_interval_ms — optional, default 60000

The probe round interval the cron is configured for (informational; the cron itself lives in wrangler.toml; the shell's honesty footer renders it).

The error catalog

Every error names the file, the key path, and the problem, e.g.:

config/status.yaml: services[3].assert.type: unknown assert type 'json-contains' (known: json-equals, body-includes, route-answers, dns-answers, smtp-dialogue, canary-received)
config/status.yaml: services[3].group: unknown group 'Gamma' (declared groups: Alpha)
config/status.yaml: vantages.list: expected exactly one kind: home vantage, found 0
config/status.yaml: services[0].request.secretHeaders[0]: a secret VALUE never lives in config: name the secret (name: STATUS_PROBE_TOKEN) plus the header it rides (header: x-ribose-probe); the Worker resolves the value from its bindings at run time
config/status.yaml: services[1].assert.value: expected a string (quote it in YAML: the probe compares the JSON value as text)

test/load-config.test.ts carries a leg for every path; run it after touching the loader or a spec.

The service display flags (TODO 77)

Two optional boolean keys on any service — on or absent, never a truthy string:

Recipe: watching your own platform (the self-platform rows)

The estate watches its own supply chain like any other surface — pure config rows, no code: probe the board itself (the home page with a content assertion), /docs, /badge.svg, and the control plane's URL where one runs. The honest closed posture is probeable too: the plane's fail-closed 404 under a CONTROL_TOKEN-bearing request rides the request posture + route-answers exactly like the identity sign-in leg.

Recipe: DNS checks as pure configuration

DNS monitoring needs no engine change — DNS-over-HTTPS is a fetch with a JSON answer. The first-class form is the dns-answers assertion (TODO 72 tier A): NOERROR is the floor, and every expected fragment must ride at least one answer's data (matched case-insensitively, whitespace collapsed — a trailing dot or an RR priority never breaks the match):

- id: dns-mx
  name: "DNS: example.org MX"
  group: DNS
  description: The mail exchangers resolve and carry the expected host.
  url: https://dns.google/resolve?name=example.org&type=MX
  assert:
    type: dns-answers
    expect:
      - mail.example.org
  warnLatencyMs: 1500
  slo: { availability30d: 0.999, latencyP95Ms: 1500 }

(The url can equally be cloudflare-dns.com/dns-query?... — then the request posture must carry accept: application/dns-json, as in the json-equals form below.) The mail-path recipes ride this row: type=MX for the exchangers, type=TXT on the apex for SPF, on selector._domainkey for DKIM, on _dmarc for the policy.

The raw form (what dns-answers wraps, still valid):

- id: dns-www-a
  name: "DNS: www A record"
  group: DNS
  description: Resolves the A record via DoH and asserts the answer's rcode.
  url: https://cloudflare-dns.com/dns-query?name=www.example.org&type=A
  request: { method: GET, headers: { accept: application/dns-json } }
  assert: { type: json-equals, path: Status, value: "0" }
  warnLatencyMs: 1500
  slo: { availability30d: 0.999, latencyP95Ms: 1500 }

Assert Status: 0 (NOERROR) for resolution health, or a known Answer.1.data address for a pin. Cert expiry needs no recipe here: the CP posture refuses vendor-fetched cert data; a dedicated probe would be a new assertion spec (the registry is the extension point).

Recipe: the round-trip canary (delivery measured, TODO 79 tier C)

The mail block's canary turns email monitoring from inference into measurement: the prober sends a tokened mail to the address, the domain's Email Routing hands it to the worker's email handler, the arrival is stamped, and the canary service's probe asserts the token arrived within the SLO:

mail:
  from: notify@example.org
  canary: { address: canary@example.org, slo_ms: 300000, cadence_ms: 600000 }
services:
  - id: mail-canary
    name: "Mail: delivery"
    group: Mail
    description: A canary mail completes the round trip within the SLO.
    url: https://mail.example.org/
    assert: { type: canary-received }
    warnLatencyMs: 60000
    slo: { availability30d: 0.999, latencyP95Ms: 120000 }

Recipe: the SMTP transport (the exercised mail path)

The mail path can be EXERCISED, not inferred: the smtp block makes the probe run the real dialogue — banner, EHLO, (STARTTLS,) QUIT — and the dialogue IS the verdict (assert.type: smtp-dialogue). The service's url stays required: it is the page a human checks (the webmail or the provider's surface), never fetched for this service:

- id: mail-path
  name: "Mail: submission path"
  group: Mail
  description: The submission host answers, offers TLS, and speaks ESMTP.
  url: https://mail.example.org/
  smtp: { host: mail.example.org, port: 587, mode: starttls }  # 465 → mode: implicit
  assert: { type: smtp-dialogue }
  warnLatencyMs: 2500
  slo: { availability30d: 0.999, latencyP95Ms: 2000 }

A 5xx banner, a refused EHLO, a failed STARTTLS, a hang past the deadline — each is DOWN with the code in the reason; the dialogue is bounded (10 s, an 8 KiB reply cap) and never sends a payload (no message is composed or delivered — tier C's canary is a separate, owner-gated act). Platform note: outbound port 25 is refused by the cloud; 465/587 are the supported postures (docs/manual/09).

The fixtures (config/fixtures/)

Hostnames are RFC 2606 .test: nothing resolves, and no test ever fetches them. The suite proves config-independence by passing with CONFIG=config/fixtures/minimal.yaml npm test.

Migrating an estate that still carries TypeScript registries

scripts/convert-registries.ts turns config/services.ts + config/vantages.ts into the YAML mechanically (data verbatim; secretHeaders record → {name, header} list) and validates its own output through the loader. Doctrine prose does not survive a mechanical pass: port the registries' comments by hand afterwards, and add the mail block by hand.

npx tsx scripts/convert-registries.ts \
  --services <checkout>/config/services.ts \
  --vantages <checkout>/config/vantages.ts \
  --name "Estate Status" --base-url https://status.example.org \
  --description "..." \
  --logo-light https://.../logo.svg --logo-dark https://.../logo-white.svg \
  --link "Estate|https://www.example.org/" \
  config/status.yaml