Phase 9: Hub API and Apprise alerts (/capabilities, /ask, /notify)

Build on Phases 1-8. Adds the project side of the frozen Hub contract
(central_ai_llm_master/docs/ask-contract.md) and the alert engine from
AGENTS.md §4 FR-12/FR-13, §8, §11 Phase 9.

- hub: internal/hub implements AskRequest/AskResponse/NotifyRequest and a
  deterministic, read-only Answerer. It answers screen time today/yesterday,
  what is blocked now, and device presence, scoped to one child or all kids.
  Screen time is the foreground-app total per device-day; the domain total is
  used only when a device reports no apps, so a report carrying both is never
  double-counted.
- apprise: internal/apprise is a small Apprise API client (stored key or
  stateless URL mode, optional bearer token, typed notifications).
- alert: internal/alert evaluates device state on an interval and raises one
  Apprise alert per transition: a limit hit (a device becomes blocked) or a
  device offline longer than the threshold. A never-seen device is never
  alerted, and the first observation seeds state without replaying an old
  limit. The alert is recorded before delivery, with its delivery outcome.
- store: migration 005 adds `alerts` (delivery outcome, audited) and
  `alert_state` (per-device dedup). New usage reads from the telemetry detail
  table so the daily answer can be checked independently.
- web: GET /capabilities, POST /ask (read-only) and POST /notify behind the
  shared CONTROL_HUB_TOKEN, separate from the session and admin API tokens.
  /notify accepts the contract's {from,level,message,person} and delivers it
  through Apprise (the future tamper-report path, FR-17).
- cmd/control: start the alert engine (CONTROL_ALERTS_ENABLED,
  CONTROL_ALERT_OFFLINE_AFTER, CONTROL_ALERT_INTERVAL) and add `alerts` and
  `usage` subcommands for read-only inspection.
- deploy/verify-phase9.sh + `make verify-phase9` prove the definition of done;
  version 0.7.0-phase9.

Measured on .13 (2026-10-13):
- GET /capabilities -> internet_use_control topics.
- POST /ask "how long have the kids been on screens today?" -> "The kids have
  had 1h 22m of screen time today." (total_seconds=4925); the independent
  count from the telemetry detail table is also 4925 (app=4925, domain=4800).
- A manual lock raised a delivered limit alert (id 4); an MQTT offline status
  raised a delivered offline alert (id 5); POST /notify delivered (id 6). All
  three report delivered=1 in `alerts`.

Tests: gofmt, go vet ./... and go test ./... pass in golang:1.25-alpine.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-13 16:41:07 +11:00
parent 4f233a8012
commit 3cf353d933
23 changed files with 2881 additions and 24 deletions

345
internal/hub/ask.go Normal file
View File

