Checks, network groups and check-triggered runs
Named probe-sets, named CIDR sets, and the governed playbook rule action that let a run trigger on a reachability or health transition.
Status
Implemented (the shipped subset): netgroups and webhook peer stamping (§4.1,
§8.1), the check engine — check_row/check_state, the sweep with
transition-only emission, N-consecutive hysteresis, and cold-start seeding
(§4.2, §5) — and the governed playbook rule action (§7). The web ships
netgroup and check pages and a read-only rules page; rule mutation stays
CLI. The set-event host-set expansion (§7.4) was later built and then removed
by operator decision; see
Decision 0019. Still not built: the
presence shim (§8.2), a web rule-builder, and run-secrets/vault on rules. Where
the shipped code diverges from this proposal, code wins and the correction is
recorded in docs/log.md.
1. Vision
Run a governed job not only on a wall clock but on a state transition:
- "Run the update playbook on my laptop when the laptop joins the home network — and not when it is outside the house."
- "Restart the service when its health endpoint goes bad."
Three new primitives deliver it:
- Checks — a named, reusable probe set.
check add at-home --kind tcp --interval 5m --target laptop=192.168.1.50:22 --target nas=192.168.1.60:22. A check answers "is this host reachable right now" for each target, and fires one event into the existing reactive pipeline when a target's answer flips. - Network groups — named CIDR sets, the operator's own vocabulary.
netgroup add home 192.168.0.0/16,10.0.0.0/8. A group classifies an address (a probe target, or a webhook's peer) so a rule can askevent.labels["netgroup"] == "home"without the daemon knowing what "home" means. - A governed
playbookrule action — a rule can launch a real ansible run (source checkout, inventory, run-secrets, sandbox, approval attribution) instead of only a fixed shell argv.
The trigger surface is then uniform: a schedule stays a wall clock (cron), and a check is an event source a rule listens to. Two triggers for the same run = a schedule row and a rule row, both converging on the one queue.
2. What already exists — and why the naive version is not enough
The reactive layer is a complete "event → CEL rule → guardrail → queue → job"
pipeline (architecture/reactive.md). A naive version of this feature works today:
# laptop-side: on link-up, POST to the generic hook
curl -X POST -H 'Content-Type: application/json' \
http://<daemon>/hook/<generic-token> \
-d '{"name":"host.up","host":"laptop","status":"up","subnet":"home"}'
exocomp rule add laptop-home \
--source generic \
--expr 'event.name == "host.up" && event.host == "laptop"' \
--action shell -- /usr/local/bin/laptop-update.sh --cooldown 24h
status: up maps to firing; a later down maps to resolved and withdraws
the approvals its firing raised (internal/reactive/engine.go:226); dedup and
cooldown stop WiFi blips from hammering the run. What the shipped state cannot
do, and why:
| Gap | Why the naive version is wrong |
|---|---|
| Location is client-asserted. The laptop says "I'm home". A lying client, a misconfigured shim, or anyone holding the hook token can claim home from anywhere — and the daemon is reachable over tailnet, so "outside the house" does not even prevent the POST. | Location must be decided by the daemon, from where the signal physically arrived or from the address a probe actually reached. |
The only rule action that runs code is shell. A rule's action is a fixed argv (internal/reactive/action.go:69-84); there is no governed playbook path. Operators would wrap exocomp playbook run in a script and call it from shell — the exact side door the one-queue invariant (AGENTS.md 5) exists to close. |
A rule must be able to launch a governed run through the queue's own assembly path. |
| No check exists. Reachability is somebody else's job (a monitor posts it). | Checks make the daemon the observer, which is what makes the location signal trustworthy and the feature self-contained. |
The precedent this is built on: drift. A scheduled tofu plan whose exit
code says "changes present" becomes an event in the same pipeline through
Engine.SweepDrift (internal/reactive/drift.go:18), with SourceDrift a
non-webhook source (internal/reactive/event.go:17). A check sweep is the same
shape: the daemon observes, and observation becomes an event.
3. Core concepts and the two decisions that shape the design
3.1 Checks are named probe-sets; one event per transition
A check has a kind, an interval, and a set of targets. Each probe answers "is
this one target reachable". Nothing is emitted on a steady state — an event is
emitted only when a target's answer flips down→up (firing) or up→down
(resolved). Emitting only on transitions is what keeps dedup honest and gives
the resolve → withdraws-pending-approval semantics for free.
3.2 Reachability ≠ location — but the target address encodes location
A probe from the daemon answers "can I reach it", not "is it on the home wifi". Over tailnet the laptop is reachable from anywhere, so a bare reachability check cannot satisfy "not when outside the house".
The trick: probe the host's home-LAN address, not a name. The daemon runs on
the LAN. tcp 192.168.1.50:22 answers only while the laptop is actually on that
LAN — away at a coffee shop its address is a different range and the home IP goes
silent. The address is the location. Asleep-at-home is handled for free: asleep
= no reply = down, so "update when it wakes up at home" is exactly the
down→up transition firing the rule. No client shim, no peer verdict, no
credentials on the laptop.
This is why checks are sufficient for the headline case, and why a netgroup is
an optimisation rather than a requirement: the operator already wrote the
location down by choosing 192.168.1.50 as the target. The netgroup adds the
operator's name for that fact (home), so the rule reads
event.labels["netgroup"] == "home" instead of hardcoding a prefix, and the same
group classifies webhook peers on the push side.
3.2.1 Preconditions — the two ways address=location fails silently
The mechanism rests on a stable, LAN-only address↔host binding. Two preconditions must hold, or it fails open rather than failing closed:
- Static lease / reservation per target. A bare
tcp 192.168.1.50:22dial proves something answers on:22at.50— not that it is the laptop. If the laptop leaves and DHCP re-leases.50to a phone, the check reports "laptop home" for the phone. Targets must be pinned by static reservation (or the operator accepts a documented misattribution window). IPv6 SLAAC privacy addresses rotate the same way in reverse. - No overlay route to the LAN range. The whole premise is that
.50goes silent when the laptop is away. A tailscale subnet router advertising192.168.1.0/24makes the daemon reach.50over tailnet from anywhere — yet reaching it still requires a live host answering at.50, which is real presence, not a false home; the residual is a different device answering at the same address, already covered by item 1 (T32). The daemon must reach check targets only via the LAN interface, never an overlay subnet route: a deployment precondition the check sweep cannot enforce from inside the container (it has no routing-table authority), documented as a trust boundary in §10.
Both are owned as stated preconditions, and both get a threat-model row.
3.3 The trusted-source line — what may shape a run's --limit
A webhook body is attacker-influenced text that lands in a durable table
(engine.go:474, record scrubs it). It must never shape argv. A check or
drift event, by contrast, is produced by the daemon's own sweep code — the
probe answered, the daemon wrote the event.
This is the dividing line that keeps a rule's run parameters fixed at rule-add
time: a playbook action's --limit/--tags are validated constants, never
event-derived (§7.2). It is also the boundary any future set-expansion would
have to respect — if a later phase lets an event's host set shape a run's
--limit, only daemon-produced sweep events may carry that set, and a webhook
event is refused at rule-add time (§7.4). Phase 2 ships without any such
expansion.
4. Data model
4.1 netgroup — migration 0029_netgroup.sql
CREATE TABLE netgroup (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
prefixes TEXT NOT NULL, -- comma-separated CIDRs, validated at write
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
Store rows, like sources and schedules: plain SQLite, backed up with the data
volume (AGENTS.md 2). Prefixes are validated at write time by
netip.ParsePrefix (the same parser --hook-allow uses, ingress.go:96), so a
row that reads is a row that parses.
4.2 check_row and check_state — migration 0030_check.sql
check is a SQLite reserved word, so the table is named check_row; the
check_state FK references it.
CREATE TABLE check_row (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL CHECK (kind IN ('tcp', 'ping', 'http')),
interval_s INTEGER NOT NULL,
targets_json TEXT NOT NULL DEFAULT '[]',
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- Per-target transition state. Survives a restart so the sweep never
-- mistakes a rebooted daemon for a fresh down->up transition (§5.2).
CREATE TABLE check_state (
check_id INTEGER NOT NULL REFERENCES check_row (id) ON DELETE CASCADE,
target_name TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('up', 'down')),
consecutive INTEGER NOT NULL DEFAULT 0,
emitted TEXT NOT NULL DEFAULT '' CHECK (emitted IN ('', 'up', 'down')),
first_seen INTEGER NOT NULL,
last_change INTEGER NOT NULL,
PRIMARY KEY (check_id, target_name)
);
targets_json is one target per entry:
[
{"name": "laptop", "address": "192.168.1.50", "port": 22},
{"name": "nas", "address": "192.168.1.60", "port": 22},
{"name": "web", "url": "https://web.lan/health", "expect": 200}
]
A target is either address[:port] (tcp/ping) or a url (http, optional
expect status). The name is the event's host and the rule's vocabulary —
it does not have to be a DNS name, because the address is the load-bearing
field for location (§3.2).
check_state holds where each target's status currently stands. A missing row
IS the unobserved cold-start state; a probe that finds no row seeds it from
the first observation — status=result, consecutive=1, emitted=result —
without emitting (§5.2). consecutive carries the hysteresis counter and
emitted the last status that was emitted, so a steady state never emits and a
restart cannot read up as a fresh transition. check list reads this table
for its per-target last status. Rows are pruned with their check on delete.
An introduction migration carries a pinned in-place-upgrade test
(TestMigration0030UpgradesFrom029), per the migration discipline.
4.3 Rule action playbook — no migration
action_kind has no CHECK constraint (0005_reactive.sql:14), so a new action
kind is a pure Go change. The action args ride the existing action_args_json
column, exactly as RunnerName rides args_json on the job row (no migration,
service/playbook.go:39):
// internal/store/rule.go — RuleAction extension
type RuleAction struct {
Argv []string `json:"argv,omitempty"`
Workspace string `json:"workspace,omitempty"`
Timeout Duration `json:"timeout,omitempty"`
Source string `json:"source,omitempty"` // registered source name
Playbook string `json:"playbook,omitempty"` // path under the source checkout
Limit string `json:"limit,omitempty"` // fixed host pattern (validated)
Tags []string `json:"tags,omitempty"`
Check bool `json:"check,omitempty"`
Diff bool `json:"diff,omitempty"`
}
Source/Playbook/Limit/Tags are validated at rule add time (path exists
under the source checkout via ResolveSourcePlaybook, service/source.go:49;
limit/tags through the existing AnsiblePassthrough validation,
service/ansible.go:35), so a rule that would never run is refused at write time
— the same discipline rule add already applies to CEL compile.
4.4 An optional hosts field on events — removed
A set-event (one event carrying every present target, so one run can cover the
whole present subset) was proposed here and later built, then removed by
operator decision. Phase 2 ships per-target events only, and the normalized
Event (event.go:35) carries no host-set field. For the removal and its
rationale see
Decision 0019.
5. Check semantics
5.1 Probe kinds
| Kind | Mechanism | Exit/result truth | Presence semantics |
|---|---|---|---|
tcp |
net.DialTimeout on address:port |
dial succeeds | Default presence probe. :22 answers "the OS is up" without the ICMP privilege story. Works against hosts that block ping. |
ping |
pinned ping/fping subprocess, exit code only (AGENTS.md 3) |
exit 0 | Same as tcp, for hosts that answer ICMP. Rootless constraint: raw ICMP needs net.ipv4.ping_group_range or a setuid helper — see §5.4. |
http |
http.Client with a short timeout |
status in range (2xx/3xx) | Liveness, not location. The URL decides what is reachable; the body is never read. |
5.2 Transition-only events, with hysteresis and cold-start seeding
Engine.SweepChecks (a sibling of SweepDrift, called on the same
--sweep-interval tick, serve.go:646-669) runs each enabled check at its
interval and probes every target. Three rules bound what reaches the pipeline:
-
Hysteresis. A transition is declared only after N consecutive observations of the new result (default 3 intervals), so a target on marginal wifi cannot flap up/down/up — each flap would otherwise withdraw and re-raise a pending approval, churning the events table. The run-side cooldown is not a substitute: it bounds how often a rule fires, not how often the pipeline churns.
-
Cold-start seeding. A target with no
check_staterow is observed and seeded without emitting: its first probe sets the row and emits nothing, so a daemon restart does not read every up target as a fresh down→up transition and fire every presence rule. Only a change from a seeded value is a transition. A target that left and returned while the daemon was down is missed once (the restart observesupagainst a persistedup) — fail-safe, and the accepted price of never spurious-firing after a restart. -
Transition-only emission. An event is emitted only on a declared transition, never on a steady state. down→up:
{source: check, kind: check, name: <check>, host: <target>, status: firing, labels: {netgroup: <groups containing the target address>}}, dedup keycheck:<check>:<target>. up→down: the same shape withstatus: resolvedand the same dedup key, so a resolve finds and withdraws what its firing raised (engine.go:226) — the laptop leaving home cancels the pending approval its arrival raised. A steady state only updatescheck_state.Hysteresis applies to both directions, which means a departure withdraws a pending approval only after N consecutive
downobservations — a~N·intervallag on the resolve path, not on the fire path. That is the right trade: thefiringevent (and its run) is what has a real-time cost, and a resolve that lands a few intervals late only holds an approval a little longer; the approval still fails closed at its TTL if it truly was a flake. The lag should be stated in the docs when the feature ships.
5.3 Location classification
At emission, the sweep classifies each target's address against netgroup
prefixes and stamps labels["netgroup"]. A target address inside home is
home; one that is in no registered group is away. The rule filters on the
group name, never a prefix literal.
5.4 Rootless notes
The container image runs no sudo (AGENTS.md 6). tcp and http need no
privilege at all — which is why tcp is the default presence kind. ping
depends on ping_group_range or a setuid fping on the host; exocomp doctor
probes it and check add --kind ping is refused loudly on a host that cannot
enforce it, the same assert-don't-assume posture sandbox.go Probe holds.
http records status only, never the body (AGENTS.md 4): a health endpoint's
response is not evidence this code should persist.
5.5 Dedup and cooldown granularity — per-rule for webhooks, per-target for checks
The guardrails run in ledger order (guard.go:84) and cooldown is per rule
today (guard.go:182): one rule_fire ledger, one budget. That is wrong for a
multi-target check: if laptop joins at 09:00 (rule fires, cooldown 24h) and
pi joins at 09:10, the second event is suppressed by the rule's cooldown and
pi never runs.
Recommendation: key the cooldown off the event's dedup key when the rule's
source is a sweep source, keeping per-rule for webhooks — and the split is
deliberate, not arbitrary. A webhook's dedup key is fingerprint-based
(event.go:101): distinct bodies get distinct keys. Uniform per-dedup-key
cooldown would hand a webhook that varies its body a fresh budget on every
distinct body — a complete bypass of the throttle. The sweep sources' dedup keys
(check:<check>:<target>) are a bounded, daemon-controlled set, so per-target is
safe there and true to the "each host's join gets its own slot" semantics.
This is a change to guardCooldown's lookup (query rule_fire by
rule_id + dedup_key for sweep-sourced rules, rule_id for webhooks), a column
on rule_fire, and a documented carve-out — not a break in the order or the
ledger discipline.
6. The trigger model: a schedule stays cron; a check is a rule
"Give a job different triggers — time (cron), or one of these checks" is the
right mental model, but the mechanism should not make the schedule polymorphic.
Schedules keep their kinds (shell/backup/tofu-plan/play; the Go
constants at store/schedule.go:15, the value set enforced by the CHECK
constraints in migrations 0015/0020); a check is a pull event source a
rule listens to, the same way drift is:
# cron: the schedule row fires an ansible job at 03:00 every day
exocomp schedule add nightly-update --cron '0 0 3 * * *' --kind play --source ops --playbook playbooks/update.yml --limit laptop
# check: the check sweep emits an event; the rule turns it into the same run
exocomp check add at-home --kind tcp --interval 5m \
--target laptop=192.168.1.50:22 --target nas=192.168.1.60:22
exocomp rule add laptop-update-on-home \
--source check \
--expr 'event.name == "at-home" && event.labels["netgroup"] == "home"' \
--action playbook --repo-source ops --playbook playbooks/update.yml --limit laptop \
--cooldown 24h --daily-cap 1
Both produce the same queue.Spec{Kind: ansible, ...} and both go through the
same queue, guardrails, and approval machinery. A "job with two triggers" is a
schedule row and a rule row — the queue is the convergence point, so no new
scheduler machinery and no trigger column.
7. The governed playbook rule action
7.1 The seam — no import cycle, engine keeps owning the gate
service already imports reactive (service/approval.go:6), so reactive
cannot import service. The seam is an interface in reactive, resolved the
same way Gate already is (engine.go:25):
// internal/reactive — the engine asks, the daemon answers
type PlaybookBuilder interface {
BuildPlaybookSpec(ctx context.Context, ev Event, rule store.Rule) (queue.Spec, error)
}
// Options gains:
Playbooks PlaybookBuilder
The daemon's serve composition supplies an implementation backed by
service.Service (resolve source checkout, validate the playbook path and
limit/tags through AnsiblePassthrough, assemble queue.Spec). buildSpec
(action.go:69) gains the playbook branch; everything after it — enqueue,
gate, approval, RecordRuleFire, the trace — is untouched. The engine still
owns what reaches the queue; the builder only shapes the spec.
7.2 Limit assembly — fixed patterns only, required
--limit on a playbook action is a required, fixed host pattern validated at
rule-add time — an event value never shapes it (consistent with
service/ansible.go). It is required because an un-limited join rule would run
the playbook against every host the checkout's inventory names, a blast radius
the queue guardrails do not bound: default-deny, matching the repo's posture.
In the shipped phase a per-target event means exactly one thing to run:
event.host; the rule pins its own target(s) at add time. Nothing expands an
event value into argv; the set-event expansion was proposed for §7.4 and is now
removed (Decision 0019).
The CLI flag is --repo-source for the playbook's registered source, because
--source is already taken by a rule's event-source filter (cmd/exocomp/rule.go:133);
the store field stays source on RuleAction.
7.3 Approval attribution
A playbook action is not destructive by default (the way tofu-apply is,
action.go:41), but a rule can --require-approval it, and the approval then
carries the run's job id through the existing gateAction path
(engine.go:337). The resolve → withdraw path (§5.2) covers a check that clears
while an approval is pending.
7.4 Removed: the set-event host-set expansion
The headline case is per-target — event.host is the laptop, the run covers the
laptop. A set model (one event per transition carrying the full present set,
expanded into a fixed host pattern) served the narrower "run over exactly the
present subset" need. It was designed here, later built, and then removed by
operator decision: per-target rules cover the operator's cases, and the set
model's extra field plus expansion and validation path were not wanted. See
Decision 0019.
8. The push side: peer stamping, then the presence shim
Checks cover hosts the daemon can probe. Two things remain:
- Peer stamping (cheap, do first) — a network-path signal, not a location
verdict. The ingress already computes the peer address for
--hook-allow(ingress.go:119). Stamping every normalized event withlabels["peer_ip"]andlabels["peer_netgroup"](the first registered group containing the peer, elseaway) costs one flag-free lookup and hardens the existing generic hook. Be precise about what it answers: the peer isr.RemoteAddr(ingress.go:123), so over tailnet it is the CGNAT100.64.0.0/10address — a home netgroup of LAN prefixes marks every tailnet-delivered hook away, including a client physically at home but connected over the overlay. That is fail-safe for a "refuse from outside" rule, and it is the honest framing: the signal is did this arrive on the LAN or over the overlay, not is the sender home. The verdict is unforgeable by the client (it comes from where the connection physically arrived), which is the property that matters. - Presence shim (phase 3, optional). For hosts the daemon cannot probe
(NAT'd clients, IoT behind a guest network), ship a small NetworkManager /
network-onlinescript underpackaging/presence/that HMAC-signs{"host","status":"up|down"}and posts it to apresencewebhook source (token derivedDerive("webhook-hmac", "presence"), the same bearer model as every hook,ingress.go:70), with a client-side debounce so a walk-in does not fire mid-sentence. The peer stamping above is what keeps "outside the house" from firing even when the shim runs over tailnet.
Checks are the primary mechanism; the shim is the escape hatch for what cannot be probed.
9. CLI, API, and web surface
exocomp netgroup add home 192.168.0.0/16,10.0.0.0/8
exocomp netgroup list
exocomp netgroup rm home
exocomp check add at-home --kind tcp --interval 5m --target laptop=192.168.1.50:22
exocomp check add web-health --kind http --interval 1m --target web=https://web.lan/health --expect 200
exocomp check list --output json # per-target last status, last transition
exocomp check enable|disable|rm <name>
exocomp rule add <name> --source check --expr '...' \
--action playbook --repo-source ops --playbook playbooks/update.yml \
[--limit <pattern>] [--tags ...] [--check] [--diff] \
--cooldown 24h --daily-cap 1
rule add learns the playbook action and its flags (cmd/exocomp/rule.go:39);
rule list renders it (rule.go:201). wire.RuleView (wire/schema.go:395)
gains the action fields. The web surface gains netgroup and check pages (rows +
modals, the same chrome the sources/schedules pages use); rules are read-only on
the web — mutation stays CLI, so there is no in-browser rule builder. serve
prints check sweep status beside the drift line (serve.go:655).
10. Security and invariants
Threat-model additions (new T-rows under the reactive section, in the existing sequential catalog):
| id | threat | mitigation |
|---|---|---|
| T30 | A spoofed home-presence claim (webhook or a forged peer) fires a run from outside the house. | Peer stamping reads the physical connection (peer_netgroup), never a claim; a check's location is the address the daemon itself probed (§3.2 preconditions still apply). |
| T31 | A check flood re-arms a rule past its budget (an interval set too short, or a target flapping). | Transition-only emission with N-consecutive hysteresis; per-target dedup and cooldown (§5.5); the daily cap throttles rather than disables. |
| T32 | DHCP re-leases a target's home address to a different device, so a check reports the wrong host as present. | Stated precondition (§3.2.1): targets are pinned by static reservation or fixed lease; the netgroup classifies the address, never the host. |
| T33 | An overlay subnet route (tailscale advertising the home LAN) keeps a checked address reachable from anywhere while an overlay host answers at that address. | An overlay route still requires a live host at the probed address (real presence); the residual is address-uniqueness (T32). LAN-only routing is a documented trust boundary — the sweep has no routing-table authority from inside the container. |
| T34 | A daemon restart reads every up target as a fresh down→up transition and fires every presence rule. | Cold-start seeding writes check_state and emits nothing on first observation; only a change from a seeded value is a transition (§5.2). |
| T35 | An http check persists a health endpoint's body (a secret, a stack trace). | http records status only; the body is never read into the event or a ledger. |
| T36 | An operator-created http check targets the daemon's own loopback listeners or the cloud metadata service, so the check's result depends on a service the operator did not mean to reach. | The http probe dials through a net.Dialer.Control hook that refuses loopback, link-local unicast (covers 169.254.169.254), unspecified, and multicast addresses at the connected IP, scoped so RFC1918/ULA LAN targets are still allowed; a refused address observes down. |
The canonical rows, with the full mitigation text, live in
security/threat-model.md.
Invariant mapping:
- 1 — one container, no new daemon or store dependency.
- 2 — netgroups and checks are plain SQLite rows (migrations 0029/0030).
- 3 — probe results are exit-code / dial-result truth, never parsed output.
- 4 — no probe body, peer verdict, or run-secret value reaches logs or ledgers.
- 5 — a check becomes an event; a rule becomes a job; every path still crosses the one queue.
- 6 —
tcpis the default presence probe;pingis refused on a host that cannot enforce it without sudo. - 7 — no registry, vendor, or external feed is on the path; a check observes the LAN the daemon already sits on.
- 8 — this document, the bundle index, and
log.mdmove with the change.
11. Sequencing
Each phase is a self-contained unit; Phase 1 pays for itself even if 2–3 never ship.
- Phase 1 — netgroups + peer stamping.
0029_netgroup.sql, thenetgroupverbs, and the ingress stamping ofpeer_ip/peer_netgroup. Harden the existing generic-hook path and give rules the operator's own vocabulary. Small, isolated, no sweep machinery. - Phase 2 — checks + the governed playbook action.
0030_check.sql(thecheck_rowandcheck_statetables), the check store/CLI/API/web verbs,SweepCheckswith hysteresis and cold-start seeding, thechecksource, per-target cooldown, theplaybookrule action and itsreactive-side builder seam. Per-target events only. This is the load-bearing phase: it delivers the laptop use case end to end with no client software. - Phase 3 — presence shim (optional). The
presencewebhook source, the shipped shim underpackaging/presence/, debounce, and the HMAC-signing discipline. Only for hosts a probe cannot reach. - Phase 4 — set-event (built then removed). A set event carrying the full present set and its host-pattern expansion was proposed here, later built, and removed by operator decision (Decision 0019). The headline case does not need it (§7.4).
Open questions
- Hysteresis N and granularity (§5.2): resolved. N is fixed at 3 consecutive observations, not operator-tunable, and a missed interval is a skip, not an observation. Granularity is per-target for the check source, settled with the cooldown keying below.
check_stateretention (§4.2): resolved. Shipped as last-status-only — rows are pruned with the check on delete, and the events table already records every transition, so no separate history table.- HTTP check on a tailnet URL: resolved. An http target's URL resolves off-LAN and carries no netgroup label; its liveness is not a location verdict, which a "trigger on bad health" rule does not need.
- Cooldown keying (§5.5): resolved. Migration
0031_rule_fire_dedup.sqladds therule_fire.dedup_keycolumn; sweep-source rules key cooldown off the event's dedup key (check:<check>:<target>), webhooks stay per-rule. - Set-event host-set expansion (§7.4): designed, built, then removed by operator decision in favor of per-target events (Decision 0019).