docs: tier the tree by lifetime, so staleness shows in the path (V-446)

Seventeen markdown files at the repo root, twelve of them dated one-shot
reports sitting next to CLAUDE.md. That is why stale docs read as
current: nothing in the path said which was which.

Root now keeps CLAUDE.md and AGENTS.md. Living docs move under docs/
and carry a Last verified line. Dated measurements move to docs/evals/
ISO-prefixed, and are never edited after the day, so a newer number is
a new file. The senior review moves to docs/archive/.

Every reference was rewritten across markdown, Go comments, the Makefile
and the recall fixture. The touched Go packages still build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 03:28:49 +04:00
parent 7079a240f7
commit 93987f2dfc
38 changed files with 99 additions and 85 deletions
+803
View File
@@ -0,0 +1,803 @@
# Maven — Design
*Last verified: 2026-08-02 @ 7079a24. Living doc: correct it in place, do not append.*
> Folded 2026-07-30 from `SPEC.md` (north star, 2026-07-03), `maven.md`
> (consolidated decisions, 2026-06-30) and `ROADMAP.md` (execution plan,
> 2026-07-06). Those three files are gone; git history holds them.
> This is the single design document: principles, target state, and the
> execution ledger. `docs/rearchitecture.md` remains authoritative wherever it disagrees
> with anything here. Everything the three sources asserted that is no longer
> the intended design is preserved under **§ Superseded** — do not read that
> section as current.
---
## Identity
**Maven** — self-hosted personal assistant. Manages your day, acts on your
homelab. One daemon on homesrv (always-on, not the workstation), multiple
client surfaces. Inference and data stay on the box; she may READ external
sources (see Non-goals — "never phones home" is deprecated).
Primary name is "Maven", with feminine-gendered Russian self-reference
("она", "меня", "помогла"). Clients may choose their own UI label. Consistent
character — tone, values, phrasing — pinned in prompt; it is what makes
restraint legible.
## Non-goals
Outside boundary: not Alexa/Siri on steroids, not a smart device.
Inside boundary — the ones that actually constrain the build:
- **Not autonomous** — suggests and acts on command. Proactive triggers never
get unsandboxed action rights. "backup failed, rerun it?" — never reruns it
herself. suggest ≠ act is the safety model.
- **Not a guesser-of-truth** — inference changes whether she asks, never what
she records. A confident wrong fact is worse than a known gap.
- **Not a nag** — she'd rather miss a nudge than be mutable. Shuts up when
uncertain. Load-bearing.
- **Not a stranger** — runs on your stuff, your model, your data. No
telemetry, no cloud model, no third-party account. She may READ external
sources to answer world questions (Kiwix first, then optional search); she
never reports anything about you to anyone, and your notes and facts are
never used as search input. **"Never phones home" as an absolute is
deprecated** — owner's call, 2026-07-31: a small model does not know enough
to be useful without reading.
- **Not a relationship** — mom-tone is a function that makes nudges land, not
emotional company. Names the drift a warm small model falls into.
## Capabilities
- **Reactive** — converse (voice in → STT → router → LLM → TTS, and text);
act (function calls into the homelab).
- **Proactive** — health nudges (hydration, meals, breaks, shower, sleep,
cleanup); user reminders (stated future intent, fires once); deliver (voice
when near, ntfy/telegram when away); restrain (quiet hours, per-rule
cooldowns, snooze-memory, self-quieting).
- **Capture** — throw facts/notes/tasks at it mid-flow.
- **State** — self (timestamped facts about you), presence (inferred, decaying
confidence, never one signal), activity, environment (homelab health,
calendar, weather).
- **Memory** — long-term recall and personalization.
- **Feedback** — nudge outcomes (acted/snoozed/ignored) tune the rules;
corrections are recorded; self-quieting falls out of this.
- **Surface** — you talk to it (phone page, PC client), it reaches you, and it
can prove it's you (auth).
## Users
| Phase | Users | Data model |
|-------|-------|------------|
| Now (MVP) | just me | single-user, no namespace |
| Soon | me + gf | per-user namespace (facts/notes/reminders partitioned by speaker attribution) |
Per-user means: when the router attributes an utterance to user X, writes go
into X's partition, and reads are user-scoped. Shared state (house chores,
shared calendar busyness) is explicitly cross-partition via a `shared` /
`household` namespace. The router owns attribution — speaker recognition for
voice, surface ownership for text.
**This is post-MVP and fenced.** The schema has no `user_id` columns. Adding
them later is a migration, not a rewrite, because append-only means no
existing row needs updating. **An agent must not introduce user-scoping
mechanisms while single-user is the only operational mode.** Revisit when a
second person is actually in the house — speaker attribution needs the second
voice to train against.
---
## Architecture
- Daemon lives on homesrv (always-on), not the workstation.
- The trigger loop is dumb: ticks ~60s, no LLM, evaluates deterministic
predicates against state.
- The LLM wakes only when a predicate fires; its job is narrow — phrase,
never drive the loop.
- Presence is a decaying confidence score over multiple weak signals with
hysteresis; never trust one source.
- Self-facts → care nudges. World-facts → ops + context. Same engine.
- Rules as code, not a config DSL — revisit at ~30 rules.
- Proactive triggers: read + suggest only, never action rights.
**Build order:** the state layer is first. Nothing proactive works without
state to evaluate predicates against — it is the floor, built before the
loop, phrasing, or delivery.
### Storage — sqlite
Single-user, no concurrent writers, on a box already tight on RAM → sqlite,
not postgres. Library not a process, no port to harden, backup is `cp`.
Giving up postgres `LISTEN/NOTIFY` is a non-loss: the loop polls anyway.
At-rest encryption is AES-256-GCM with a tmpfs working copy
(`internal/store/crypt.go`), **not** sqlcipher. The key is read at daemon
start, never hardcoded.
```sql
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA foreign_keys=ON;
PRAGMA busy_timeout=5000;
```
### Schema — append-only, three shapes
Never UPDATE a value. A wrong fact is superseded, not overwritten — this
keeps the audit trail. Current value = latest non-voided row for a key.
**facts** — substrate, all observations (self + env):
```sql
facts (
id, ts, -- ts = valid-time (true-as-of), not insert-time
kind, -- 'self' | 'env' | 'config'
key, value, -- value json if structured
source, -- tap:* | infer:* | poll:* | ambient | promote | feedback
confidence, -- 1.0 taps only; <1 inferred
voids_id -- correction points at the fact it cancels
)
-- index (key, ts desc)
```
**reminders** — user intent, fires once:
```sql
reminders ( id, created_ts, fire_ts, payload, status ) -- pending|fired|cancelled
```
**nudges** — every proactive send + outcome. This table IS the restraint
memory:
```sql
nudges ( id, ts, rule, channel, message, outcome, outcome_ts ) -- pending|acted|snoozed|ignored
```
**Presence is not a table** — it is a pure function over recent facts,
computed each tick. The only stateful bit is hysteresis:
```sql
presence_state ( last_bucket, last_score, updated_ts )
```
Facts additionally carry `Subject`/`EntityID`/`ResolutionState` for
entity-aware resolution against Nexus (see `docs/ecosystem.md`).
### Trigger model
- The loop ticks ~60s, no LLM. 99% of ticks evaluate a few predicates and die
for free.
- A predicate is `(State) -> Boolean`, **pure, no I/O** → unit-tests with a
fake State, zero infra.
- `since(key)==null` → don't fire. Silence on no-data is "shuts up when
uncertain."
- **The gate is universal, applied by the loop, never per-rule** —
quiet-hours, presence, cooldown, snooze, calendar-busy all live in one
`fires()`. Cross-cutting restraint lives in one place or it drifts.
- One nudge per tick (max severity), never dogpile.
### Rules decide, LLM phrases
The rule decides whether Maven speaks — absolute, deterministic. The LLM only
words it: input `(rule, severity, context)`, output `message`. **No
`send`/veto bool** — a nondeterministic small model never gets to silently
kill a greenlit nudge. Suppression context ("don't nag mid-meeting") moves
INTO the gate as an env predicate, not the LLM's job.
### User reminders — a separate class
- Relative → absolute **at capture** ("in 4h" → store `now+4h`, never the
string).
- Reuses the loop, not a second scheduler — just a predicate:
`fire_ts <= now AND pending`.
- **Bypasses the restraint gate** — "wake me 7" fires in quiet hours; that's
the point. Snooze still applies. Two delivery paths.
---
## Reactive path — routing
**Target design: LLM-as-router** (see `docs/rearchitecture.md` and `CLAUDE.md`). One
resident model emits GBNF-constrained structured JSON, and the same model
phrases replies; the embedder is a RAG hint, not a routing gate. The
committed default today is the classifier/embedder cascade, which is an
interim stopgap — see **§ Superseded**.
### A cascade, not one decider
Not alternatives — layers:
- **Stage 0 — exact match (regex/grammar).** Wake-word + known command
grammar. "maven, restart nginx" hits the allowlist directly and skips
everything downstream. Lowest latency; boring high-frequency acts for free.
- **Stage 1 — route decision.** The resident LLM (target) or the
nearest-centroid classifier (current stopgap). Any LLM error falls through
to the classifier so a turn never breaks on the model.
- **Stage 2 — slot extraction, per intent.** Classification gives *what
kind*, not *the args*. Reminders need a datetime, acts need fn + params.
- **Stage 3 — confidence gate.** Below threshold → clarify, don't guess. Same
pattern as `since(key)==null → don't fire`. A misroute is a confident wrong
write, which is worse than a gap.
Router contract: `[{"intent":<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-attended**
`systemd-ask-password` over ssh, or pushed through the passkey-authed page.
Theft = the box reboots into a locked daemon and stays there.
| layer | mechanism | buys |
|---|---|---|
| disk | LUKS2, `systemd-cryptenroll` **TPM2 + PIN** | offline extraction dead (TPM), powered theft needs the PIN in your head |
| db key | not at rest: supplied at daemon start, sourced from the authed surface, held in process memory only | unlock is a deliberate gesture, never a file to steal |
TPM+PIN is the honest middle; a yubikey is the upgrade path *if carried on
your body*. **Runtime:** key in daemon RAM → `mlock` the page (no
swap-to-disk), swap off or encrypted, zero on shutdown. **The trade:** a cold
reboot needs you (remotely) present; in exchange a stolen laptop — running or
off — is a brick holding ciphertext.
Implemented shape (`internal/webauthn/keywrap.go`): **the key is wrapped, not
derived.** A passkey assertion doesn't produce deterministic bytes (WebAuthn
signatures are randomized), so at enrollment a random 32-byte AES key is
generated, wrapped with HKDF-SHA256(credential public key, salt) +
AES-256-GCM, and stored on disk; at cold-start the assertion unwraps it. The
daemon boots *locked* — the IPC server serves only the unlock method
(`MethodStoreEncryptionKey`/`MethodUnlock`), and the loop, voice and delivery
don't start until unlock succeeds. mavweb serves the passkey page while
locked; other pages return 503. An env-key fallback is preserved for dev/CI
and recovery. This is disk-theft protection, not RAM-capture protection: root
on the host can still dump the key after unlock, but a stolen disk or a
`docker inspect` no longer yields it.
### Core/module key isolation
The convenience (reboot attendance collapses to *core cold-start only*) is
contingent on one thing: **the key lives in core's address space and nowhere
else.**
- **core = the only key-holder.** The daemon holding the unlocked DB + the
trigger loop. Unlocked once (remote-attended), runs for weeks. It reboots
almost never *because it was deliberately given nothing that churns* — no
tool code, no model weights.
- **modules = restart-free, key-free, fail-independent.** STT/TTS, phrasing,
tool executors, delivery. Update/crash/swap one → none touch the unlock.
"Update the tool module, no attendance" is correct *by construction*: the
module never had the key.
Enforced by an **IPC boundary, not a shared address space** (unix domain
socket, local-only). **Core mediates and never hands back a DB handle**
modules send requests *to* core ("write this fact" / "read presence").
**Module compromise ≤ module authority:** the worst a popped TTS does is send
garbage audio. The discipline: **nothing enters core's process unless it must
read state under the lock.** The loop and predicates qualify; phrasing,
routing, transcription, tool execution and delivery all read *derived* data.
Erode this and you buy back the attendance you just eliminated. systemd
topology: core = one unit, each module its own unit, `After=core.socket`,
socket-activated, `Restart=on-failure`.
### The through-line
Network → box → process: the same question at three radii.
- **network (wg):** who reaches the box
- **box (LUKS+TPM+PIN, at-rest encryption):** what a dead/stolen box gives up
- **process (core/module socket):** what a compromised module reaches
Every cut is the same instinct — *a boundary you can move from inside isn't
one*, *compromised X can't forge Y*, *attribution is not auth*. Auth didn't
add a new principle; it applied the existing one at smaller and smaller scope.
---
## Calendar
Integration with **Radicale** (self-hosted CalDAV), not Nextcloud. Scope is
read + write: read to detect busy/available (gating nudges) and answer "what's
on my calendar"; write to schedule and move events. The feed is a separate
binary, `cmd/mavcaldav`, which polls Radicale and writes `calendar_busy` plus
per-event facts through CoreAPI, on value change only (same append-only
discipline as `mavpoll`).
## Deployment
| Phase | Mechanism | Notes |
|-------|-----------|-------|
| Then | scripts (`start-maven.sh`, `kill-maven.sh`) | manual start/stop in tmux |
| Now | Docker (one image, several daemon containers) | `docker-compose.yml` |
| Alt | systemd user units | one per binary, socket-activated modules |
Invariant: **core is rarely redeployed, components are.** The IPC boundary
(worker STT/TTS sockets, `internal/ipc` CoreAPI socket) means `mavsttd`,
`mavttsd`, `mavpoll`, `mavweb` restart independently without touching the
daemon.
## Client protocol
The voice wire protocol (length-prefixed JSON frames over TCP) is designed for
**multiple client implementations**. The reference PWA at `cmd/mavweb` is one
client; any app (phone, desktop CLI, smartwatch) can implement the same frame
protocol. The published spec is `docs/protocol.md` — **generated from
`internal/voice/wire.go`**, not composed freehand, so it can't drift from
code. It covers transport (4-byte big-endian length prefix), methods
(`PushToTalk`, `Pong`), push kinds (`AudioNudge`), surface identity
(header field, cap enforced server-side), error codes, and how passkey
assertions are carried for step-up.
The act allowlist is config-driven (`deploy/mavend.json` seeds a homelab set:
read-only status/ps/uptime/df/free/logs, gated restart/stop/reboot).
Broadening to home automation, media or comms is JSON, not code.
---
## Execution ledger
Condensed from `ROADMAP.md` (2026-07-06). The live queue is
`20-07-2026-BACKLOG.md`; current state is `PROGRESS.md`.
| # | 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 ~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 transport** — `systemd-ask-password` over ssh vs a
passkey-authed page push for cold-start unlock. Both work; unpicked.
- **wake-word hardware** — USB mic on a client box (fast path) vs an ESP32-S3
room device (the "real" version). The code is the same either way; the
hardware changes the deploy — and it decides where an ambient reply is
spoken (speaker on the capture device vs the PWA if a session is live).
**Plumbing / deferred:**
- **passkey enrollment bootstrap** — the first credential on a fresh device,
before a passkey exists to authenticate with (trust-on-first-use gap).
- **mTLS in or out** — provisioning cost on mobile vs paranoia payoff.
Leaning optional.
- **PIN vs yubikey for LUKS** — PIN-in-head pinned for now.
- **session lifetime / re-auth cadence** — unset.
- **compound captures** — "slept 6h, fan noise wrecked it" = one fact + one
note in one utterance. Needs a second pass or it loses half.
- **query read-path** — semantic RAG vs a structured read, depending on the
ask.
- **presence — away tap override** — an explicit `away` tap as a hard
override. Clean extension, deferred; scoring stands without it.
- **presence — weights/τ hand-tuning** — first-guess numbers; expect tuning
against real signal traces.
- **presence — wg home-vs-cellular** — let wg carry more weight when clearly
home. Needs the signal to exist first.
- **listening modes 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.