# Maven — Design *Last verified: 2026-08-02 @ 7079a24. Living doc: correct it in place, do not append.* > Folded 2026-07-30 from `SPEC.md` (north star, 2026-07-03), `maven.md` > (consolidated decisions, 2026-06-30) and `ROADMAP.md` (execution plan, > 2026-07-06). Those three files are gone; git history holds them. > This is the single design document: principles, target state, and the > execution ledger. `docs/rearchitecture.md` remains authoritative wherever it disagrees > with anything here. Everything the three sources asserted that is no longer > the intended design is preserved under **§ Superseded** — do not read that > section as current. --- ## Identity **Maven** — self-hosted personal assistant. Manages your day, acts on your homelab. One daemon on homesrv (always-on, not the workstation), multiple client surfaces. Inference and data stay on the box; she may READ external sources (see Non-goals — "never phones home" is deprecated). Primary name is "Maven", with feminine-gendered Russian self-reference ("она", "меня", "помогла"). Clients may choose their own UI label. Consistent character — tone, values, phrasing — pinned in prompt; it is what makes restraint legible. ## Non-goals Outside boundary: not Alexa/Siri on steroids, not a smart device. Inside boundary — the ones that actually constrain the build: - **Not autonomous** — suggests and acts on command. Proactive triggers never get unsandboxed action rights. "backup failed, rerun it?" — never reruns it herself. suggest ≠ act is the safety model. - **Not a guesser-of-truth** — inference changes whether she asks, never what she records. A confident wrong fact is worse than a known gap. - **Not a nag** — she'd rather miss a nudge than be mutable. Shuts up when uncertain. Load-bearing. - **Not a stranger** — runs on your stuff, your model, your data. No telemetry, no cloud model, no third-party account. She may READ external sources to answer world questions (Kiwix first, then optional search); she never reports anything about you to anyone, and your notes and facts are never used as search input. **"Never phones home" as an absolute is deprecated** — owner's call, 2026-07-31: a small model does not know enough to be useful without reading. - **Not a relationship** — mom-tone is a function that makes nudges land, not emotional company. Names the drift a warm small model falls into. ## Capabilities - **Reactive** — converse (voice in → STT → router → LLM → TTS, and text); act (function calls into the homelab). - **Proactive** — health nudges (hydration, meals, breaks, shower, sleep, cleanup); user reminders (stated future intent, fires once); deliver (voice when near, ntfy/telegram when away); restrain (quiet hours, per-rule cooldowns, snooze-memory, self-quieting). - **Capture** — throw facts/notes/tasks at it mid-flow. - **State** — self (timestamped facts about you), presence (inferred, decaying confidence, never one signal), activity, environment (homelab health, calendar, weather). - **Memory** — long-term recall and personalization. - **Feedback** — nudge outcomes (acted/snoozed/ignored) tune the rules; corrections are recorded; self-quieting falls out of this. - **Surface** — you talk to it (phone page, PC client), it reaches you, and it can prove it's you (auth). ## 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, and reads are user-scoped. Shared state (house chores, shared calendar busyness) is explicitly cross-partition via a `shared` / `household` namespace. The router owns attribution — speaker recognition for voice, surface ownership for text. **This is post-MVP and fenced.** The schema has no `user_id` columns. Adding them later is a migration, not a rewrite, because append-only means no existing row needs updating. **An agent must not introduce user-scoping mechanisms while single-user is the only operational mode.** Revisit when a second person is actually in the house — speaker attribution needs the second voice to train against. --- ## Architecture - Daemon lives on homesrv (always-on), not the workstation. - The trigger loop is dumb: ticks ~60s, no LLM, evaluates deterministic predicates against state. - The LLM wakes only when a predicate fires; its job is narrow — phrase, never drive the loop. - Presence is a decaying confidence score over multiple weak signals with hysteresis; never trust one source. - Self-facts → care nudges. World-facts → ops + context. Same engine. - Rules as code, not a config DSL — revisit at ~30 rules. - Proactive triggers: read + suggest only, never action rights. **Build order:** the state layer is first. Nothing proactive works without state to evaluate predicates against — it is the floor, built before the loop, phrasing, or delivery. ### Storage — sqlite Single-user, no concurrent writers, on a box already tight on RAM → sqlite, not postgres. Library not a process, no port to harden, backup is `cp`. Giving up postgres `LISTEN/NOTIFY` is a non-loss: the loop polls anyway. At-rest encryption is AES-256-GCM with a tmpfs working copy (`internal/store/crypt.go`), **not** sqlcipher. The key is read at daemon start, never hardcoded. ```sql PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000; ``` ### Schema — append-only, three shapes Never UPDATE a value. A wrong fact is superseded, not overwritten — this keeps the audit trail. Current value = latest non-voided row for a key. **facts** — substrate, all observations (self + env): ```sql facts ( id, ts, -- ts = valid-time (true-as-of), not insert-time kind, -- 'self' | 'env' | 'config' key, value, -- value json if structured source, -- tap:* | infer:* | poll:* | ambient | promote | feedback confidence, -- 1.0 taps only; <1 inferred voids_id -- correction points at the fact it cancels ) -- index (key, ts desc) ``` **reminders** — user intent, fires once: ```sql reminders ( id, created_ts, fire_ts, payload, status ) -- pending|fired|cancelled ``` **nudges** — every proactive send + outcome. This table IS the restraint memory: ```sql nudges ( id, ts, rule, channel, message, outcome, outcome_ts ) -- pending|acted|snoozed|ignored ``` **Presence is not a table** — it is a pure function over recent facts, computed each tick. The only stateful bit is hysteresis: ```sql presence_state ( last_bucket, last_score, updated_ts ) ``` Facts additionally carry `Subject`/`EntityID`/`ResolutionState` for entity-aware resolution against Nexus (see `docs/ecosystem.md`). ### Trigger model - The loop ticks ~60s, no LLM. 99% of ticks evaluate a few predicates and die for free. - A predicate is `(State) -> Boolean`, **pure, no I/O** → unit-tests with a fake State, zero infra. - `since(key)==null` → don't fire. Silence on no-data is "shuts up when uncertain." - **The gate is universal, applied by the loop, never per-rule** — quiet-hours, presence, cooldown, snooze, calendar-busy all live in one `fires()`. Cross-cutting restraint lives in one place or it drifts. - One nudge per tick (max severity), never dogpile. ### Rules decide, LLM phrases The rule decides whether Maven speaks — absolute, deterministic. The LLM only words it: input `(rule, severity, context)`, output `message`. **No `send`/veto bool** — a nondeterministic small model never gets to silently kill a greenlit nudge. Suppression context ("don't nag mid-meeting") moves INTO the gate as an env predicate, not the LLM's job. ### User reminders — a separate class - Relative → absolute **at capture** ("in 4h" → store `now+4h`, never the string). - Reuses the loop, not a second scheduler — just a predicate: `fire_ts <= now AND pending`. - **Bypasses the restraint gate** — "wake me 7" fires in quiet hours; that's the point. Snooze still applies. Two delivery paths. --- ## Reactive path — routing **Target design: LLM-as-router** (see `docs/rearchitecture.md` and `CLAUDE.md`). One resident model emits GBNF-constrained structured JSON, and the same model phrases replies; the embedder is a RAG hint, not a routing gate. The committed default today is the classifier/embedder cascade, which is an interim stopgap — see **§ Superseded**. ### A cascade, not one decider Not alternatives — layers: - **Stage 0 — exact match (regex/grammar).** Wake-word + known command grammar. "maven, restart nginx" hits the allowlist directly and skips everything downstream. Lowest latency; boring high-frequency acts for free. - **Stage 1 — route decision.** The resident LLM (target) or the nearest-centroid classifier (current stopgap). Any LLM error falls through to the classifier so a turn never breaks on the model. - **Stage 2 — slot extraction, per intent.** Classification gives *what kind*, not *the args*. Reminders need a datetime, acts need fn + params. - **Stage 3 — confidence gate.** Below threshold → clarify, don't guess. Same pattern as `since(key)==null → don't fire`. A misroute is a confident wrong write, which is worse than a gap. Router contract: `[{"intent":, key?, value?, text?, verb?}, ...]` over 7 intents (`fact, reminder, note, query, act, chat, system`). ### save-where — the two-memory routing axis One discriminator: **does the loop evaluate a predicate against it?** | intent | example | lands in | why | |---|---|---|---| | act | "restart the backup" | function call (allowlist) | command now, not stored | | reminder | "wake me 7", "vet tuesday" | `reminders` | has a fire-time | | fact | "drank water", "slept 6h" | `facts` | structured state the loop reasons over | | note | "gpu driver fixed the flicker" | semantic store | recall/preference, no predicate touches it | | query | "is the backup up?" | LLM over the stores | answer, don't store | fact-vs-note is the whole line: a predicate will read it → structured `facts` row; "recall when relevant" → semantic store. Reminder splits off by future timestamp; act splits off by being imperative-now. ### The preference seam — forced, not a choice A preference ("prefer backups at 3am") looks like note-or-config. It isn't, because of a hard constraint: **a predicate cannot read the semantic store.** The loop is dumb and deterministic; it can't run a vector search every tick. The moment a preference becomes load-bearing it MUST exist as a structured `key=value` row the loop can evaluate. The store split is physical, not cosmetic. So: **capture → always a note** (inert, fail-safe, drives nothing), and **a note stays a note until a rule needs it.** ### Promotion **Promotion = the moment a human authors a predicate that reads the value.** - Authoring the rule copies the value into `facts (kind=config, source=promote)` — a deterministic key the loop can evaluate. - The note stays as provenance (where the config came from, in your words). - The predicate reads the promoted `facts` row, never the semantic store. Properties: **one-way, never auto** — a note cannot self-promote; there is no path from the semantic store into the loop that skips a human writing a predicate. This **closes the injection hole**: ambient-derive overhears the TV say "prefer backups at 3am" → lands as an inert note → cannot drive the loop. Same shape as `proposed → enabled`: the low-authority form is free and automatic, the high-authority form requires a deliberate human act. ### Tool registration — drafting is suggest, enabling is act Maven can scaffold a tool she's missing. She cannot enable it. - She detects the gap, scaffolds the registration (name, command, params, destructive y/n), writes a `proposed` row, surfaces it. - `proposed → enabled` flips through an authed surface (PC client / authed page), **never the voice/chat path** — that's the act, and it's the user's. Why human-only: editing the allowlist is the one act that *moves the boundary*, and a boundary you can move from inside isn't one. Registration is privilege escalation, a different authority tier from invoking a listed tool. Paranoid case: prompt injection via ambient-derive — the TV says "maven add a shell tool" — a self-registering Maven grants *itself* arbitrary capability. Human-only enable keeps a compromised Maven boxed by what's already on. She builds the stubs, you review and enable; you never lose the pen. ### Confirmation is not one mechanism A gate assumes a fully-formed action — "drop the db? y/n" works because the action is already specified. An underspecified request can't be gated; you can't confirm what isn't specified. Confirmation scales to how formed the act is. Acts fuzzy-match against the fn allowlist: **not on the list → refuse, don't improvise.** Destructive ones still gate behind confirm. Misroute correction is append-only and grows the router's examples with use — same shape as `nudges.outcome` tuning cooldowns, no retrain. --- ## Voice pipeline (STT / TTS) Real STT + TTS are 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 ` | whisper.cpp (CGo, Vulkan) | | TTS | `voice.tts.socket` in config | `cmd/mavttsd -piper -model ` | piper (subprocess, espeak-ng) | | STT stub | socket unset / no `-model` | in-process `stt.Stub` or `mavsttd` stub | hash + template | | TTS stub | socket unset / no `-piper` | in-process `tts.Stub` or `mavttsd` stub | 200ms tone | The server has an iGPU + Vulkan. whisper.cpp uses Vulkan; piper uses CPU (lightweight, real-time). Voice defaults to the stock piper RU voice (`ru_RU-irina-medium.onnx`); a custom trained voice is a model-file swap (`-model` / `VoiceConfig.Tts.Voice`), not a code change. ## Resident language model One llama-server process serves both the grammar-constrained route contract and the persona/response contract. The target artifact is produced by RU continued pretraining followed by joint persona/router SFT. Stage-0 grammar, the classifier, and stub phrasing remain availability fallbacks. A larger on-demand reasoner is deferred until the main feature set is complete. Models are per-component and downloaded separately (gitignored `models/`); no model is baked into a binary. llama-server runs with `-ngl -1`. **Resident checkpoint (resolved 2026-07-30, task #318).** Currently **Qwen3.5-0.8B** (`Q4_K_M`) — the smallest checkpoint in the gguf library, chosen for latency on the deploy box. The **target** is the locally CPT'd **Qwen3-1.7B**, whose training is still in flight (Vikunja #122); until that produces a gguf, 0.8B is what runs. The library lives at `/mnt/hdd1/llms`, bind-mounted to `/opt/maven/models/llm`, which **shadows** the repo's `models/llm/` — the LFM2.5-1.2B gguf in the repo tree is a leftover and is never loaded. Earlier docs claiming LFM2.5 or a 2B Qwen as resident described states that are no longer current; see § Superseded. Persona is configurable: `voice.persona` in `mavend.json` is prepended to every system prompt; empty means the built-in feminine-gendered Russian persona. ## ML & hardware profile | Resource | Available | Used by | |----------|-----------|---------| | CPU | Ryzen 5 5600U, 13GB RAM | loop, router, delivery | | iGPU | Vega, Vulkan | whisper.cpp (STT), piper (TTS), llama-server offload | | llama-server | `n_gpu_layers: 99` | resident model — Qwen3.5-0.8B now, Qwen3-1.7B target (#122) | --- ## State — signal sources **Explicit taps set truth. Passive signals drive prompting.** A passive signal can never write a self-fact — it only makes Maven ask. The boundary lives in `source`; rules trust provenance. - **self / taps (1.0):** water, meal, shower — phone / voice / telegram. - **self / passive (activity, not truth):** desk-idle, voice_active, sleep. - **presence (weak, decaying, multi-source):** wg handshake, page heartbeat, desk-not-idle. *No LAN sweeps* — too invasive; wg + heartbeat get ~90%. - **env / polled:** healthcheck (disk/service/backup/cert), calendar (CalDAV), weather. - **provenance-scoping:** a rule on `service_down` trusts only `source=poll:healthcheck`. A compromised poller must not be able to forge a trigger. ### Presence — concrete scoring **Combiner — noisy-OR, not weighted sum.** These are independent-ish positive indicators of one latent binary ("kami here?"). ``` p_i = weight_i · exp(-Δt_i / τ_i) # one signal's decayed contribution P = 1 − Π (1 − p_i) # combine ``` Diminishing returns on stacking weak signals; never exceeds 1.0. "Any one signal raises confidence, no single one owns it" falls straight out. Weighted-sum was rejected: heartbeat+wg fresh would peg identical to everything-fresh — overcounts. **Signals.** `Δt` = `now − latest fact ts` for that key. No fact → drops out of the product (not a zero). | signal | source | fresh weight | τ (min) | rationale | |---|---|---|---|---| | desk active | `infer:hyprland` (not-idle) | 0.90 | 8 | input = human at keyboard. strongest. τ forgives read/think gaps | | page heartbeat | `infer:heartbeat` | 0.60 | 4 | a surface you use is open + alive. pings ~30s; 4min gap = gone | | wg handshake | `infer:wg` | 0.40 | 20 | device on tunnel. coarse — pocket or three rooms away | **Threshold + hysteresis (schmitt trigger):** ``` ENTER (away → present): P ≥ 0.55 EXIT (present → away): P < 0.30 cold start: away # fail-closed; same as since(key)==null → don't fire ``` A wide band is stable. A lone fresh wg (0.40) can't *enter* present but *holds* it while decaying — phone-on-network alone never declares you here. | scenario | P | bucket | |---|---|---| | at desk, typing | ~0.90 | present | | at desk, 5min reading (client open) | ~0.79 | present | | desk-only, ~9min zero input | <0.30 | → away | | couch, phone page open, no desk | 0.60 | present | | left house, only wg lingering | decays ~20min | → away | | cold boot, nothing | 0 | away | Implemented in core (`internal/store/presence.go`) — presence reads State under the lock; it is predicate input, not a module. Each tick: score → resolve against the last bucket → persist `presence_state`. Decay uses wall-clock Δt, so tick jitter causes no drift. Boundaries are **hand-tuned, NOT feedback-tuned** — keep presence numbers out of the auto-tuner or a weird week drifts you silently invisible. **Presence = reachability, not wakefulness** — sleep/quiet-hours are handled separately in the gate. Stated caveat: noisy-OR assumes independence and desk+heartbeat correlate; co-firing slightly overcounts, which is fine — genuinely co-firing IS stronger evidence. --- ## Proactive ### Listening — three modes, not one "Ambient listening" was the wrong frame; there are three capabilities split by trigger + retention. The threat model is local-only: the file at rest and who can reach the box. 1. **address-capture** — "maven, note this" out loud. Hands-free voice path, no tap. Raw audio dies after deriving the fact. **← MVP pick.** Lowest retention, highest daily payoff. 2. **meeting-record** — deliberate start/stop, verbatim transcript *kept*. Retention is the point (overrides any "always delete" rule). Post-MVP. 3. **ambient-derive** — background overhearing, low-confidence candidate facts, raw ephemeral, only the derived fact survives. Needs the confidence model working first. Last. Only mode 3 is always-on. Modes 1–2 fire heavy transcription on an explicit trigger → the CPU idles otherwise. Always-on means lightweight VAD (+ maybe owner-detect) only. **Speaker-scoping is attribution metadata, not a kill-gate** — tag `source=ambient:self | ambient:other`, so per-person retention becomes a `WHERE` clause. `cmd/mavwaked/` ships as energy-VAD only, no wake-word model — every utterance fires, capped at `SurfaceVoice` (L0). Its 30ms/16kHz frame shape matches silero-vad ONNX input 1:1, so swapping in a real wake-word model (silero-vad / openWakeWord) is a local change in `vad.go`. Hardware topology is settled: mavwaked is a *client* binary (desk PC / Pi with a mic, systemd user unit), not a homesrv daemon. ### Delivery / channel routing Routing = `f(severity, presence)`. Presence decides *reachability*, severity decides *insistence*. Both are needed. | | present | away | |---|---|---| | **sev1–2** (care) | voice | **drop** | | **sev3** (ops, soft) | voice | ntfy, once | | **sev4** (ops, hard) | voice + ntfy | telegram, repeat til ack | sev ≤ 2 drops on away, sev ≥ 3 holds: a missed water nudge is noise, a missed backup failure isn't. Away-channels (ntfy/telegram) leave the box — the one path that leaves the box for a person to see, through your own relay. **Minimal body** — "disk low on homesrv," not detail; don't make notifications a shoulder-surf exfil surface. The same table governs runtime fallthrough: when the dispatcher chose voice but no session is live at push time (`ErrNoSession`), it falls through to the next channel on this table rather than stopping. Delivery is durable — `BeginDeliveryAttempt` before `Send`, `CompleteDeliveryAttempt` after, with a stale `pending` row reconciled to `unknown` at startup (never silently resent or dropped). ### Feedback loop (outcomes → tune cooldowns) The `nudges.outcome` column IS the signal — no new storage. Cooldown becomes a function of recent outcomes, not a constant. - mostly `ignored` → nagging into the void → lengthen cooldown / raise threshold - mostly `acted` → landing → leave it, or cautiously shorten - mostly `snoozed` → right nudge, wrong *time* → shift the window, not the frequency **Tunes parameters, never logic.** It can widen a cooldown, nudge a threshold, shift a window; it cannot rewrite a predicate or invent a rule. Bounded knobs (`cooldown ∈ [min,max]`) so a weird week can't mutate Maven silent or stalker. Dead simple at MVP: a ratio over the last N, not a learned model — `ignored_rate > 0.7 → cooldown *= 1.5, capped`. **Persist the adjusted cooldown as a fact** (`source=feedback`) — it survives restart and stays visible; why Maven went quiet should be a query, not a mystery. ### Quiet hours The loop reads a `quiet_hours` config fact. A voice toggle ("тихий режим") writes it; a time-window schedule in config and calendar-busy also gate the same way, written at tick boundaries. --- ## Auth ### A cascade, not a pick-one Same shape as `confirmation is not one mechanism` — each layer answers a different question. | layer | question | mechanism | surface | |---|---|---|---| | 0 — network | on the tunnel at all? | WireGuard | everything. floor | | 1 — device | enrolled box? | mTLS client cert, terminated at proxy (optional) | PC client, authed page | | 2 — session | you, this session? | passkey / WebAuthn | PC client, authed page | | 3 — step-up | you, *right now*, for this act? | passkey user-verification gesture | registration-enable, destructive acts, **core cold-start unlock** | wg is necessary-not-sufficient: an unlocked laptop inside the tunnel is "authed" at layer 0 only — that gap is why the upper layers exist. **Passkey over password/token** because step-up is load-bearing: WebAuthn gives per-assertion user verification for free, and the biometric/PIN gesture IS the human-in-the-loop. No shared secret on the box to steal; the private key stays in the enclave/TPM. **mTLS (layer 1) is the optional one** — if dropping a layer, drop mTLS, never the passkey. `internal/webauthn/` does real WebAuthn (ES256, sign-count regression) and `cmd/mavweb/webauthn.go` serves enroll + assert; a successful assert bumps the session to L3 for 5 minutes. ### The invariant — surface caps authority **Auth tier is a property of the surface, and the surface caps maximum authority.** You cannot step up past what the channel structurally carries. - **voice** presents layer 0 + speaker attribution and STOPS. Speaker verification is attribution, not auth. A room mic is reachable by anyone present → voice is *structurally incapable* of layer 3. - **telegram inbound** = possession of a telegram account + a chat-id allowlist; telegram's auth, outside our control. Weak tier → read + soft acts, never destructive, never registration. So voice/chat can never reach registration-enable — **not because auth "failed" but because the channel can't carry the proof.** Destructive acts always gate behind an authed surface for the final confirm. ### Key provenance **The theorem:** no unattended key source survives a powered-on stolen box. Anything the daemon fetches with no human present, a thief who grabs the running laptop fetches too. The question was never "find the secure source" — it's **pick the failure mode:** unattended-but-loses-to-running-theft, or theft-resistant-but-attended. Structural; you can't have both. **The trap — TPM alone:** TPM sealing binds release to PCRs and defeats offline disk extraction (real, worth having), but against full-box theft it does nothing — PCRs still match, the thief boots, the TPM unseals on cue. A laptop is portable, so full-box theft is the *likely* case. **The resolution — cold-start IS a layer-3 act:** unlocking the DB makes Maven's entire memory readable, the single highest-authority op. "Always-on" means it doesn't need babysitting during *normal operation*; it does NOT mean it survives a cold boot with nobody around. Unlock is **remote-attended** — `systemd-ask-password` over ssh, or pushed through the passkey-authed page. Theft = the box reboots into a locked daemon and stays there. | layer | mechanism | buys | |---|---|---| | disk | LUKS2, `systemd-cryptenroll` **TPM2 + PIN** | offline extraction dead (TPM), powered theft needs the PIN in your head | | db key | not at rest: supplied at daemon start, sourced from the authed surface, held in process memory only | unlock is a deliberate gesture, never a file to steal | TPM+PIN is the honest middle; a yubikey is the upgrade path *if carried on your body*. **Runtime:** key in daemon RAM → `mlock` the page (no swap-to-disk), swap off or encrypted, zero on shutdown. **The trade:** a cold reboot needs you (remotely) present; in exchange a stolen laptop — running or off — is a brick holding ciphertext. Implemented shape (`internal/webauthn/keywrap.go`): **the key is wrapped, not derived.** A passkey assertion doesn't produce deterministic bytes (WebAuthn signatures are randomized), so at enrollment a random 32-byte AES key is generated, wrapped with HKDF-SHA256(credential public key, salt) + AES-256-GCM, and stored on disk; at cold-start the assertion unwraps it. The daemon boots *locked* — the IPC server serves only the unlock method (`MethodStoreEncryptionKey`/`MethodUnlock`), and the loop, voice and delivery don't start until unlock succeeds. mavweb serves the passkey page while locked; other pages return 503. An env-key fallback is preserved for dev/CI and recovery. This is disk-theft protection, not RAM-capture protection: root on the host can still dump the key after unlock, but a stolen disk or a `docker inspect` no longer yields it. ### Core/module key isolation The convenience (reboot attendance collapses to *core cold-start only*) is contingent on one thing: **the key lives in core's address space and nowhere else.** - **core = the only key-holder.** The daemon holding the unlocked DB + the trigger loop. Unlocked once (remote-attended), runs for weeks. It reboots almost never *because it was deliberately given nothing that churns* — no tool code, no model weights. - **modules = restart-free, key-free, fail-independent.** STT/TTS, phrasing, tool executors, delivery. Update/crash/swap one → none touch the unlock. "Update the tool module, no attendance" is correct *by construction*: the module never had the key. Enforced by an **IPC boundary, not a shared address space** (unix domain socket, local-only). **Core mediates and never hands back a DB handle** — modules send requests *to* core ("write this fact" / "read presence"). **Module compromise ≤ module authority:** the worst a popped TTS does is send garbage audio. The discipline: **nothing enters core's process unless it must read state under the lock.** The loop and predicates qualify; phrasing, routing, transcription, tool execution and delivery all read *derived* data. Erode this and you buy back the attendance you just eliminated. systemd topology: core = one unit, each module its own unit, `After=core.socket`, socket-activated, `Restart=on-failure`. ### The through-line Network → box → process: the same question at three radii. - **network (wg):** who reaches the box - **box (LUKS+TPM+PIN, at-rest encryption):** what a dead/stolen box gives up - **process (core/module socket):** what a compromised module reaches Every cut is the same instinct — *a boundary you can move from inside isn't one*, *compromised X can't forge Y*, *attribution is not auth*. Auth didn't add a new principle; it applied the existing one at smaller and smaller scope. --- ## Calendar Integration with **Radicale** (self-hosted CalDAV), not Nextcloud. Scope is read + write: read to detect busy/available (gating nudges) and answer "what's on my calendar"; write to schedule and move events. The feed is a separate binary, `cmd/mavcaldav`, which polls Radicale and writes `calendar_busy` plus per-event facts through CoreAPI, on value change only (same append-only discipline as `mavpoll`). ## Deployment | Phase | Mechanism | Notes | |-------|-----------|-------| | Then | scripts (`start-maven.sh`, `kill-maven.sh`) | manual start/stop in tmux | | Now | Docker (one image, several daemon containers) | `docker-compose.yml` | | Alt | systemd user units | one per binary, socket-activated modules | Invariant: **core is rarely redeployed, components are.** The IPC boundary (worker STT/TTS sockets, `internal/ipc` CoreAPI socket) means `mavsttd`, `mavttsd`, `mavpoll`, `mavweb` restart independently without touching the daemon. ## Client protocol The voice wire protocol (length-prefixed JSON 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 published spec is `docs/protocol.md` — **generated from `internal/voice/wire.go`**, not composed freehand, so it can't drift from code. It covers transport (4-byte big-endian length prefix), methods (`PushToTalk`, `Pong`), push kinds (`AudioNudge`), surface identity (header field, cap enforced server-side), error codes, and how passkey assertions are carried for step-up. The act allowlist is config-driven (`deploy/mavend.json` seeds a homelab set: read-only status/ps/uptime/df/free/logs, gated restart/stop/reboot). Broadening to home automation, media or comms is JSON, not code. --- ## Execution ledger Condensed from `ROADMAP.md` (2026-07-06). The live queue is the Vikunja board (project Maven, ID 2); this table is history, not a work list. | # | Item | Prio | Status | |---|------|------|--------| | 1.1 | Kuma API key for `service_down` polling | P1 | done `eda434f` | | 1.2 | Voice bind verify + stale comment fix | P1 | done `eda434f` | | 1.3 | desk_active presence script on desk PC | P1 | **not done** — operator action on `linux` (systemd user timer + hypridle listener); 0 facts ever written, presence runs on `page_heartbeat` alone | | 2.1 | Cold-start unlock (passkey → L3 key seam) | P2 | code done `b0932a1`+`15fe7bb`, **tests missing** — wrap/unwrap round-trip, wrong-cred unwrap fails, locked-mode IPC rejects non-unlock methods | | 3.1 | Always-on listening | P3 | MVP `e57647c` (energy-VAD only); remaining: wake-word model in `vad.go` | | 3.2 | Conversation depth (multi-turn) | P3 | done `05236ad` — anaphora resolver + cross-intent `followUpMerge` + `Session.History` | | 3.3 | Latency / streaming (streaming STT/TTS, barge-in) | P3 | not started; recommended path is WebSocket voice, keeping TCP for non-browser clients | | 4.1 | Routing quality (dev embedder) | P4 | done `b7eb53a` — `make download-embedder`, configurable `voice.query_min_score` | | 4.2 | Act surface broadening | P4 | not a code item (operator config) | | 4.3 | LTM ANN index | P4 | deferred — `memory.Store` is the swap point; brute-force cosine is sub-ms at single-user scale. Revisit past ~10k rows | | 4.4 | Persona prompt | P4 | done `b7eb53a` — `voice.persona` | | 4.5 | Custom TTS voice | P4 | not started; operator work (record ~50–100 clips, train a piper voice), code already supports the swap | | 5.1 | Multi-user | P5 | deferred by design — do not start without an explicit operator decision | Also landed from the SPEC's original open items: protocol doc, away-channel fallthrough, CalDAV poller, quiet-hours schedule, tools enable/disable page (`/tools`, gated at step-up), and note RAG (the `query` intent phrases from gated top-k notes instead of dumping a verbatim note). Conventions retained from the roadmap: **done when** = a checkable finish criterion; name the exact files, not "somewhere in internal/"; every code item ends with `make test` green (gofmt + vet + `-race`), no exceptions. --- ## Open questions Router+invocation, two-memory routing, presence, and auth were once listed here and are resolved by the sections above. **Impactful — gate behaviour or the MVP surface:** - **stage-3 confidence threshold** — the gate-or-clarify number. Defines how often Maven asks vs. guesses on free-form input. - **quiet-hours definition** — fixed clock vs derived from sleep facts. - **confirmation tier model** — only the forced pin is settled (registration is out-of-band, human-only). The rest is a sketch: confirmation scales with specification + reversibility (just-do / binary-confirm / clarify-then-confirm / out-of-band). Open sub-item: destructive-act confirm — policy-level vs per-function flag. - **ask-password transport** — `systemd-ask-password` over ssh vs a passkey-authed page push for cold-start unlock. Both work; unpicked. - **wake-word hardware** — USB mic on a client box (fast path) vs an ESP32-S3 room device (the "real" version). The code is the same either way; the hardware changes the deploy — and it decides where an ambient reply is spoken (speaker on the capture device vs the PWA if a session is live). **Plumbing / deferred:** - **passkey enrollment bootstrap** — the first credential on a fresh device, before a passkey exists to authenticate with (trust-on-first-use gap). - **mTLS in or out** — provisioning cost on mobile vs paranoia payoff. Leaning optional. - **PIN vs yubikey for LUKS** — PIN-in-head pinned for now. - **session lifetime / re-auth cadence** — unset. - **compound captures** — "slept 6h, fan noise wrecked it" = one fact + one note in one utterance. Needs a second pass or it loses half. - **query read-path** — semantic RAG vs a structured read, depending on the ask. - **presence — away tap override** — an explicit `away` tap as a hard override. Clean extension, deferred; scoring stands without it. - **presence — weights/τ hand-tuning** — first-guess numbers; expect tuning against real signal traces. - **presence — wg home-vs-cellular** — let wg carry more weight when clearly home. Needs the signal to exist first. - **listening modes 2–3** — meeting-record + ambient-derive; ambient-derive needs the confidence model first. - **LLM dialogue manager** — the router/phraser deciding "ask for X" vs "act." Blocked on the resident-model question (task #318). --- ## Superseded Kept for provenance. **None of this is the current or intended design.** - **Classifier-owns-the-route.** `maven.md` argued the route decision must stay deterministic — "classifier owns the route, the SLM stays in its phrasing lane" — with an embedding + nearest-centroid stage 1 over ~10 examples per intent, and misroutes appended as new centroid examples. *Replaced by* LLM-as-router (`docs/rearchitecture.md`): one resident model emits GBNF-constrained JSON and also phrases replies; the embedder is demoted to a RAG hint. *Landed 2026-07-31:* the LLM router is on by default and set `true` in `deploy/mavend.json`. The classifier cascade stays as the failure floor — it runs when there is no llama-server to talk to and on any per-turn LLM error — but routing by seed similarity is the known cause of weak RU query handling and is not a design to extend. - **Named STT/TTS model picks.** `maven.md` picked faster-whisper small/int8 as primary STT with vosk RU for a low-latency command grammar, and silero (license unverified) as TTS with piper RU as the floor, all on onnxruntime/CPU. *Replaced by* whisper.cpp (CGo, Vulkan) in `cmd/mavsttd` and piper as the production TTS in `cmd/mavttsd`. Several Go doc comments still cite the old picks by way of `maven.md § stt/tts`. - **Small-model phrasing claim.** `maven.md` specified "lfm2.5 / sub-1b for phrasing — prompted, not trained," and `SPEC.md` named a specific resident size. Both are superseded by the RU-CPT + joint persona/router SFT plan. *Resolved 2026-07-30 (#318), revised 2026-07-31:* the resident checkpoint is stock **Qwen3-1.7B** (`UD-Q4_K_XL`, `n_ctx` 4096), which replaced Qwen3.5-0.8B after measuring better on both fixtures (`docs/evals/2026-07-31-model-bakeoff.md`). The CPT'd **Qwen3-1.7B** remains the target (#122); what stock gets wrong is the persona, not the Russian. Note the resident model is no longer described as untrained — the target is trained end-to-end, which is the substantive change from the old claim. - **sqlcipher at rest.** `maven.md` specified sqlcipher with the key read at daemon start. *Replaced by* AES-256-GCM with a tmpfs working copy (`internal/store/crypt.go`). The key-provenance argument above survives unchanged; only the cipher layer differs. - **Kotlin/Spring implementation sketches.** `maven.md` gave the presence scorer as Kotlin (`data class Signal`, `presenceScore`, `resolve`) and cited Spring Security's passkey support as in-stack. *Replaced by* Go throughout; the presence math is unchanged and lives in `internal/store/presence.go`. - **obsidian → chroma for long-term memory.** `maven.md` specified Obsidian as canonical markdown with a derived Chroma embedding index, and listed the chunking mechanics as unbuilt. *Replaced by* sqlite-backed vector storage (`internal/store/memory.go` behind `internal/memory.Store`); no Chroma, no Obsidian. Wherever this document says "semantic store," that is what it means. - **Script-based deployment.** `start-maven.sh` / `kill-maven.sh` in tmux was the "now" row of the SPEC deployment table. *Replaced by* the Docker deployment (one image, several daemon containers). - **`FloorEnrollment` as the auth floor.** `SPEC.md`'s week-1 floor granted full L3 to any same-uid caller with only the wg tunnel underneath. *Replaced by* real WebAuthn enroll/assert plus the wrapped-key cold-start path; the passkey step-up item is landed.