40 KiB
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) andROADMAP.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.mdremains 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
factsrow, 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
proposedrow, surfaces it. proposed → enabledflips 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_downtrusts onlysource=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.
- 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.
- meeting-record — deliberate start/stop, verbatim transcript kept. Retention is the point (overrides any "always delete" rule). Post-MVP.
- 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.
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.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-passwordover 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
awaytap 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.mdargued 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 settrueindeploy/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.mdpicked 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) incmd/mavsttdand piper as the production TTS incmd/mavttsd. Several Go doc comments still cite the old picks by way ofmaven.md § stt/tts. - Small-model phrasing claim.
maven.mdspecified "lfm2.5 / sub-1b for phrasing — prompted, not trained," andSPEC.mdnamed 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_ctx4096), 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.mdspecified 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.mdgave 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 ininternal/store/presence.go. - obsidian → chroma for long-term memory.
maven.mdspecified 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.gobehindinternal/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.shin tmux was the "now" row of the SPEC deployment table. Replaced by the Docker deployment (one image, several daemon containers). FloorEnrollmentas 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.