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

Closed
claude wants to merge 1 commits from overnight/ambient-calendar into overnight/local-calendar
Contributor

What

The work calendar is read without a work credential. Per the task body: a corp mail or calendar session on the homelab ties the box's blast radius to the employer's data — don't. So maven reads the signal instead.

  • internal/calendar/ambient.goNotification and EventFromNotification. Pure parse, no HTTP, no store.
  • cmd/mavweb/ambient.goPOST /api/ambient. An Android notification-listener relays meeting notifications over wg/LAN; the ones that clearly describe a meeting become facts at source=ambient:notif, confidence=0.6.
  • internal/store/facts.goCalendarEvents now reads every calendar source instead of only poll:caldav, and keeps each row's source and confidence so the caller can judge them. A non-calendar source still cannot forge a calendar event.
  • internal/router/calendar.goCalendarEntry + FormatEntries. Format is unchanged in behaviour (it delegates with everything marked certain).
  • cmd/mavend/actions_query.goqueryCalendar marks events below full confidence as uncertain.

Provenance is the feature

A notification is evidence about a meeting, not a reading of a calendar, so it is never indistinguishable from one. Stored below 1.0, and recited hedged:

на 03.08.2026: Standup @ 10:00-10:30; похоже, Планёрка @ 14:00-14:30

The parse refuses to guess

It needs a real clock reading and a summary that is not just that clock reading. Otherwise it stores nothing:

notification result
Планёрка / 10:00-10:30 event 10:00–10:30
Встреча с подрядчиком в 14:00 event 14:00–14:30 (30m default)
Sprint review / Today 16:00 – 17:00, Meet event 16:00–17:00
Созвон / с 11:30 до 12:15 event 11:30–12:15
3 новых письма nothing
Планёрка в 14 nothing (a bare hour is not a time)
Входящие / 12 непрочитанных nothing
Отчёт / срок 2026.08.15 nothing (not 08:15)
10:00-10:30 with no words nothing

A mailbox of noise rendered as invented meetings is worse than a gap.

Off unless configured

No -ambient-token, no route registered — an unconfigured ingest does not exist rather than 503ing at probes. The token is a shared secret compared in constant time (Authorization: Bearer or X-Maven-Token), because the poster is a background Android service and WebAuthn has no answer for one. The endpoint is write-only, accepts exactly one shape of write, and cannot read anything back out. Reposts dedupe against the latest fact for that key+source — a phone reposts the same notification many times.

Not shipped

The Android notification-listener app itself: a separate artifact and a physical device, not Go in this repo. Everything on maven's side is done and tested; pairing it needs the app.

Verified

make build and make test both exit 0. New tests: 15 parse cases in internal/calendar (ranges, prepositions, en-dash, до, dotted clocks, midnight crossing, and 6 refusal cases), 10 in cmd/mavweb (store, dedupe, ignore non-meetings, 401 on missing/wrong token, X-Maven-Token, 503 when off, 405 on GET, bad JSON, missing posted_at, read error), plus a store test that ambient events are returned with their source and a forged source is not, and a formatter test for hedging.

Vikunja #126

