Files
Maven/docs/design.md
claude 6d8a95095a deploy, docs: turn service_down back on (V-444)
It was disabled because it could not say which service. It can now.
2026-08-04 05:15:02 +04:00

41 KiB
Raw Permalink Blame History

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.

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):

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:

reminders ( id, created_ts, fire_ts, payload, status )  -- pending|fired|cancelled

nudges — every proactive send + outcome. This table IS the restraint memory:

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:

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":<enum>, key?, value?, text?, verb?}, ...] over 7 intents (fact, reminder, note, query, act, chat, system).

A restart expires a parked question

Decided 2026-08-04 (Vikunja #385). The follow-up dialogue session survives a restart; the clarify question parked behind it does not, and neither do the three yes/no confirms in voice.go. ClarifyStore stays in memory.

Three reasons, in the order they settle it:

  • The clock stops meaning anything. A parked question carries a 90s TTL and an attempt count. A restart is a gap of unknown length, so a restored question is either already dead or pretending to be young.
  • Restoring the question restores the request behind it. He asked for something, she asked back, and then the daemon went away. Acting on that minutes later, against words he has probably given up on, is the misroute the stage 3 gate exists to avoid.
  • She does not announce it either. The expiry notice needs to know a question was parked, and knowing that across a restart means storing it. One sentence, in the rare window where he speaks within 90s of a restart, does not pay for a marker that outlives the thing it describes. His next words route fresh, which is the correct answer with or without the notice.

So the notice stays what it is: the in-process TTL case, where she really did wait and really did let go.

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 <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 / 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.

A fact per monitor, not an aggregate

mavpoll writes one fact per kuma monitor, keyed service_down:<monitor name>. It used to fold the whole gauge into a single boolean, and the nudge could then only say that something on homesrv was down. That is not something he can act on, so the rule shipped disabled.

Three things follow from the split:

  • The key set is no longer known at wiring time. A rule declares WantPrefixes and the gatherer resolves the family per tick, which is the only prefix read in the loop.
  • Pausing a monitor in kuma silences that monitor. Under the aggregate it silenced nothing, because some other monitor kept the boolean at "down".
  • A monitor deleted in kuma would keep its last fact reading "down" forever, so mavpoll marks a vanished monitor "unknown". No rule fires on "unknown".

The rule is also edge-triggered: it fires on a transition it has not already nudged about (State.NudgedSince). A polled fact is written only when the value changes, but the predicate reads the current value, so without the edge check a service that stays down qualifies on every tick and cooldown is the only brake.

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 12 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
sev12 (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-attendedsystemd-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.mdgenerated 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 b7eb53amake 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 b7eb53avoice.persona
4.5 Custom TTS voice P4 not started; operator work (record ~50100 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 transportsystemd-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 23 — 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.