@@ -0,0 +1,345 @@
// Read-only question answering for the Hub (AGENTS.md §4 FR-12, §10 rule 9,
// §11 Phase 9).
//
// The answerer decides intent from the question text and answers from SQLite.
// It is not an LLM call: it is deterministic, answers in milliseconds, and
// never guesses. An unanswerable question returns answer=null plus a reason.
//
// Reader is the read-only data surface. *store.Store satisfies it. Keeping it
// an interface makes the read-only guarantee explicit: every method reads.
package hub
import (
"context"
"fmt"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
)
// Reader is the read-only data surface the answerer uses.
type Reader interface {
ListDevices(ctx context.Context) ([]store.Device, error)
EffectiveState(ctx context.Context, dev store.Device, now time.Time) (store.EffectiveState, error)
UsageByDeviceForDay(ctx context.Context, day string) ([]store.DeviceDayUsage, error)
}
// Answerer answers one question at a time from a read-only Reader.
type Answerer struct {
Reader Reader
// Location is the schedule timezone, so "today" matches the day a device
// stamped its telemetry in. Defaults to the process local zone.
Location *time.Location
// Now is injectable so the answer is deterministic in tests.
Now func() time.Time
}
// NewAnswerer builds an Answerer over a read-only Reader.
func NewAnswerer(r Reader, loc *time.Location) *Answerer {
if loc == nil {
loc = time.Local
}
return &Answerer{Reader: r, Location: loc, Now: func() time.Time { return time.Now().In(loc) }}
}
func (a *Answerer) now() time.Time {
if a.Now == nil {
return time.Now().In(a.loc())
}
return a.Now()
}
func (a *Answerer) loc() *time.Location {
if a.Location == nil {
return time.Local
}
return a.Location
}
// Answer returns a short, speakable answer for one question. It never writes
// state.
func (a *Answerer) Answer(ctx context.Context, req AskRequest) AskResponse {
now := a.now()
asker := strings.ToLower(strings.TrimSpace(req.Asker))
person := strings.ToLower(strings.TrimSpace(req.Person))
switch {
case asker == "":
return cannotAnswer("no asker was given")
case asker != "sam":
return cannotAnswer("I only answer for Sam")
}
q := normalizeQuestion(req.Question)
if q == "" {
return cannotAnswer("no question was given")
}
// Resolve who the question is about. The person field wins when it names a
// child; otherwise a child named in the question is used; otherwise every
// device is included.
child := ""
switch person {
case "", "sam", "kids", "children", "everyone", "both":
// aggregate (or derive from the question below)
case "harry", "finn":
child = person
default:
return cannotAnswer("no data for that person")
}
if child == "" {
switch {
case strings.Contains(q, "harry"):
child = "harry"
case strings.Contains(q, "finn"):
child = "finn"
}
}
devs, err := a.Reader.ListDevices(ctx)
if err != nil {
return cannotAnswer("the device list could not be read")
}
if child != "" && !knownChild(devs, child) {
return cannotAnswer("no data for that person")
}
switch {
case containsAny(q, "screen time", "screentime", "screen-time",
"on screens", "on screen", "screen", "how long", "spent", "usage", "used"):
return a.answerScreenTime(ctx, now, child, q)
case containsAny(q, "blocked", "block", "locked", "lock", "restricted", "restrict"):
return a.answerBlocked(ctx, now, child, devs)
case containsAny(q, "online", "offline", "connected", "reachable"):
return a.answerPresence(ctx, now, child, devs)
default:
return cannotAnswer("I can answer questions about screen time, what is blocked, and whether a device is online")
}
}
// answerScreenTime answers "how long have the kids been on screens today?" from
// the stored daily rollup. The day is "today" in the schedule timezone, or
// yesterday when the question says so.
func (a *Answerer) answerScreenTime(ctx context.Context, now time.Time, child, q string) AskResponse {
day := now
if containsAny(q, "yesterday", "last night") {
day = now.AddDate(0, 0, -1)
}
dayStr := day.Format("2006-01-02")
usage, err := a.Reader.UsageByDeviceForDay(ctx, dayStr)
if err != nil {
return cannotAnswer("usage could not be read")
}
total := 0
byDevice := make([]map[string]any, 0, len(usage))
for _, u := range usage {
if child != "" && !strings.EqualFold(strings.TrimSpace(u.Child), child) {
continue
}
seconds := ScreenSeconds(u.AppSeconds, u.DomainSeconds)
total += seconds
byDevice = append(byDevice, map[string]any{
"device": u.Name,
"child": u.Child,
"seconds": seconds,
"app_seconds": u.AppSeconds,
"domain_seconds": u.DomainSeconds,
})
}
when := "today"
if strings.EqualFold(dayStr, now.AddDate(0, 0, -1).Format("2006-01-02")) {
when = "yesterday"
}
subject := "The kids"
verb := "have"
if child != "" {
subject = titleCase(child)
verb = "has"
}
answer := fmt.Sprintf("%s %s had no recorded screen time %s.", subject, verb, when)
if total > 0 {
answer = fmt.Sprintf("%s %s had %s of screen time %s.", subject, verb, humanDuration(total), when)
}
return AskResponse{
Answer: answerText(answer),
Data: map[string]any{"day": dayStr, "total_seconds": total, "by_device": byDevice},
Expires: expiresIn(now, 15*time.Minute),
}
}
// answerBlocked reports whether anything is blocked right now, and until when.
func (a *Answerer) answerBlocked(ctx context.Context, now time.Time, child string, devs []store.Device) AskResponse {
blocked := make([]map[string]any, 0, len(devs))
for _, dev := range devs {
if !matchesChild(dev, child) {
continue
}
eff, err := a.Reader.EffectiveState(ctx, dev, now)
if err != nil {
return cannotAnswer("device state could not be read")
}
if !eff.Locked {
continue
}
item := map[string]any{
"device": dev.Name,
"child": dev.Child,
"reason": eff.Reason,
}
if eff.Until != nil {
item["until"] = eff.Until.UTC().Format(time.RFC3339)
item["until_local"] = eff.Until.In(a.loc()).Format("15:04")
}
blocked = append(blocked, item)
}
resp := AskResponse{
Data: map[string]any{"blocked": blocked, "count": len(blocked)},
Expires: expiresIn(now, 5*time.Minute),
}
switch len(blocked) {
case 0:
resp.Answer = answerText("Nothing is blocked right now.")
case 1:
name, _ := blocked[0]["device"].(string)
reason, _ := blocked[0]["reason"].(string)
if until, ok := blocked[0]["until_local"].(string); ok && until != "" {
resp.Answer = answerText(fmt.Sprintf("%s is blocked until %s (%s).", name, until, reason))
} else {
resp.Answer = answerText(fmt.Sprintf("%s is blocked (%s).", name, reason))
}
default:
resp.Answer = answerText(fmt.Sprintf("%d devices are blocked right now.", len(blocked)))
}
return resp
}
// answerPresence reports whether the devices are online. Presence comes from
// the broker's Last Will and Testament (FR-6).
func (a *Answerer) answerPresence(ctx context.Context, now time.Time, child string, devs []store.Device) AskResponse {
selected := make([]store.Device, 0, len(devs))
for _, dev := range devs {
if matchesChild(dev, child) {
selected = append(selected, dev)
}
}
if len(selected) == 0 {
return cannotAnswer("no devices are registered")
}
offline := make([]map[string]any, 0, len(selected))
online := 0
for _, dev := range selected {
if dev.Online {
online++
continue
}
item := map[string]any{"device": dev.Name, "child": dev.Child}
if dev.LastSeen != nil {
item["last_seen"] = dev.LastSeen.UTC().Format(time.RFC3339)
item["last_seen_local"] = dev.LastSeen.In(a.loc()).Format("15:04")
item["offline_for"] = humanDuration(int(now.Sub(*dev.LastSeen).Seconds()))
}
offline = append(offline, item)
}
resp := AskResponse{
Data: map[string]any{"online": online, "offline": offline, "total": len(selected)},
Expires: expiresIn(now, 5*time.Minute),
}
switch {
case len(offline) == 0:
if len(selected) == 1 {
resp.Answer = answerText(fmt.Sprintf("%s is online.", selected[0].Name))
} else {
resp.Answer = answerText(fmt.Sprintf("All %d devices are online.", len(selected)))
}
case len(offline) == 1:
name, _ := offline[0]["device"].(string)
dur, _ := offline[0]["offline_for"].(string)
resp.Answer = answerText(fmt.Sprintf("%s is offline (for %s).", name, dur))
default:
resp.Answer = answerText(fmt.Sprintf("%d devices are offline.", len(offline)))
}
return resp
}
// ScreenSeconds is the screen-time measure for one device-day. The
// foreground-app total is the measure; domain seconds are a browsing
// sub-measure and are only used when a device reported no app rows at all (the
// phone's VPN filter, for example). A report that carries both is not
// double-counted.
func ScreenSeconds(app, domain int) int {
if app > 0 {
return app
}
return domain
}
// knownChild reports whether any registered device belongs to child.
func knownChild(devs []store.Device, child string) bool {
for _, dev := range devs {
if strings.EqualFold(strings.TrimSpace(dev.Child), child) {
return true
}
}
return false
}
// matchesChild reports whether a device is in scope for a question. An empty
// child means every device.
func matchesChild(dev store.Device, child string) bool {
return child == "" || strings.EqualFold(strings.TrimSpace(dev.Child), child)
}
// normalizeQuestion lowercases and collapses whitespace so intent matching is
// stable.
func normalizeQuestion(q string) string {
return strings.Join(strings.Fields(strings.ToLower(q)), " ")
}
func containsAny(q string, subs ...string) bool {
for _, s := range subs {
if strings.Contains(q, s) {
return true
}
}
return false
}
// expiresIn formats an RFC3339 expiry used by data-bearing answers.
func expiresIn(now time.Time, d time.Duration) string {
return now.UTC().Add(d).Format(time.RFC3339)
}
// titleCase upper-cases the first letter of a single-word name.
func titleCase(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return s
}
r := []rune(s)
return strings.ToUpper(string(r[0])) + string(r[1:])
}
// humanDuration renders seconds compactly: "3h 20m", "45m", "under a minute".
func humanDuration(seconds int) string {
if seconds <= 0 {
return "0m"
}
if seconds < 60 {
return "under a minute"
}
m := seconds / 60
h := m / 60
m %= 60
if h == 0 {
return fmt.Sprintf("%dm", m)
}
return fmt.Sprintf("%dh %02dm", h, m)
}