## What The work calendar is read **without a work credential**. Per the task body: a corp mail or calendar session on the homelab ties the box's blast radius to the employer's data — don't. So maven reads the *signal* instead. - `internal/calendar/ambient.go` — `Notification` and `EventFromNotification`. Pure parse, no HTTP, no store. - `cmd/mavweb/ambient.go` — `POST /api/ambient`. An Android notification-listener relays meeting notifications over wg/LAN; the ones that clearly describe a meeting become facts at `source=ambient:notif`, `confidence=0.6`. - `internal/store/facts.go` — `CalendarEvents` now reads every calendar source instead of only `poll:caldav`, and keeps each row's source and confidence so the caller can judge them. A non-calendar source still cannot forge a calendar event. - `internal/router/calendar.go` — `CalendarEntry` + `FormatEntries`. `Format` is unchanged in behaviour (it delegates with everything marked certain). - `cmd/mavend/actions_query.go` — `queryCalendar` marks events below full confidence as uncertain. ## Provenance is the feature A notification is evidence *about* a meeting, not a reading of a calendar, so it is never indistinguishable from one. Stored below 1.0, and recited hedged: ``` на 03.08.2026: Standup @ 10:00-10:30; похоже, Планёрка @ 14:00-14:30 ``` ## The parse refuses to guess It needs a real clock reading **and** a summary that is not just that clock reading. Otherwise it stores nothing: | notification | result | |---|---| | `Планёрка` / `10:00-10:30` | event 10:00–10:30 | | `Встреча с подрядчиком в 14:00` | event 14:00–14:30 (30m default) | | `Sprint review` / `Today 16:00 – 17:00, Meet` | event 16:00–17:00 | | `Созвон` / `с 11:30 до 12:15` | event 11:30–12:15 | | `3 новых письма` | nothing | | `Планёрка в 14` | nothing (a bare hour is not a time) | | `Входящие` / `12 непрочитанных` | nothing | | `Отчёт` / `срок 2026.08.15` | nothing (not 08:15) | | `10:00-10:30` with no words | nothing | A mailbox of noise rendered as invented meetings is worse than a gap. ## Off unless configured No `-ambient-token`, no route registered — an unconfigured ingest does not exist rather than 503ing at probes. The token is a shared secret compared in constant time (`Authorization: Bearer` or `X-Maven-Token`), because the poster is a background Android service and WebAuthn has no answer for one. The endpoint is write-only, accepts exactly one shape of write, and cannot read anything back out. Reposts dedupe against the latest fact for that key+source — a phone reposts the same notification many times. ## Not shipped The Android notification-listener app itself: a separate artifact and a physical device, not Go in this repo. Everything on maven's side is done and tested; pairing it needs the app. ## Verified `make build` and `make test` both exit 0. New tests: 15 parse cases in `internal/calendar` (ranges, prepositions, en-dash, `до`, dotted clocks, midnight crossing, and 6 refusal cases), 10 in `cmd/mavweb` (store, dedupe, ignore non-meetings, 401 on missing/wrong token, `X-Maven-Token`, 503 when off, 405 on GET, bad JSON, missing `posted_at`, read error), plus a store test that ambient events are returned with their source and a forged source is not, and a formatter test for hedging. Vikunja #126
claude added 1 commit 2026-08-01 00:04:29 +02:00
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.
kami reviewed 2026-08-01 10:27:37 +02:00
@@ -0,0 +15,4 @@
// POST /api/ambient — the work calendar read (Vikunja #126).
//
// Maven does not hold a work credential. A corp mail or calendar session on the
Owner

we'll have to think about it in the future, cause right now this is fragile and won't even work, probably.

we'll have to think about it in the future, cause right now this is fragile and won't even work, probably.
claude reviewed 2026-08-01 11:26:15 +02:00
claude left a comment
Author
Contributor

Reading the work calendar as a relayed signal instead of holding a work credential is the right shape, and the code keeps that promise end to end. kind=env rather than a self-fact. AmbientConfidence below 1.0 rather than pretending to be a CalDAV read. The query path hedging on that confidence. Refusing bare hours, so "в 14" cannot become a meeting. A notification with no clock reading storing nothing at all. subtle.ConstantTimeCompare on the token, and no route registered when the token is unset, so an unconfigured ingest is not a 503 to probe.

Your comment on this one is tracked as Vikunja #432.

Three things.

1. The ambient signal never reaches the busy gate. handleAmbient writes calendar_event_* and nothing else. calendar_busy is written only by the CalDAV poller. So Maven now knows about a work meeting well enough to recite it, and not well enough to stop nudging him during it. That is backwards: suppressing a nudge is the lower-risk use of a low-confidence signal, and reciting it is the higher-risk one. The hard part is that calendar_busy is a level, not an event, so an ambient writer needs an expiry rather than a single write. Worth a task rather than a change here.

