35c6ff5a71
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
117 lines
5.1 KiB
Go
117 lines
5.1 KiB
Go
// 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}
|
||
}
|