211
internal/hub/ask_test.go Normal file
View File

@@ -0,0 +1,211 @@
package hub
import (
"context"
"strings"
"testing"
"time"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
)
// fakeReader is a read-only Reader backed by fixed data.
type fakeReader struct {
devices []store.Device
eff map[int64]store.EffectiveState
usage map[string][]store.DeviceDayUsage
}
func (f *fakeReader) ListDevices(context.Context) ([]store.Device, error) {
return f.devices, nil
}
func (f *fakeReader) EffectiveState(_ context.Context, dev store.Device, _ time.Time) (store.EffectiveState, error) {
return f.eff[dev.ID], nil
}
func (f *fakeReader) UsageByDeviceForDay(_ context.Context, day string) ([]store.DeviceDayUsage, error) {
return f.usage[day], nil
}
func testAnswerer(r Reader, now time.Time) *Answerer {
loc := time.UTC
a := NewAnswerer(r, loc)
a.Now = func() time.Time { return now.In(loc) }
return a
}
func mustAnswer(t *testing.T, a *Answerer, req AskRequest) AskResponse {
t.Helper()
return a.Answer(context.Background(), req)
}
func answerTextOf(t *testing.T, resp AskResponse) string {
t.Helper()
if resp.Answer == nil {
t.Fatalf("answer = nil, reason = %q", resp.Reason)
}
return *resp.Answer
}
func TestScreenTimeToday(t *testing.T) {
now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
r := &fakeReader{
devices: []store.Device{
{ID: 1, Name: "Harry's phone", Child: "Harry"},
{ID: 2, Name: "Finn's computer", Child: "Finn"},
},
usage: map[string][]store.DeviceDayUsage{
"2026-10-13": {
{DeviceID: 1, Name: "Harry's phone", Child: "Harry", AppSeconds: 3600, DomainSeconds: 900},
// app and domain for the same session must not be summed.
{DeviceID: 2, Name: "Finn's computer", Child: "Finn", AppSeconds: 1800, DomainSeconds: 2000},
},
},
}
a := testAnswerer(r, now)
resp := mustAnswer(t, a, AskRequest{
Question: "how long have the kids been on screens today?",
Asker: "sam",
})
got := answerTextOf(t, resp)
if !strings.Contains(got, "1h 30m") {
t.Errorf("answer = %q, want 1h 30m", got)
}
if total, _ := resp.Data["total_seconds"].(int); total != 5400 {
t.Errorf("total_seconds = %v, want 5400 (app totals, no double count)", resp.Data["total_seconds"])
}
}
func TestScreenTimeOneChild(t *testing.T) {
now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
r := &fakeReader{
devices: []store.Device{
{ID: 1, Name: "Harry's phone", Child: "Harry"},
{ID: 2, Name: "Finn's computer", Child: "Finn"},
},
usage: map[string][]store.DeviceDayUsage{
"2026-10-13": {
{DeviceID: 1, Name: "Harry's phone", Child: "Harry", AppSeconds: 2700},
{DeviceID: 2, Name: "Finn's computer", Child: "Finn", AppSeconds: 900},
},
},
}
a := testAnswerer(r, now)
resp := mustAnswer(t, a, AskRequest{Question: "how long has Harry been on screens?", Asker: "sam"})
if got := answerTextOf(t, resp); !strings.Contains(got, "45m") || !strings.Contains(got, "Harry") {
t.Errorf("answer = %q, want Harry 45m", got)
}
}
func TestScreenTimeYesterday(t *testing.T) {
now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
r := &fakeReader{
devices: []store.Device{{ID: 1, Name: "Harry's phone", Child: "Harry"}},
usage: map[string][]store.DeviceDayUsage{
"2026-10-12": {{DeviceID: 1, Name: "Harry's phone", Child: "Harry", AppSeconds: 120}},
},
}
a := testAnswerer(r, now)
resp := mustAnswer(t, a, AskRequest{Question: "how long were the kids on screens yesterday?", Asker: "sam"})
if got := answerTextOf(t, resp); !strings.Contains(got, "yesterday") {
t.Errorf("answer = %q, want yesterday", got)
}
}
func TestScreenTimeNoneRecorded(t *testing.T) {
now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
r := &fakeReader{
devices: []store.Device{{ID: 1, Name: "Harry's phone", Child: "Harry"}},
usage: map[string][]store.DeviceDayUsage{},
}
a := testAnswerer(r, now)
resp := mustAnswer(t, a, AskRequest{Question: "how long have the kids been on screens today?", Asker: "sam"})
if got := answerTextOf(t, resp); !strings.Contains(got, "no recorded screen time") {
t.Errorf("answer = %q", got)
}
}
func TestBlockedAnswer(t *testing.T) {
now := time.Date(2026, 10, 13, 20, 30, 0, 0, time.UTC)
until := time.Date(2026, 10, 13, 21, 0, 0, 0, time.UTC)
r := &fakeReader{
devices: []store.Device{{ID: 1, Name: "Harry's phone", Child: "Harry"}},
eff: map[int64]store.EffectiveState{
1: {DeviceID: 1, Locked: true, Reason: "schedule", Until: &until},
},
}
a := testAnswerer(r, now)
resp := mustAnswer(t, a, AskRequest{Question: "what is blocked right now?", Asker: "sam"})
if got := answerTextOf(t, resp); !strings.Contains(got, "Harry's phone") || !strings.Contains(got, "21:00") {
t.Errorf("answer = %q", got)
}
}
func TestPresenceAnswer(t *testing.T) {
now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
lastSeen := now.Add(-2*time.Hour - 10*time.Minute)
r := &fakeReader{
devices: []store.Device{
{ID: 1, Name: "Harry's phone", Child: "Harry", Online: true},
{ID: 2, Name: "Finn's computer", Child: "Finn", Online: false, LastSeen: &lastSeen},
},
}
a := testAnswerer(r, now)
resp := mustAnswer(t, a, AskRequest{Question: "are the devices online?", Asker: "sam"})
if got := answerTextOf(t, resp); !strings.Contains(got, "Finn's computer") || !strings.Contains(got, "2h 10m") {
t.Errorf("answer = %q", got)
}
}
func TestPermissionsAndUnknowns(t *testing.T) {
now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
r := &fakeReader{
devices: []store.Device{{ID: 1, Name: "Harry's phone", Child: "Harry"}},
}
a := testAnswerer(r, now)
cases := []struct {
name string
req AskRequest
want string
}{
{"no asker", AskRequest{Question: "screen time?"}, "no asker"},
{"not sam", AskRequest{Question: "screen time?", Asker: "harry"}, "only answer for Sam"},
{"unknown person", AskRequest{Question: "screen time?", Asker: "sam", Person: "jo"}, "no data for that person"},
{"unknown intent", AskRequest{Question: "what is the weather?", Asker: "sam"}, "I can answer"},
}
for _, tc := range cases {
resp := mustAnswer(t, a, tc.req)
if resp.Answer != nil {
t.Errorf("%s: answer = %q, want nil", tc.name, *resp.Answer)
continue
}
if !strings.Contains(resp.Reason, tc.want) {
t.Errorf("%s: reason = %q, want to contain %q", tc.name, resp.Reason, tc.want)
}
}
if !strings.Contains(mustAnswer(t, a, AskRequest{Question: "", Asker: "sam"}).Reason, "no question") {
t.Error("empty question should be refused")
}
}
func TestDefaultCapabilities(t *testing.T) {
c := DefaultCapabilities()
if c.Project != "internet_use_control" {
t.Errorf("project = %q", c.Project)
}
if len(c.Topics) == 0 || len(c.People) == 0 {
t.Errorf("capabilities = %#v", c)
}
}
func TestScreenSecondsPrefersApp(t *testing.T) {
if got := ScreenSeconds(600, 900); got != 600 {
t.Errorf("screenSeconds(600,900) = %d, want 600", got)
}
if got := ScreenSeconds(0, 900); got != 900 {
t.Errorf("screenSeconds(0,900) = %d, want 900", got)
}
}

