Files
Maven/internal/store/facts.go
T
kami 49f089d8a6 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.
2026-08-01 02:04:06 +04:00

277 lines
9.2 KiB
Go

package store
import (
"context"
"database/sql"
"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
// inferences; the caller is responsible for that provenance discipline.
//
// If voidsID is Valid, this row voids (cancels) the referenced fact. The
// caller must have verified voidsID points at an existing fact — we check it
// here too and refuse to write a dangling voids pointer (the audit trail must
// stay coherent).
func (s *Store) WriteFact(ctx context.Context, ts time.Time, kind FactKind, key, value, source string, confidence float64, voidsID sql.NullInt64) (int64, error) {
if confidence <= 0.0 || confidence > 1.0 {
return 0, fmt.Errorf("%w: %f", ErrConfidence, confidence)
}
if voidsID.Valid {
var ok int64
err := s.db.QueryRowContext(ctx, "SELECT 1 FROM facts WHERE id = ?", voidsID.Int64).Scan(&ok)
if errors.Is(err, sql.ErrNoRows) {
return 0, fmt.Errorf("%w: id=%d", ErrVoidsMissing, voidsID.Int64)
}
if err != nil {
return 0, fmt.Errorf("voids lookup: %w", err)
}
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO facts (ts, kind, key, value, source, confidence, voids_id) VALUES (?,?,?,?,?,?,?)`,
ts.UnixMilli(), string(kind), key, value, source, confidence, voidsID)
if err != nil {
return 0, fmt.Errorf("write fact: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("last insert id: %w", err)
}
return id, nil
}
// LatestFact returns the latest non-voided fact for key, or ErrNoFact.
// "Non-voided" = no later row has voids_id pointing at it. We resolve this by
// taking the newest row whose id is not referenced by any voids_id.
func (s *Store) LatestFact(ctx context.Context, key string) (Fact, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, ts, kind, key, value, source, confidence, voids_id
FROM facts
WHERE key = ?
AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
ORDER BY ts DESC, id DESC
LIMIT 1`, key)
return scanFact(row)
}
// RecentFacts — the newest n facts across all keys, for the monitoring dash.
// Includes voided rows (the audit trail is the point: you want to SEE a
// correction, not have it hidden). Newest first.
func (s *Store) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, ts, kind, key, value, source, confidence, voids_id
FROM facts ORDER BY ts DESC, id DESC LIMIT ?`, n)
if err != nil {
return nil, fmt.Errorf("recent facts: %w", err)
}
defer rows.Close()
var out []Fact
for rows.Next() {
f, err := scanFact(rows)
if err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
// 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 := 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 IN (`+placeholders(len(sources))+`)
AND key >= ? AND key < ?
ORDER BY key`, args...)
if err != nil {
return nil, fmt.Errorf("calendar events: %w", err)
}
defer rows.Close()
var out []Fact
for rows.Next() {
f, err := scanFact(rows)
if err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
// LatestFactBySource — provenance-scoped. A rule on `service_down` trusts only
// source=poll:healthcheck; a compromised poller can't forge a trigger. Use this
// from rules, not LatestFact, whenever the rule's source contract matters.
func (s *Store) LatestFactBySource(ctx context.Context, key, source string) (Fact, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, ts, kind, key, value, source, confidence, voids_id
FROM facts
WHERE key = ? AND source = ?
AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
ORDER BY ts DESC, id DESC
LIMIT 1`, key, source)
return scanFact(row)
}
// Since returns how long ago the latest non-voided fact for key landed, or
// (0, ErrNoFact). Implements the `since(key)==null → don't fire` guard from
// the spec — silence on no-data is "shuts up when uncertain".
func (s *Store) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) {
f, err := s.LatestFact(ctx, key)
if err != nil {
return 0, err
}
if now.Before(f.Ts) {
return 0, nil
}
return now.Sub(f.Ts), nil
}
// SetValue is a convenience for writing a structured (json) value at confidence 1.0
// from a tap. Self-taps (water, meal, shower) land here. Caller supplies source
// like "tap:water"; we serialize the value.
func (s *Store) SetValue(ctx context.Context, kind FactKind, key, source string, value any, ts time.Time) (int64, error) {
raw, err := json.Marshal(value)
if err != nil {
return 0, fmt.Errorf("marshal value: %w", err)
}
return s.WriteFact(ctx, ts, kind, key, string(raw), source, 1.0, sql.NullInt64{})
}
// CorrectValue voids the latest non-voided fact for key and writes a replacement
// in one transaction. Use this for "you corrected a bad fact" feedback — keeps
// the audit trail, supersedes the wrong value.
func (s *Store) CorrectValue(ctx context.Context, key, source string, value any, ts time.Time) (newID int64, err error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
var oldID int64
err = tx.QueryRowContext(ctx, `
SELECT id FROM facts
WHERE key = ?
AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
ORDER BY ts DESC, id DESC LIMIT 1`, key).Scan(&oldID)
if errors.Is(err, sql.ErrNoRows) {
// nothing to correct; write as a plain new fact instead — no voiding needed.
} else if err != nil {
return 0, fmt.Errorf("correct: find old: %w", err)
}
raw, merr := json.Marshal(value)
if merr != nil {
return 0, fmt.Errorf("marshal value: %w", merr)
}
var voids sql.NullInt64
if oldID != 0 {
voids = sql.NullInt64{Int64: oldID, Valid: true}
}
res, err := tx.ExecContext(ctx,
`INSERT INTO facts (ts, kind, key, value, source, confidence, voids_id) VALUES (?,?,?,?,?,?,?)`,
ts.UnixMilli(), string(KindSelf), key, string(raw), source, 1.0, voids)
if err != nil {
return 0, fmt.Errorf("write corrected: %w", err)
}
if err := tx.Commit(); err != nil {
return 0, err
}
newID, err = res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("last insert id: %w", err)
}
return newID, nil
}
// VoidLatestFact voids the latest non-voided fact for key. It writes a new
// fact with voids_id pointing at the old one, keeping the audit trail intact.
// Returns the voided fact's ID and the new void-marker fact's ID.
// If no fact exists for the key, returns ErrNoFact.
func (s *Store) VoidLatestFact(ctx context.Context, key, source string, ts time.Time) (oldID, newID int64, err error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, 0, err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
err = tx.QueryRowContext(ctx, `
SELECT id FROM facts
WHERE key = ?
AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
ORDER BY ts DESC, id DESC LIMIT 1`, key).Scan(&oldID)
if errors.Is(err, sql.ErrNoRows) {
return 0, 0, ErrNoFact
}
if err != nil {
return 0, 0, fmt.Errorf("void: find latest: %w", err)
}
res, err := tx.ExecContext(ctx,
`INSERT INTO facts (ts, kind, key, value, source, confidence, voids_id) VALUES (?,?,?,?,?,?,?)`,
ts.UnixMilli(), string(KindSelf), key, `"voided"`, source, 1.0,
sql.NullInt64{Int64: oldID, Valid: true})
if err != nil {
return 0, 0, fmt.Errorf("void: write void-marker: %w", err)
}
if err := tx.Commit(); err != nil {
return 0, 0, err
}
newID, err = res.LastInsertId()
if err != nil {
return 0, 0, fmt.Errorf("void: last insert id: %w", err)
}
return oldID, newID, nil
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanFact(r rowScanner) (Fact, error) {
var f Fact
var tsMilli int64
var kind string
var voids sql.NullInt64
if err := r.Scan(&f.ID, &tsMilli, &kind, &f.Key, &f.Value, &f.Source, &f.Confidence, &voids); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Fact{}, ErrNoFact
}
return Fact{}, err
}
f.Ts = time.UnixMilli(tsMilli).UTC()
f.Kind = FactKind(kind)
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), ",")
}