Files
Maven/internal/delivery/channel.go
T
claude 35c6ff5a71 Make delivery and integration failures explicit
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.
2026-08-13 02:50:59 +04:00

117 lines
5.1 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 docs/design.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; 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}
}