Read the work calendar as a notification signal, not a mailbox (#126)

Maven does not get a work credential. A corp mail or calendar session living on
the homelab ties the box's blast radius to the employer's data, which is the
thing this task exists to refuse. What she reads instead is the signal: an
Android notification-listener on the phone relays meeting notifications over
wg/LAN to POST /api/ambient, and the ones that clearly describe a meeting become
calendar events at source=ambient:notif, confidence 0.6.

The provenance is the point. A notification is evidence about a meeting, not a
reading of a calendar, so it is never indistinguishable from one: it is stored
below full confidence, store.CalendarEvents keeps the source and confidence on
every row it returns, and the query path hedges — "похоже, Планёрка @ 14:00" for
a relayed event, plain text for a CalDAV read.

The parse is deliberately conservative (internal/calendar/ambient.go). It needs
a real clock reading and a summary that is not just that clock reading;
otherwise it stores nothing at all. A bare hour is not a time, an unread count
is not a time, and "срок 2026.08.15" does not offer 08:15 as a meeting — loose
digits in a notification are far more often a badge or a date, and a mailbox of
noise rendered as invented meetings is worse than a gap.

The ingest is off unless configured: no -ambient-token, no route registered. The
token is a shared secret compared in constant time, because the poster is a
background Android service and WebAuthn has no answer for one. The endpoint is
write-only, accepts one shape of write, and cannot read anything back out.
Reposts of the same notification dedupe against the latest fact for that
key+source, the same append-only discipline cmd/mavcaldav follows.

Not shipped: the Android relay app itself, which is a separate artifact and a
device, not Go in this repo.
This commit is contained in:
kami
2026-08-01 02:04:06 +04:00
parent 3af290152c
commit 49f089d8a6
10 changed files with 871 additions and 12 deletions
+25 -5
View File
@@ -6,7 +6,10 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/kami/maven/internal/calendar"
)
// WriteFact appends a fact row. confidence must be 1.0 for taps and (0,1) for
@@ -79,17 +82,29 @@ func (s *Store) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
return out, rows.Err()
}
// CalendarEvents returns caldav facts whose key date falls within [from, to).
// CalendarEvents returns calendar facts whose key date falls within [from, to).
// Calendar event keys have the format calendar_event_YYYYMMDD_<summary>.
//
// Every calendar source is included, not just the personal CalDAV poll: the work
// calendar arrives as ambient:notif notifications (Vikunja #126) and belongs in
// the same answer. The source stays on each Fact, along with its confidence, so
// the caller can hedge a reading it did not get from a calendar server —
// filtering by source here would have thrown that judgement away.
func (s *Store) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
prefixFrom := fmt.Sprintf("calendar_event_%s", from.Format("20060102"))
prefixTo := fmt.Sprintf("calendar_event_%s", to.Format("20060102"))
prefixFrom := calendar.KeyPrefixForDay(from)
prefixTo := calendar.KeyPrefixForDay(to)
sources := calendar.Sources()
args := make([]any, 0, len(sources)+2)
for _, src := range sources {
args = append(args, src)
}
args = append(args, prefixFrom, prefixTo)
rows, err := s.db.QueryContext(ctx, `
SELECT id, ts, kind, key, value, source, confidence, voids_id
FROM facts
WHERE source = 'poll:caldav'
WHERE source IN (`+placeholders(len(sources))+`)
AND key >= ? AND key < ?
ORDER BY key`, prefixFrom, prefixTo)
ORDER BY key`, args...)
if err != nil {
return nil, fmt.Errorf("calendar events: %w", err)
}
@@ -254,3 +269,8 @@ func scanFact(r rowScanner) (Fact, error) {
f.VoidsID = voids
return f, nil
}
// placeholders renders n comma-separated SQL bind markers.
func placeholders(n int) string {
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
}
+46
View File
@@ -8,6 +8,8 @@ import (
"path/filepath"
"testing"
"time"
"github.com/kami/maven/internal/calendar"
)
func newTestStore(t *testing.T) *Store {
@@ -378,3 +380,47 @@ func TestCalendarEvents(t *testing.T) {
t.Fatalf("expected 0 events on July 8, got %d", len(events))
}
}
// The work calendar arrives as relayed phone notifications, not a CalDAV read
// (Vikunja #126). Those events belong in the same day's answer, and their
// provenance has to survive the query so the caller can hedge them.
func TestCalendarEventsIncludesAmbientSource(t *testing.T) {
store := newTestStore(t)
defer store.Close()
ctx := context.Background()
day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC)
store.WriteFact(ctx, day.Add(10*time.Hour), KindEnv, "calendar_event_20260803_Aaa-personal",
`"Aaa personal @ 10:00-10:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{})
store.WriteFact(ctx, day.Add(14*time.Hour), KindEnv, "calendar_event_20260803_Bbb-work",
`"Bbb work @ 14:00-14:30"`, calendar.SourceAmbient, calendar.AmbientConfidence, sql.NullInt64{})
// A fact that merely looks like one must still be excluded by source.
store.WriteFact(ctx, day.Add(16*time.Hour), KindEnv, "calendar_event_20260803_Ccc-forged",
`"Ccc forged @ 16:00-16:30"`, "tap:voice", 1.0, sql.NullInt64{})
events, err := store.CalendarEvents(ctx, day, day.AddDate(0, 0, 1))
if err != nil {
t.Fatalf("CalendarEvents: %v", err)
}
if len(events) != 2 {
t.Fatalf("got %d events, want the personal and the ambient one: %+v", len(events), events)
}
bySource := map[string]Fact{}
for _, e := range events {
bySource[e.Source] = e
}
if _, ok := bySource[calendar.SourcePersonal]; !ok {
t.Error("the personal CalDAV event is missing")
}
amb, ok := bySource[calendar.SourceAmbient]
if !ok {
t.Fatal("the ambient work event is missing")
}
if amb.Confidence >= 1.0 {
t.Errorf("ambient confidence = %v, must stay below a calendar read's", amb.Confidence)
}
if _, ok := bySource["tap:voice"]; ok {
t.Error("a non-calendar source must not be read as a calendar event")
}
}