Decision 0017 — One probe vocabulary for checks and gates

One shared probe vocabulary for checks and gates, with the SSRF guard applied per face and Location opt-in per kind.

Status: accepted · 2026-09-15

Context

Reactive checks (tcp, ping, http) and post-run gate probes (http, dns, tls, metric) were two disjoint implementations of the same idea: run a network assertion, decide up/down from dial/exit/status truth. The overlap was real and drifting — both implemented HTTP with different default accept ranges (checks 2xx/3xx, gates 2xx) and only checks carried a dial-time SSRF guard. The v1.1 roadmap's cert-expiry signal (R2) was already written in the gate package but unreachable from a check, so an operator needed a second monitor for it.

Decision

Introduce internal/probe: one union Spec (Kind plus the per-kind fields), one Validate, one Run returning up/down + duration + detail, and one Location() (netip.Addr, bool) accessor. Both checks and gates build a Spec and call it; verify keeps its public Probe/Encode/Decode and job routing unchanged. Checks gain dns, tls (not-after floor), and metric (op/value/threshold) as a pure Go addition once the kind vocabulary exists.

Two consequences are explicit, not incidental:

  • check_row.kind widens by rebuilding both check_row and check_state — SQLite cannot alter a CHECK, and check_state's incoming FK means the child is dropped before the parent. Targets stay a flat, additive JSON shape so existing rows and golden tests are not restructured.
  • The SSRF guard is an option. Checks refuse loopback/link-local/unspecified/ multicast (AllowLoopback=false); gates allow them (true), because a gate legitimately probes a loopback health endpoint that a check must not.
  • Location is opt-in per kind. Only tcp/ping address literals (and a bare-IP tls address) classify into a netgroup; dns/tls/metric carry none, and every netgroup read path goes through Location rather than assuming Address is an IP.

Consequences

  • dns/tls/metric reach a check for free; the roadmap's cert ladder and metric thresholds need no new engine.
  • One HTTP implementation, one validation path, one test surface; the two accept-range defaults are preserved as a field and pinned by tests rather than silently unified.
  • internal/probe does not import store; the store builds a Spec from a check kind + target, and validation delegates to the same Spec.Validate.
  • A future probe kind is a Go addition plus, only if it changes the stored kind set, one widening migration — the same shape this record already paid for.