spec: pin Radicale + cmd/mavcaldav, add acceptance criteria per open item

Addresses Claude's review:
- Acceptance criteria (done-when) for all 8 open items
- PROTOCOL.md must be generated from wire.go, not freehand
- Resolved CalDAV: Radicale, new cmd/mavcaldav module
- Reordered to core→capabilities→harden priority
- Hard guard: multi-user schema is do-not-touch this phase
- Dropped Nextcloud mention
This commit is contained in:
kami
2026-07-03 12:51:51 +02:00
parent 359ae81d1f
commit 8331be18ab
+270
View File
@@ -0,0 +1,270 @@
# Maven — Project Spec
> Generated 2026-07-03 from a live QA session. This is the north star, not a
> roadmap. Priority is: core works → add capabilities → harden. Details change;
> the principles and target state are settled.
## Identity
**Maven** — self-hosted personal assistant. One daemon on homesrv, multiple
client surfaces. All local, never phones home.
Primary name is "Maven" with feminine-gendered Russian self-reference ("она",
"меня", "помогла"). Clients are free to choose their own UI label.
## Users
| Phase | Users | Data model |
|-------|-------|------------|
| Now (MVP) | just me | Single-user, no namespace |
| Soon | me + gf | Per-user namespace (facts/notes/reminders partitioned by speaker attribution) |
Per-user means: when the router attributes an utterance to user X, writes go
into X's partition. Reads are user-scoped too. Shared state (house chores,
shared calendar busyness) is explicitly cross-partition via a `shared` or
`household` namespace. The router owns attribution — speaker recognition (for
voice) plus surface ownership (for text).
## Voice pipeline
Real STT + TTS are already wired and tested. The stub floor exists for CI and
for the "no models on disk" bootstrap.
| Component | When active | Module | Handler |
|-----------|-------------|--------|---------|
| STT | `voice.stt.socket` in config | `cmd/mavsttd -model <path>` | whisper.cpp (CGo, Vulkan) |
| TTS | `voice.tts.socket` in config | `cmd/mavttsd -piper <bin> -model <path>` | piper (subprocess, espeak-ng) |
| STT stub | socket unset or module without `-model` | in-process `stt.Stub` or `mavsttd` stub | hash + template |
| TTS stub | socket unset or module without `-piper` | in-process `tts.Stub` or `mavttsd` stub | 200ms tone |
The server has iGPU + Vulkan. whisper.cpp already uses Vulkan; piper uses CPU
(lightweight, real-time). Stubs let the daemon exercise end-to-end without
any model files.
## Auth model
A cascade, not a pick-one:
| Layer | Question | Mechanism | Surface |
|-------|----------|-----------|---------|
| 0 — network | On the tunnel? | WireGuard | everything |
| 1 — device | Enrolled box? | mTLS (long-term, optional) | PC client |
| 2 — session | You, now? | Passkey / WebAuthn | authed surface |
| 3 — step-up | You, *right now*, for this act? | Passkey user-verification gesture | destructive tool confirm, registration enable, cold-start unlock |
Week 1 floor: layer 0 only (WG tunnel), `FloorEnrollment` grants full L3 to
any same-uid caller. The passkey layer is the deferred build — the code has
the auth scaffolding (`internal/auth/`), just not the WebAuthn dance.
**Key invariant:** voice/chat structurally cannot reach layer 3. A room mic
is reachable by anyone present → destructive acts always gate behind an
authed surface for final confirm.
## Away-channel fallthrough
When no voice session is active and a nudge fires:
| Severity | Voice available | No voice |
|----------|----------------|----------|
| sev1-2 (care) | voice | **drop** (silent, non-critical) |
| sev3 (ops soft) | voice | ntfy |
| sev4 (ops hard) | voice + ntfy | telegram, repeat-til-ack |
The current code returns `ErrNoSession` and stops. Target: the dispatcher
falls through the routing table to the next channel when voice returns
no-session, matching the table above. The `voicesink` and `dispatcher` need
this reroute path wired.
## Calendar
Integration with **Radicale** (self-hosted CalDAV). Not Nextcloud.
Scope: **read + write events**:
- Read: detect busy/available (gate nudges), answer "what's on my calendar"
- Write: "schedule a meeting at 3pm", "move the dentist appointment"
The `calendar_busy` config fact already exists and the loop gate reads it.
The feed is a **new `cmd/mavcaldav` module** (separate binary) that polls
Radicale and writes `calendar_busy` + events as facts through CoreAPI.
## Deployment
| Phase | Mechanism | Notes |
|-------|-----------|-------|
| Now | scripts (`start-maven.sh`, `kill-maven.sh`) | manual start/stop in tmux |
| Soon | systemd user units | one per binary, socket-activated modules |
| Future | Docker / Podman | single compose, or dockerfiles per component |
Invariant: **core is rarely redeployed, components are**. The IPC boundary
(`internal/worker` STT/TTS sockets, `internal/ipc` CoreAPI socket) means
`mavsttd`, `mavttsd`, `mavpoll`, `mavweb` can restart independently without
touching the daemon. systemd `After=mavend.socket`, `Restart=on-failure`
per module.
## Client protocol
The voice wire protocol (JSON length-prefixed frames over TCP) is designed
for **multiple client implementations**. The reference PWA at `cmd/mavweb`
is one client — any app (phone, desktop CLI, smartwatch) can implement the
same frame protocol.
The protocol needs a **published spec document** so third parties can write
clients without reading the Go source. Spec document lives in `PROTOCOL.md`
and covers:
- Transport: TCP, length-prefixed JSON frames (4-byte big-endian length)
- Methods: `PushToTalk`, `Pong`
- Push kinds: `AudioNudge`
- Surface identity: header field, cap enforcement server-side
- Error codes
The auth section of the spec documents how passkey assertions are carried
over the wire (for the step-up layer).
## Multi-user architecture (gf phase)
When the second user arrives:
1. **Speaker attribution** — the router produces a `speaker` label per utterance
("kami", "gf", "unknown"). Voice uses speaker embedding / voice-print match;
text/telegram uses explicit surface ownership or a command prefix.
2. **Per-user partitioning**`facts.user_id`, `notes.user_id`, `reminders.user_id`.
Queries scope to the current speaker's partition.
3. **Cross-user reads** — explicit, e.g. "show kami's calendar" or "remind us both".
The router decides from the utterance form.
4. **Shared namespace** — household chores, shared calendar, home automation.
A `user_id = 0` or `user_id = 'shared'` convention.
This is a **post-MVP** concern. Single-user works for now; the schema has no
`user_id` columns yet. Adding them later is a migration, not a rewrite, because
the append-only design means no existing row needs updating.
## ML & hardware profile
| Resource | Available | Used by |
|----------|-----------|---------|
| CPU | Ryzen, 13GB RAM | loop, router classifier, delivery |
| iGPU | Vulkan-capable | whisper.cpp (STT), piper (TTS) |
| GPU layers | llama-server with `-ngl -1` | LFM 2.5-1.2B phraser |
Models are per-component, downloaded separately (gitignored `models/` dir).
No model is baked into the binary.
## Open design items
Priority: core works → add capabilities → harden. Items 16 are capabilities;
7 is hardening; 8 is post-MVP and explicitly out of scope this phase.
Each item includes a "done when…" line so an autonomous agent has a checkable
finish criterion.
---
### 1. Protocol spec (`PROTOCOL.md`)
Document the voice wire format so the protocol is multi-client by design.
**Must be generated from `internal/voice/wire.go`**, not composed freehand —
the wire.go comments are already complete; the spec must not drift from code.
- Transport: TCP, length-prefixed JSON frames (4-byte big-endian length)
- Methods: `PushToTalk`, `Pong`
- Push kinds: `AudioNudge`
- Surface identity: header field, cap enforcement server-side
- Error codes
Done when: a new client implementor can build a working PushToTalk round-trip
from PROTOCOL.md alone, and `diff PROTOCOL.md internal/voice/wire.go` shows no
contradictions in constants or method names.
---
### 2. Away-channel fallthrough
Wire the dispatcher to fall through when voice returns `ErrNoSession`,
matching the table in § Away-channel fallthrough (lines 6771).
The `voicesink` and `dispatcher` need this reroute path wired.
Done when: a test (or manual trace) where voice returns `ErrNoSession` and a
sev3 nudge lands on ntfy, sev4 on telegram-repeat-til-ack, sev12 drops
silently. Existing delivery test patterns in `internal/delivery/` show the
shape.
---
### 3. CalDAV poller (`cmd/mavcaldav`)
New separate binary that polls Radicale (CalDAV) on a configurable interval
and writes facts through CoreAPI:
- `calendar_busy` — boolean, read by the loop gate
- `calendar_event` — per-event facts for "what's on my calendar" queries
Writes only on value change (same append-only discipline as `mavpoll`).
Done when: `cmd/mavcaldav -socket <core> -url <radicale> -user <u> -pass <p>`
runs, polls Radicale, and a `calendar_busy` fact with the right value appears
in the store. A `DueReminders`-style test proves the loop gate reads it.
---
### 4. Quiet-hours schedule
The loop reads a `quiet_hours` config fact. Today a voice toggle writes it
manually ("тихий режим"). Target: the fact is also set by a time-window
schedule (e.g. "quiet from 23:00 to 08:00") or automatically from
calendar-busy. The schedule lives in config (`voice.quiet_hours_window` or
similar); the loop writes the config fact at tick boundaries when the window
is active.
Done when: setting a quiet window in config suppresses proactive nudges
during those hours without the user saying "тихий режим", and
calendar-busy also gates the same way.
---
### 5. Tool executor `/tools` page
The executor + matcher are wired (`internal/tool`, `internal/store/tools.go`).
Tools are proposed via voice, but enabling them requires an authed surface.
The missing piece is the **`/tools` page at `cmd/mavweb`** serving enable/
disable/discover UI, gated at `AuthStepUp`.
Done when: visiting `/tools` on mavweb shows proposed tools with an "Enable"
button, and enabled tools with "Disable". Enabling fills cmd + destructive
flag and writes to the store. The tool runs on the next matching utterance
without a daemon restart (already true — store-backed).
---
### 6. Note RAG
Today the `query` intent returns the verbatim top-k note. Target: feed
gated top-k notes to the phraser (llama-server) to compose a natural answer:
"вот что я нашла: <summary>".
Done when: asking "что я говорил про X" returns a phrased answer with
content from the matching notes, not a raw note dump.
---
### 7. Passkey step-up
WebAuthn enrollment + assertion in the authed surface. Replaces
`FloorEnrollment` with real passkey verification for:
- Destructive tool confirm (layer 3)
- Registration enable (layer 3)
- Cold-start unlock (layer 3)
Done when: a destructive tool requires a WebAuthn gesture (biometric/PIN)
before it fires; `FloorEnrollment` is removed; cold-start passes through
the passkey page.
---
### 8. Multi-user schema
**DO NOT TOUCH THIS PHASE.** Per-user partitioning (`facts.user_id`,
`notes.user_id`, `reminders.user_id`) is a migration-later concern. The
schema has no user_id columns; adding them when the second user arrives
is a migration, not a rewrite, because append-only means no existing row
needs updating. An autonomous agent must not introduce user-scoping
mechanisms while single-user is the only operational mode.
## Non-goals (unchanged from `maven.md`)
- Not a relationship simulator
- Not a guesser of truth — inference changes whether she asks, never what she records
- Not a nag — would rather miss a nudge than be mutable
- Not autonomous — suggests and acts on command, never unsandboxed action rights