0017. Lock the config surface before 1.0
0017. Lock the config surface before 1.0
- Status: Accepted
- Date: 2026-07-12
Context
The first release turns every key name, nesting choice, default value and duration unit in the config into a backward-compatibility contract. Not all of it is equally locked, so the review ranked the surface by reversibility:
- Irreversible — no alias can save these: default values (changing one
silently changes behavior for every config that omits the key), enum spellings
(
severity,status,protocol— baked into user bodies and PromQL), metric names/labels (0015 ), and alert-body field names (0018 ). - Alias-able: YAML key names — a rename can carry a deprecation alias for a migration window after 1.0.
Every key was walked once, and the changes landed atomically in one commit, so
the first published config.example.yaml is the contract we keep.
Decision
Renames and removals (old spellings rejected by KnownFields strict parsing):
- Target
retries→connect_retries— it retries only the connection (unreachable), within a cycle; the name scopes it to what it governs and separates it from the cross-cycle debounce. Notifier-sideretries(delivery retries) deliberately keeps the plain name — locally unambiguous, liketimeout. - Target
confirmations→flap_streak— “flap” says what it debounces (transient network statuses, not fact statuses; 0009 ), “streak” says how (a run of N consecutive cycles). - The singular
notifier:sugar is removed — a mutually-exclusive dual form (pointer field, two both-set error paths, doc caveats) to save typing[ ]in one case. Onlynotifiers: [name]remains. - Notifier
content_typeis removed, folded intoheaders— it was two ways to set one header with a silent precedence. TheContent-Type: application/jsondefault moves into the sender before the headers loop, so a header still overrides it and JSON notifiers don’t hand-write it.
Defaults (the irreversible bucket, checked value by value):
send_recoverydefaults totrue— a notifier that omits it must still close the loop (“recovered”), not go silent. This forces the typebool→*bool(a bare bool’s zero valuefalseis indistinguishable from unset); auto-resolving receivers (e.g. PagerDuty) opt out with an explicitfalse.alert_repeat_intervaldefault stays a flat24hscalar — simple and predictable; the per-severity map (0010 ) is the documented upgrade, not the default.database.alert_log_retentiondefault corrected to365d(code said90dwhile the example promised365d— a plain bug, fixed ahead of the pass). The remaining defaults (check_interval 5m,concurrency 10,timeout 10s,warning_days 30,critical_days 7,probe_log_retention 90d) were reviewed and kept.
Deliberate keeps, recorded so they don’t get “fixed” later:
servername, notserver_name. It is a cross-surface word: also a frozen metric label (0015 ) and a CLI flag, and a legitimate TLS/SNI token (openssl s_client -servername). Renaming only the key desyncs three surfaces, and the metric label is on the irreversible list — the snake_case exception is the lesser evil.protocol: tls, nothttps. The enum’s axis is “how the TLS session starts”: the STARTTLS entries are named by the app dialog the prober actually speaks (smtp/imap/pop3/ftp/postgres), andtlsnames the case with no app dialog — pure handshake from byte 0, covering every implicit-TLS target (443, 465, 993, 995, 636, raw TLS…).httpsis a subset name for a superset behavior and invites “why nosmtps/imaps?”.concurrency/timeout/insecurereused across probe and notifier scopes (same concept, different scope; qualifying them stutters), andcheck_intervalmatching the user-facingcertel checkverb.- The “days” vocabulary convention: “days” always means whole days until
expiry; threshold keys are
<severity>_days, the live value isdays_left— never a second root (remaining,expires_in).
Consequences
- After 1.0, any key change needs a deprecation/alias path; default values and enum spellings effectively cannot change at all. The churn budget was spent where no alias could ever help.
KnownFieldsstrict parsing makes removed/renamed keys fail loudly at load instead of being silently ignored.- The
connect_retries/flap_streakpair keeps the probe/alert boundary visible in config: intra-cycle “try harder” vs cross-cycle “wait and confirm” — they don’t even gate the same statuses.
Alternatives considered
- Group
connect_retries+flap_streakinto anunreachable_policyobject. Rejected: they sit on opposite sides of the probe/alert boundary, don’t gate the same statuses, and nesting would break the clean per-fieldtarget_defaultsfallthrough (merge-vs-replace ambiguity). - A
notifier_defaultssection mirroringtarget_defaults. Rejected: notifiers number 1–3 in practice, not dozens; the duplication is minimal and an extra top-level section costs more clarity than it saves. - Symmetric
connect_retrieson the notifier. Rejected: delivery retries are not connects; the plainretriesis locally unambiguous.