Files
Maven/internal/delivery/channel.go
T
2026-07-03 00:32:48 +02:00

95 lines
4.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package delivery is maven's channel-routing + dispatch layer.
//
// Spec contract (from maven.md § delivery / channel routing):
//
// - routing = f(severity, presence). presence decides REACHABILITY; severity
// decides INSISTENCE. need both.
//
// | | present | away |
// | sev12 (care) | voice | drop |
// | sev3 (ops soft) | voice | ntfy, once |
// | sev4 (ops hard) | voice + ntfy | telegram, repeat til ack |
//
// - sev ≤ 2 drops on away, sev ≥ 3 holds. "a missed water nudge is noise;
// a missed backup-failure isn't."
// - away channels (ntfy/telegram) leave the box — the one path that crosses
// "never phones home," through your relay. MINIMAL BODY — "disk low on
// homesrv," not detail. don't make notifications a shoulder-surf exfil
// surface.
// - reminders are a SEPARATE class — two delivery paths. reminders bypass
// the restraint gate ("wake me 7" fires in quiet hours; that's the point).
// snooze still applies. voice when present, ntfy when away. fire once.
//
// Architecture mirrors the loop's gather/pure split: the routing table is a
// PURE function of (severity, presence); the Dispatcher holds the impure Sinks
// (one per channel transport) and the recorder seams. ShouldRepeat is pure —
// the daemon's tick loop calls it each cycle for un-acked sev4 telegram sends.
package delivery
import (
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/store"
)
// Channel — one delivery transport. Drop is an explicit no-op (the routing
// table chose to suppress, which is a decision, not a failure — "a missed
// water nudge is noise"). a nil Sink for a wired channel is a daemon config
// gap, not a Channel value.
type Channel string
const (
ChannelVoice Channel = "voice"
ChannelNtfy Channel = "ntfy"
ChannelTelegram Channel = "telegram"
ChannelDrop Channel = "drop"
)
// ChannelsFor — the PURE routing table: f(severity, presence).
//
// presence decides reachability; severity decides insistence. the loop's gate
// already suppressed care nudges (sev ≤ 2) on away — this table is the
// delivery-side authority for ALL severities, including the ops nudges the
// gate lets through. double authority is intentional: the gate decides whether
// a rule EMITS; delivery decides where it LANDS. they agree on care-away
// (both drop) and diverge only where they must (ops survives away here, not
// because the gate let it through, but because delivery insists).
//
// sev4 present → voice + ntfy: the disk-fire alarm gets voice AND a push —
// you're here, but this is loud enough to also surface on the watch.
// sev4 away → telegram, repeat til ack: the one channel that crosses the relay
// and insists until you respond.
func ChannelsFor(sev loop.Severity, presence store.Bucket) []Channel {
if presence == store.Away {
switch {
case sev <= loop.Sev2:
return []Channel{ChannelDrop}
case sev == loop.Sev3:
return []Channel{ChannelNtfy}
case sev >= loop.Sev4:
return []Channel{ChannelTelegram}
}
return []Channel{ChannelDrop} // unknown sev → fail-closed
}
// present
if sev >= loop.Sev4 {
return []Channel{ChannelVoice, ChannelNtfy}
}
return []Channel{ChannelVoice}
}
// ChannelsForReminder — reminders are a SEPARATE class that bypasses the gate.
// "wake me 7" fires in quiet hours; that's the point. presence still routes
// reachability: voice when present, ntfy when away. fires once — no repeat
// (repeat-til-ack is a sev4 ops-hard behavior, not a reminder behavior).
//
// reminders don't carry a Severity — they're user-stated future intent, not
// loop-derived insistence. the routing is presence-only: reachability without
// the insistence axis. a reminder that must be louder (an alarm) is a future
// per-reminder override, not a table entry.
func ChannelsForReminder(presence store.Bucket) []Channel {
if presence == store.Away {
return []Channel{ChannelNtfy}
}
return []Channel{ChannelVoice}
}