2. /api/ambient is missing from the route table PR 51 just added. That table is the mechanism for making each new route's gate a decision. This is the first route added after it, and it is not listed. The gate itself is fine and arguably better than step-up for a background Android service. Add the row, with "shared secret, no step-up, poster is not a browser" as the reason.

3. Every ambient event is dated to the notification's own day. EventFromNotification takes y, m, d from n.Posted, justified by "a meeting notification is about today or it would not be firing now". Calendar apps break that. A 21:00 "Tomorrow at 09:00" reminder becomes an event at 09:00 today, which is in the past, and FactKey files it under today's date. It stores a wrong meeting rather than nothing, which is the one outcome the rest of this file works hard to avoid. Recognising "завтра" and "tomorrow" would cover most of it. Refusing a start time more than a few hours in the past would cover the rest.

Smaller: strings.TrimPrefix(..., "Bearer") is case-sensitive, so a client sending bearer <tok> falls through to the X-Maven-Token branch and gets a 401. Not wrong, but the failure is silent from the phone's side. strings.EqualFold on the scheme costs nothing.

Reading the work calendar as a relayed signal instead of holding a work credential is the right shape, and the code keeps that promise end to end. `kind=env` rather than a self-fact. `AmbientConfidence` below 1.0 rather than pretending to be a CalDAV read. The query path hedging on that confidence. Refusing bare hours, so "в 14" cannot become a meeting. A notification with no clock reading storing nothing at all. `subtle.ConstantTimeCompare` on the token, and no route registered when the token is unset, so an unconfigured ingest is not a 503 to probe. Your comment on this one is tracked as Vikunja #432. Three things. **1. The ambient signal never reaches the busy gate.** `handleAmbient` writes `calendar_event_*` and nothing else. `calendar_busy` is written only by the CalDAV poller. So Maven now knows about a work meeting well enough to recite it, and not well enough to stop nudging him during it. That is backwards: suppressing a nudge is the lower-risk use of a low-confidence signal, and reciting it is the higher-risk one. The hard part is that `calendar_busy` is a level, not an event, so an ambient writer needs an expiry rather than a single write. Worth a task rather than a change here. **2. `/api/ambient` is missing from the route table PR 51 just added.** That table is the mechanism for making each new route's gate a decision. This is the first route added after it, and it is not listed. The gate itself is fine and arguably better than step-up for a background Android service. Add the row, with "shared secret, no step-up, poster is not a browser" as the reason. **3. Every ambient event is dated to the notification's own day.** `EventFromNotification` takes `y, m, d` from `n.Posted`, justified by "a meeting notification is about today or it would not be firing now". Calendar apps break that. A 21:00 "Tomorrow at 09:00" reminder becomes an event at 09:00 today, which is in the past, and `FactKey` files it under today's date. It stores a wrong meeting rather than nothing, which is the one outcome the rest of this file works hard to avoid. Recognising "завтра" and "tomorrow" would cover most of it. Refusing a start time more than a few hours in the past would cover the rest. Smaller: `strings.TrimPrefix(..., "Bearer")` is case-sensitive, so a client sending `bearer <tok>` falls through to the `X-Maven-Token` branch and gets a 401. Not wrong, but the failure is silent from the phone's side. `strings.EqualFold` on the scheme costs nothing.
kami closed this pull request 2026-08-01 14:51:42 +02:00
Owner

Landed on master. The stack was one linear chain, so #84 carried every commit from #50 up, and master now contains this branch in full. Merging this PR on its own is an empty diff, so it is closed rather than merged. The review findings for it were fixed in the 2026-08-01 pass and are on master as commits on the stack tip, not on this branch.

Landed on master. The stack was one linear chain, so #84 carried every commit from #50 up, and master now contains this branch in full. Merging this PR on its own is an empty diff, so it is closed rather than merged. The review findings for it were fixed in the 2026-08-01 pass and are on master as commits on the stack tip, not on this branch.

Pull request closed

Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: kami/Maven#57