Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
32 KiB
status, system, requirements, created, owners
| status | system | requirements | created | owners | ||||
|---|---|---|---|---|---|---|---|---|
| draft | executors |
|
2026-09-07 |
|
SSH Host Reachability System Design
Purpose and boundaries
The executor system owns SSH lifecycle and executor failure and recovery contracts, so it owns the path answering "is this SSH host still reachable". This design covers the poller, the probe, its classification, persistence and configuration. The API, event and web projections are in SSH Host Reachability Surfaces.
Contracts this design uses but does not own:
internal/agent/runtime/lifecycleowns SSH target resolution (ResolveSSHTarget), dialing (DialSSH), fingerprint pinning,ProxyJump, and the dial error types. The probe consumes those, and does not reimplement.- The task system owns
executors,executor_profiles, andtask_sessions, and the event bus to WebSocket bridge ininternal/gateway/websocket. internal/common/configowns the operator configuration catalog.internal/integrations/healthpollis precedent, not a dependency: itsProberis shaped around one integration with many workspaces, while this poller sweeps many independent hosts. Its conventions are kept, not imported.
Requirement mapping
| Requirement | Design section |
|---|---|
REQ-EXECUTORS-SSH-REACHABILITY-001 |
The poller, The probe, State transitions, Persistence, Configuration |
REQ-EXECUTORS-SSH-REACHABILITY-002 |
Surfaces design |
REQ-EXECUTORS-SSH-REACHABILITY-003 |
Surfaces design |
The task-card indicator and the launch-path record write are deferred; see the
requirement document's ## Out of scope.
Components and responsibilities
internal/agent/runtime/lifecycle (extended). SSHExecutor.HealthCheck
keeps returning nil: the runtime is available even when an individual host is
not, and the registry's HealthCheckAll sweep is a per-runtime concept called
once from Manager.Start. Conflating per-host reachability with runtime health
there would mark the whole SSH runtime down because one of several hosts moved.
This package gains three exported functions and the two types they need:
SSHTargetFromExecutorConfig(map[string]string) (*SSHTarget, error)— lifted frominternal/ssh; projectsexecutors.configinto theSSHConnConfigthatResolveSSHTargettakes. That function needs the typed struct, not a map, so without this the reachability package must importinternal/sshto read an executor's own config.ProbeSSHHost(ctx, target *SSHTarget, timeout time.Duration) SSHProbeOutcome— dials with the pinned fingerprint, closes immediately, runs no remote command, and reports success, cancellation, target host, reason, and a sanitized message. It bounds the handshake on the derived probe context, not only the TCP dial.ssh.NewClientConntakes no context andssh.ClientConfig.Timeoutcovers onlyssh.Dial's own TCP connect, so a host that completes TCP and then stalls the handshake would hold its goroutine pastprobeTimeoutand pastStop.handshakeWithDeadlinesets a deadline when the transport supports it and also closes the owner connection from a context cancellation callback, which covers ProxyJump channel connections.ClassifyDialError(err error) SSHReachabilityReason— maps a dial error to exactly one reason from the closed set.
Classification lives here, not in the reachability package, and that is the
load-bearing decision in this section. The error types it must distinguish are
lifecycle's own and unexported: errHostKeyMismatch is lowercase, so no other
package can errors.As it. Exporting the classifier rather than the error
types keeps that boundary intact and gives the launch path identical
classification without a second implementation.
Reason is a string enum whose members are exactly config, network,
timeout, auth, host_key, unknown, plus the empty string for success.
internal/executors/reachability (new). Owns the poller, the concurrency
limiter, the hysteresis rule and the store interface: it enumerates eligible
executors, orders them, runs a bounded pass, calls ProbeSSHHost, applies the
state rule, writes results and publishes change events. It depends on a narrow
repository interface, not the full task repository.
internal/task/repository/sqlite (extended). Owns the
executor_reachability table, its additive migration, its queries and the
explicit record delete in Persistence.
internal/task/service (extended). Owns save detection, because it is the
only layer holding both the stored executor and the incoming change.
Service.UpdateExecutor (service_resources.go) loads the row, applies the
request and bumps UpdatedAt; Service.CreateExecutor is the creation call
site. Each notifies an ExecutorSaveObserver — an interface the service
declares and the reachability package implements, wired at construction, so the
service depends on an interface rather than on the reachability package. The
observer is handed the connection-configuration fields as they stood before the
save and as they stand after, compares exactly the fields Terminology names,
and on any difference performs the reset in Persistence and
schedules the off-cycle probe. A create is a difference by definition.
applyExecutorUpdates mutates the loaded executor in place, so the before
values must be copied out before it runs. Read afterwards they compare the row
against itself, every save looks like a no-op, the reset never fires, and a
re-pointed executor keeps reporting the previous host's state indefinitely.
internal/ssh and apps/web. The HTTP and WebSocket projection, the web
components and the launch interaction. Specified in the
surfaces design, which owns them; this document
states no contract for them.
The poller
A single ticker loop, started where the lifecycle manager and the integration
pollers start and stopped on backend shutdown. Both Start and Stop are
idempotent. Every probe the package starts is registered on one package-owned
WaitGroup and derives from one package context, so Stop cancels and awaits
all of them — the in-flight pass, an off-cycle probe after a save, a coalesced
immediate probe — not only the pass. That is what makes an immediate probe's
package-owned context safe: it outlives its caller but not the package. Draining
is bounded because every probe carries probeTimeout and because
ProbeSSHHost bounds the handshake phase as well as the TCP dial; without that
second half a stalled handshake outlives both its deadline and Stop. A goleak
TestMain guards the package, matching healthpoll.
When the effective interval is 0 the poller does not start: no startup pass
and no ticker. The immediate-probe route still works, not going through the
poller loop. The package context, WaitGroup and semaphore are still created
and registered for cleanup; only the pass and ticker are skipped. A Start
returning early before creating them leaves immediate probes unowned at
shutdown, breaking the drain contract and the goleak guard.
One pass:
- List eligible executors:
type = 'ssh',deleted_at IS NULL,status = 'active', capturing each row'supdated_atwith its id. - Sort ascending by the
executors.idcolumn.idis the primary key and is never reused, so the order is total; no tiebreak is needed or defined. - Acquire the shared probe semaphore per executor, preserving dispatch order.
- Await all workers, then release the pass.
A sync.Mutex-guarded passRunning flag makes an overlapping tick a no-op: the
tick is dropped, not queued, and increments a skipped-pass counter. Queueing it
would compound the overload that caused the overlap, and the next tick carries
identical information.
The semaphore is process-global, not per-pass. One passConcurrency-slot
semaphore, owned by the package, is acquired by every probe whoever asked for
it: a scheduled pass, an off-cycle probe after a save, an immediate-probe
request. Saving twenty executors in a script therefore opens at most
passConcurrency connections, not twenty. A launch's own DialSSH is not a
probe and never takes a slot: a launch must not queue behind background probing.
Cost per pass is N TCP connections and handshakes for N hosts, at most
passConcurrency at a time, each bounded by probeTimeout. At the defaults
below, one host costs one handshake per minute.
The probe
Per executor, inside its own context.WithTimeout(ctx, probeTimeout):
- Project
executors.configinto anSSHConnConfigand resolve it withlifecycle.ResolveSSHTarget— the same projection the existing handler performs insshTargetFromExecutorConfig, lifted into a shared helper so the poller and the handlers cannot drift. A resolution failure classifies asconfigand returns without touching the network.ResolveSSHTargetdoes not validate the pin, so the probe checks it separately and returnsconfigwhen the resolved target'sPinnedFingerprintis empty, before any dial. Leaving this to resolution is a security defect rather than a gap:buildClientConfig's host-key callback returnsnilunconditionally when the pin is empty, so the probe would dial unpinned, accept whatever key answered, and record a substituted host asreachable. lifecycle.ProbeSSHHost(ctx, target), dialing with the pinned fingerprint set, so a changed host key fails the handshake rather than being silently accepted. That is the substantive difference from the manual test path, which dials unpinned to observe a fingerprint for first-time trust.- On success the client is closed immediately and the outcome is
reachable.
ClassifyDialError walks a total order and returns the first match. The table
below is written in that order, and the order is the contract:
| Order | Reason | Condition |
|---|---|---|
| 1 | config |
Target resolution failed, or resolved with no pinned fingerprint, before any dial. |
| 2 | timeout |
The probe deadline expired, or the error unwraps to a net timeout. |
| 3 | host_key |
The handshake failed host-key verification against the pin. |
| 4 | auth |
Transport negotiation completed; authentication failed. |
| 5 | network |
TCP dial failed: refused, no route, DNS, network unreachable. |
| 6 | unknown |
Any other error. |
config is decided before any dial. timeout precedes network because a
deadline expiry surfaces as a dial error and would otherwise read as a refused
connection. host_key precedes auth because both arrive as handshake failures
and only host_key is a security event. The stored message is the error text,
which dialDirect already stamps with host and port.
State transitions
Three states, and one counter that is part of the contract rather than an implementation detail:
unknown: no record, or a record explicitly reset. Never inferred.reachable: the last probe succeeded.unreachable: a sticky failure, or a transient failure streak that reachedfailureThreshold.
On success: state = reachable, consecutive_failures = 0, reason and
message cleared, last_success_at = checked_at. One success is enough from any
prior state: a host that just answered is reachable, and delaying that
conclusion only prolongs a false alarm.
On a sticky failure (config or host_key): state = unreachable
immediately, whatever the counter says. The counter still increments and
last_success_at is left alone. Both conditions are deterministic and will
repeat on the next probe, so the threshold would buy no confidence — it would
only delay a possible host-key compromise by a full interval.
On a transient failure (network, timeout, auth, unknown):
consecutive_failures += 1, and the state becomes unreachable on the probe
where the counter reaches failureThreshold. Until then the state is left
exactly as it was: reachable stays reachable and unknown stays
unknown. A failing probe never promotes a state, so it cannot turn unknown
into reachable; it changes the reason and the counter while the state stands
still. That pair, a non-empty reason under reachable or unknown, is the
intended representation of a below-threshold failure, not a contradiction: one
dropped packet must not paint a healthy host as down, while recovery is reported
at once.
A save that changes the connection configuration resets the record to unknown,
then probes out of band if the executor is still eligible, because the prior
streak and success describe a different target. A save that changes nothing
resets nothing; the mechanism is in Persistence.
Persistence
New table, one row per SSH executor:
CREATE TABLE IF NOT EXISTS executor_reachability (
executor_id TEXT PRIMARY KEY,
state TEXT NOT NULL DEFAULT 'unknown',
reason TEXT NOT NULL DEFAULT '',
message TEXT NOT NULL DEFAULT '',
consecutive_failures INTEGER NOT NULL DEFAULT 0,
host TEXT NOT NULL DEFAULT '',
checked_at TIMESTAMP,
last_success_at TIMESTAMP,
updated_at TIMESTAMP NOT NULL
);
last_success_at exists because the pre-launch warning must name the age of the
last successful probe, and checked_at is overwritten by every failure. It is
NULL until the first success, and only the configuration reset clears it.
A separate table rather than columns on executors: config is user-authored
input and status a user-controlled switch, so observed state must not share
either, and a per-interval write stays off the row every read touches.
Deletion is explicit, not a cascade. Repository.DeleteExecutor is a soft
delete (UPDATE executors SET deleted_at = ?, updated_at = ?), so no foreign-key
cascade can fire and the table declares none. DeleteExecutor issues DELETE FROM executor_reachability WHERE executor_id = ? in the same transaction as the
soft delete, and is the only call site that removes a record.
One statement decides every observation. The write is a single upsert; the caller
supplies the observation (reason, message, host, checked_at) and the statement
derives the counter and the state from the row's own prior values, so no
read-modify-write in Go can lose a streak to an interleaving:
INSERT INTO executor_reachability (executor_id, state, reason, message,
consecutive_failures, host, checked_at, last_success_at, updated_at)
SELECT :id, :state, :reason, :message, :failures, :host, :checked_at,
:last_success_at, :now
WHERE EXISTS (SELECT 1 FROM executors e
WHERE e.id = :id AND e.deleted_at IS NULL
AND e.status = 'active' AND e.updated_at = :seen_updated_at)
ON CONFLICT (executor_id) DO UPDATE SET
consecutive_failures = CASE WHEN :reason = '' THEN 0
ELSE executor_reachability.consecutive_failures + 1 END,
state = CASE WHEN :reason = '' THEN 'reachable'
WHEN :reason IN ('config','host_key') THEN 'unreachable'
WHEN executor_reachability.consecutive_failures + 1 >= :threshold
THEN 'unreachable'
ELSE executor_reachability.state END,
reason = :reason, message = :message, host = :host,
checked_at = :checked_at,
last_success_at = CASE WHEN :reason = '' THEN :checked_at
ELSE executor_reachability.last_success_at END,
updated_at = :now
WHERE executor_reachability.checked_at IS NULL
OR excluded.checked_at > executor_reachability.checked_at;
On the insert path there is no prior row, so the caller supplies the initial
state and failures; every later write derives both in SQL from the stored
row. The ON CONFLICT WHERE is last-write-wins on checked_at: a scheduled
pass and an immediate probe racing on one executor converge on the later
observation rather than on whichever transaction committed second, and an equal
timestamp is not later, so the incoming row loses. checked_at is stored at
millisecond resolution or finer, keeping the tie rule a backstop.
That > is a text comparison, so the encoding is part of the contract.
TIMESTAMP takes NUMERIC affinity and the driver binds time.Time as text
(2006-01-02 15:04:05.999999999-07:00), so SQLite orders these lexically, not
temporally. Two rules make lexical order equal time order, and both are
required: normalize to UTC before writing, because the driver keeps whatever
offset it is handed and a DST fall-back then sorts an earlier observation later;
and bind time.Time, never a pre-formatted string, because RFC3339 renders
UTC as Z, which sorts after . and would let a whole-second observation beat
a later sub-second one. Postgres compares temporally and is unaffected, which is
why this belongs in the SQLite tests: the parity test passes on Postgres while
SQLite keeps the wrong row.
The eligibility guard is the EXISTS above, and :seen_updated_at is the
updated_at the probe captured before it dialed. Every mutation of the executor
row — configuration save, status change, soft delete — bumps updated_at, so one
comparison covers all three: it stops a probe in flight against the old target
from landing after a reset, and stops a worker from re-creating a deleted record
or writing one for an executor deactivated mid-pass. The EXISTS gates the
insert; the ON CONFLICT arm inherits it, because a row can only conflict if the
insert was admitted. A refused write is logged at debug and dropped.
The reset is a second statement, and deliberately not that one. A
configuration reset is not an observation; it invalidates one. The upsert above
cannot express it — its state CASE has no unknown branch, and its WHERE
admits only a strictly later checked_at, which a reset does not carry. Nor may
it be expressed as a delete: DeleteExecutor is the only call site permitted to
remove a row.
INSERT INTO executor_reachability (executor_id, state, reason, message,
consecutive_failures, host, checked_at, last_success_at, updated_at)
SELECT :id, 'unknown', '', '', 0, :host, NULL, NULL, :now
WHERE EXISTS (SELECT 1 FROM executors e
WHERE e.id = :id AND e.type = 'ssh'
AND e.deleted_at IS NULL AND e.status = 'active')
ON CONFLICT (executor_id) DO UPDATE SET
state = 'unknown', reason = '', message = '', consecutive_failures = 0,
host = :host, checked_at = NULL, last_success_at = NULL, updated_at = :now;
The EXISTS carries a decision, not just a safety check: an executor whose
status is not active is not reset by a save, because
AC-EXECUTORS-SSH-REACHABILITY-001.17 retains its record unchanged and
eligibility wins over the reset trigger. The statement lands zero rows and the
retained record stands until the executor is reactivated and probed.
:host is the newly saved host, so the settings page never renders a pre-change
host beside unknown — the window this capability exists to illuminate is
exactly the one that would otherwise lie. There is deliberately no checked_at
guard and no :seen_updated_at pin: a reset is ordered by the save that caused
it, not by an observation clock, so it must win over whatever is stored. A probe
already in flight against the pre-save target cannot overwrite it afterwards,
because that probe's own write still carries the pre-save updated_at and the
upsert's EXISTS rejects it. That is the mechanism
AC-EXECUTORS-SSH-REACHABILITY-001.19 requires, and it is why the reset needs no
clock of its own.
The reset advances the row's updated_at like any other write, which is what
lets a client order it against a concurrent probe result: see the surfaces
design's reconciliation rule.
Retention is one row per executor. The table survives restart and the surface reports the preserved record until a new probe replaces it: a blank panel after every restart would be worse than a timestamped result marked stale.
The migration is a single additive CREATE TABLE IF NOT EXISTS applied through
the existing idempotent mechanism in internal/task/repository/sqlite, so it is
safe on SQLite and Postgres and on an already-migrated database.
Configuration
One catalog entry in internal/common/config/catalog.go:
| Key | Environment variable | Default |
|---|---|---|
executors.sshReachabilityIntervalSeconds |
KANDEV_EXECUTORS_SSHREACHABILITYINTERVALSECONDS |
60 |
Supported range 15 to 3600 seconds; 0 disables the poller. The catalog does
not clamp. applyNonNegativeIntEnv falls back to the default — never to a
bound — for a value that is absent, empty, negative, fractional or unparsable, so
every one of those yields 60. Its minimum must stay 0: widening it to 15 to
express the supported range would push 0 below the minimum and silently turn
the documented kill switch into a 60-second cadence.
That normalization is the environment path only, and the two sources do not
behave alike. applyBoundedIntEnv, which applyNonNegativeIntEnv wraps, reads
nonEmptyEnv(env, entry.EnvVars...) and returns early when no environment
variable is set, so a value written in config.yaml never reaches its
fallback-and-bounds logic at all. A YAML value is decoded by decodeConfig,
which sets no WeaklyTypedInput, and Load returns the decode error, so a
non-integer in config.yaml refuses boot exactly as it does for every other
typed key in the catalog. That is the documented behavior rather than an
exception carved out for this key, and
AC-EXECUTORS-SSH-REACHABILITY-001.24 states it that way instead of promising a
leniency the loader does not implement. A negative whole number does decode
cleanly and so does reach the package, which is why the clamp below needs a
negative branch of its own.
Clamping is therefore the package's job, applied to whatever value reaches it:
below 0 yields the default 60, 0 disables, 1-14 becomes 15, above
3600 becomes 3600, and a clamp logs once at startup rather than refusing to
boot.
The clamped value is the effective interval, and every consumer uses it.
Staleness (three times the interval), the probing_enabled projection, and the
settings page's own refresh cadence all read the effective interval, never the
raw configured number. An operator who writes 5 gets a 15-second cadence and a
45-second staleness horizon, consistently across the backend and the UI.
The other three knobs are compile-time constants, not configuration, because no operator scenario has been identified that needs them independently and each new key is a permanent contract:
| Constant | Value | Rationale |
|---|---|---|
probeTimeout |
10s | sshDialTimeout is 30s, which would let one hung host hold a slot for half the interval. |
passConcurrency |
4 | Bounds simultaneous outbound connections and file descriptors while keeping a pass short. |
failureThreshold |
2 | Smallest value that suppresses a single-probe blip; worst-case detection is two intervals. |
This capability adds no runtime feature toggle: the interval key already provides the kill switch through the designated operator-startup mechanism, and a toggle would leave a retired identity to carry forever.
Control flow
ticker -> poller.pass() (only if effective interval > 0)
| list eligible SSH executors by executors.id, with updated_at
v
global semaphore(passConcurrency) <- off-cycle and immediate probes
v
probe() SSHTargetFromExecutorConfig > ProbeSSHHost > ClassifyDialError
v
store.Upsert(observation) guards: eligible, updated_at, checked_at
|- state or reason changed -> bus.Publish -> WS -> store slice
'- unchanged -> stop
save -> ExecutorSaveObserver (do the connection-config fields differ?)
'- yes -> store.Reset(host) guard: eligible -> bus.Publish -> off-cycle probe
launch -> session creation reads record, emits session.launch.warning
'- SSHExecutor.CreateInstance (reads no record, writes no record)
'- failed at its dial -> ClassifyDialError -> error names host
Failure and recovery
The poller is best-effort and never a source of truth for a caller. Every
failure mode degrades to a stale or unknown record and a log line:
- Listing executors fails: the pass is abandoned with a warning, nothing is written, the next tick retries.
- A probe panics or errors unexpectedly: it classifies
unknownand the pass continues; no other executor is affected. - A record write fails: logged, the pass completes, the next probe rewrites, costing one interval of freshness.
- A write refused by a guard is expected, not an error: logged at debug and dropped.
- A probe exceeding
probeTimeoutis abandoned by its context; the client is closed on the returning path so it leaks no connection, and its slot released. - A probe cancelled by
Stopwrites nothing, which is not the case above. Shutdown cancels the package context, so an in-flight dial returnscontext.Canceled, and no guard catches it: a shutdown mutates no executor row, soEXISTSandupdated_atstill admit the write. Recorded, it would incrementconsecutive_failuresfor every probe in flight — up topassConcurrencyhosts per restart — and atfailureThresholda second restart would flip a healthy host tounreachable, turning a routine deploy into exactly the false alarm this capability exists to prevent.ClassifyDialErrormust therefore separate the two context errors: only a deadline is atimeoutobservation, and a cancellation is not an observation. - The store is unreachable on an API read: the route errors and the surface
reports reachability as not known, never
unreachable.
There are no retries inside a pass: the next tick is the retry, and an immediate retry against a host that just refused a connection adds cost, not information.
Security
The probe uses the pinned fingerprint on the dial to the target, so a
host-key change there is a detected host_key failure and never a silent
re-pin; the stored fingerprint is read-only to this path. host_key reports
unreachable on its first occurrence rather than waiting out the threshold: a
mismatch is possible interception, and delaying it a full interval is a security
cost with no accuracy benefit. Credentials are unchanged: the probe reuses the
executor's configured identity source and holds no secret of its own.
message carries an SSH error string, which can include host, port, username
and identity path — all values the user configured and can already see on the
same settings page, so the record is not sensitive. ClassifyDialError must not
place key material or agent socket contents into message.
The probe opens no remote shell and runs no remote command, so it grants nothing beyond what the manual test already exercises, and adds no new trust boundary.
ProxyJump is a second hop with a weaker trust model, and the reason set must
not paper over it. An executor pins one fingerprint and it belongs to the
target; dialViaJump verifies the bastion against ~/.ssh/known_hosts instead,
accepting an absent host with a logged warning (OpenSSH's
StrictHostKeyChecking=accept-new). Two consequences the probe inherits:
- A bastion key mismatch is rejected, but surfaces as
ssh: bastion dial: …, not aserrHostKeyMismatch, soClassifyDialErrorwould call itnetworkorunknownand wait outfailureThresholdunder the wrong reason. The classifier must map a bastion host-key rejection tohost_key, whichAC-EXECUTORS-SSH-REACHABILITY-001.29now requires observably. - Every bastion-path error is about the bastion, while the record's
hostis the target. A bastion that is down reports the target unreachable and sends an operator to a machine that is fine — the same misattribution as the firewall message of 2026-09-06, from a different cause.hoststays the target in every case: it identifies the executor's subject, the column keeps one meaning for success and failure alike, andAC-…-001.4andAC-…-002.1stay true for a probe that succeeds through a jump. The disambiguation belongs inmessage, which must name the bastion and state that the failure arose on the jump hop whenever it did.messageis rendered beside the state wherever an unreachable record is shown, so the operator reads "unreachable … via bastion X" instead of being sent to a healthy target.
Observability
Structured zap logs plus expvar counters under /debug/vars, following the
office_stall_* and routing_* precedent. Every name below carries the prefix
executor_ssh_reachability_:
probe_total, labelled by outcome (reachableplus each failure reason).state_transitions_total, by destination state.pass_skipped_total, when a tick is dropped because a pass is still running: the interval is shorter than a pass.write_refused_total, when a guard drops a write. A rising value means configuration churn, not a fault.reset_total, when a save invalidates a record.probe_discarded_total, whenStopcancelled a probe.probe_totalis labelled by outcome and a cancelled probe has none, so without this the shutdown discard is the package's only uncounted drop.probe_duration_ms, the last pass's aggregate probe duration.
A transition logs at Warn for reachable to unreachable and Info for the
reverse, with executor id, host, reason and failure count. Unchanged results do
not log, so a steady host stays silent.
Prior art and departures
internal/integrations/healthpoll is the closest in-repo precedent: an
immediate probe on start, a fixed cadence, a configured-or-skip gate,
best-effort semantics, idempotent Start/Stop, and a goroutine-leak test.
Those conventions are kept. Its persistence shape — a timestamp, a boolean and an
error string on the config row — is not, in four places, because an SSH host is
not an HTTP API: a closed reason set rather than an error string, since five
failures are acted on differently and only a typed reason lets the UI say
"fingerprint changed"; asymmetric hysteresis rather than flipping on the
first result, which would let one dropped packet paint a healthy host as down
(see State transitions); a separate record rather than
a column, since observed state must not share a row with user-authored input
(see Persistence); and changes pushed, results not, since
pushing every result would put one message per executor per interval on the wire
forever.
Testing
Strategy only; the plan's ## Tests table owns the per-criterion evidence.
- Poller tests drive the ticker with
testing/synctest, matchinghealthpoll_test.go, under agoleakTestMain. - Classification and hysteresis are table-driven — over constructed errors and over probe sequences — so the ordering rules and the exact probe each state flips on are asserted, not inferred.
- The upsert's ordering rule is exercised on both dialects, including the whole-second versus sub-second pair the text-encoding trap turns into a silent SQLite-only failure.
- The guards get a negative test each: soft-deleted, deactivated or reconfigured
mid-probe writes nothing. The reset gets its own: it lands with no
checked_at, and a probe in flight from before the save does not overwrite it. - The immediate-probe route is tested for caller cancellation and for a
guard-refused write returning
persisted: false. - Web E2E lives in
apps/web/e2e/tests/ssh/under thecontainersproject, gated onKANDEV_E2E_CONTAINERS=1.
Related decisions
No new ADR: this design creates no system boundary and no ownership rule, and extends an existing contract through the repository's designated mechanisms for configuration, persistence, events and metrics. (0018 covers the adjacent runtime-override tier.)