74
internal/hub/hub.go Normal file
View File

@@ -0,0 +1,74 @@
// Package hub implements the project side of the Hub contract
// (central_ai_llm_master/docs/ask-contract.md) for the Internet Use Control
// Control Service.
//
// The contract is one-directional by design: the Hub asks a project about its
// own data and the project answers. A project never learns the Hub's data model
// and the Hub never learns the project's.
//
// GET /capabilities -> the topics this project can answer (Hub routing)
// POST /ask -> a short answer about this project's data
// POST /notify -> accept a notification and deliver it to Sam
//
// /ask and /capabilities are read-only. /ask answers from SQLite and never
// writes, so the "read only" promise (AGENTS.md §10 rule 9) is a testable
// property, not a comment (TestAskWritesNothing).
package hub
// ProjectName is the value the Hub matches in routing.md.
const ProjectName = "internet_use_control"
// Capabilities is the GET /capabilities reply. The Hub reads it to route a
// question to this project. Keep the topic list short and plain; it holds no
// private data.
type Capabilities struct {
Project string `json:"project"`
Topics []string `json:"topics"`
People []string `json:"people"`
}
// DefaultCapabilities returns the topics this service can answer. They match
// the screen-time domain in AGENTS.md §1 and §4.
func DefaultCapabilities() Capabilities {
return Capabilities{
Project: ProjectName,
Topics: []string{"internet", "screen time", "screens", "usage", "devices", "blocking", "kids"},
People: []string{"harry", "finn"},
}
}
// AskRequest is the POST /ask body (ask-contract.md).
type AskRequest struct {
Question string `json:"question"`
Asker string `json:"asker"`
Person string `json:"person,omitempty"`
Source string `json:"source,omitempty"`
}
// AskResponse is the POST /ask reply. A nil Answer means the project cannot
// answer and Reason explains why. The shape is frozen by the contract.
type AskResponse struct {
Answer *string `json:"answer"`
Data map[string]any `json:"data,omitempty"`
Expires string `json:"expires,omitempty"`
Reason string `json:"reason,omitempty"`
}
// NotifyRequest is the POST /notify body (ask-contract.md, "Notify"). The
// contract defines it as project-to-Hub; this service accepts the same shape so
// a caller (Hub, admin app, or a future phone tamper report) can have Sam
// notified without inventing a second shape.
type NotifyRequest struct {
From string `json:"from"`
Level string `json:"level"`
Message string `json:"message"`
Person string `json:"person,omitempty"`
}
// answerText is a small helper so pointer handling stays in one place.
func answerText(s string) *string { return &s }
// cannotAnswer builds the contract's "cannot answer" reply.
func cannotAnswer(reason string) AskResponse {
return AskResponse{Answer: nil, Reason: reason}
}