95 lines
4.0 KiB
Go
95 lines
4.0 KiB
Go
// 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 |
|
||
// | sev1–2 (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}
|
||
}
|