// Package delivery is maven's channel-routing + dispatch layer. // // Spec contract (from docs/design.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; when away, try ntfy then // telegram as alternatives and stop after the first success. 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 ( "errors" "github.com/kami/maven/internal/loop" "github.com/kami/maven/internal/store" ) // ErrVoiceNoSession — sentinel returned by the voice sink when no live // client session exists at push time. The dispatcher treats this as a // skip-continue: the voice channel is unavailable for this delivery, but // remaining channels (ntfy, telegram) should still fire. The voicesink // maps voice.ErrNoSession to this sentinel so the dispatcher doesn't need // to import the voice package. var ErrVoiceNoSession = errors.New("delivery: voice: no live session") // ErrPermanent is the class of transport failures that waiting cannot repair: // a revoked credential or an endpoint that refuses this sender. Dispatchers // may still try a different reach for the same message, but the failed reach // must not be put on an automatic retry clock until its configuration changes. var ErrPermanent = errors.New("delivery: permanent failure") // 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, then an ordered ntfy→telegram alternative // chain when away. The dispatcher stops after the first successful alternative, // so a reminder still fires once rather than being broadcast on both channels. // There is 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, ChannelTelegram} } return []Channel{ChannelVoice} }