Files
Maven/docs/design.md
T

40 KiB
Raw 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).

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.

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.


A list is the fourth shape

Facts, notes and tasks were the three append-only shapes. list_items is the fourth (Vikunja #453): an item, a status, and a list tag.

It is not a task. Milk is not work, nothing prioritises it, and the ranker must not start counting groceries as outstanding errands. It is not a fact either, because it claims nothing about the world. What it is, is a set that grows and shrinks.

The property that makes the separate table worth it: no predicate reads a list. Nothing ranks it, nothing nudges about it, the digestion worker ignores it. So two people adding to the same list at once cost nothing — there is no order to disagree about and no lifecycle past crossed-off.

The unique index is the tasks one, per list, and live rows only. Saying "молоко" twice before the shop is one line; saying it again next week, after the last one was crossed off, is a new line.

Spoken, it is four turns: add, read back, cross one item off, cross the lot off. All four are matched deterministically in internal/router/list.go and all four run at stage 0, because an add and a read-back are cheap and should not depend on the resident model having a good turn. Crossing one item off claims the turn only when the list holds that item, which is what keeps "купил новый ноутбук" a note.

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.