# QA plan: checking Maven properly

*Last verified: 2026-08-04 @ 8d816f4. Living doc: correct it in place, do not append.*

Written 2026-08-01, after the 35-PR stack landed and the box came back up.
Refreshed 2026-08-02 against the live list, after PRs #85-#90.
Reconciled 2026-08-04 against the board, after the review stack merged.

The board holds 95 open tasks and 35 of them are `QA:` tasks. The ratio moved
because the build backlog grew, not because verification shrank. QA is
verification work, not build work, and most of it sat unverifiable while Maven
was down for 11 days. That blocker is gone.

Every open `QA:` task appears below. Distrust the count in this header first. It
is right on the day it is written and wrong a week later.

Fourteen ids this plan used to name closed on 2026-08-04 and are gone from it. If
you cannot find one, check whether it closed before assuming the plan dropped it.

This plan orders them by what unblocks what. Do sessions 1 and 2 first. Almost everything
downstream assumes the voice loop works, and nobody has confirmed that since
the redeploy.

---

## What the 02-08-2026 run found

Sessions 1, 2 and 3 all ran. Read these five before picking anything up.

- **470: a question writes invented knowledge into memory.** Recall then serves
  it back. `что дальше?` lands on `IntentFact` and stores the model's answer as a
  `self` fact at confidence 1.00. Two junk rows then claimed seven unrelated
  world questions through recall, outranking the search leg. A question about the
  capital of Australia was answered `какая последняя версия языка Go?`. Two bad
  writes silently disabled world answering, with nothing logged.
- **466: a pending clarify is global.** One unanswerable clarify swallowed the
  next three utterances from three separate sessions. With ntfy, telegram and
  voice all live, a clarify raised on web chat eats the next telegram message.
- **467: spoken task capture is dead.** The router calls the capture marker an
  `act`, and capture is reachable only from the `note` intent.
- **The classifier baseline in this repo was wrong**, and it flattered the
  router. See session 2 and **464**.
- **477: the model swap and the self-update cannot be triggered on this box.**
  Both are built and both are correct in test. The swap needs a passkey and
  WebAuthn is unconfigured. `mavupdate` needs to reach a socket that only an
  in-container uid can open.

- **479: an unconfigured capability lets the question escape to web search.**
  Netscan off, asked `какие устройства в сети?`. She answered from the live web
  with a general article about network hardware. A question about his LAN went to
  an upstream engine. The crawler fails the same way.

Twenty-one defects were filed on 02-08-2026: 462 through 482. Six tasks this plan
had written off as blocked turned out to be ready to check. All six ran. Every
one of them is code-correct and stops at the deploy.

Three of the five config blockers in **472** were then cleared. The morning
routine, ambient ingest, feeds, the crawler and netscan are all live. Two remain,
and both are the owner's call: a token for each ecosystem sibling, and seed data
in Nexus and Praxis.

---

## Before you start

Two things bite anyone running these checks on homesrv.

**curl needs `--noproxy '*'`.** The shell exports `http_proxy=http://127.0.0.1:18080`.
Without the flag, every local check returns 503 from the proxy and looks like a
dead service. This cost me a false regression report today.

**The database is not readable with sqlite3.** Four older QA steps say
`docker compose exec mavend sqlite3 /data/maven.db "select ..."`. That cannot
work: the container has no `sqlite3` binary, and the store is AES-256-GCM at
rest with a tmpfs working copy. Read state through mavweb instead, at
`/history`, `/trace`, `/routines` and `/dash`.

---

## Session 1: the voice loop (half a day)

Nothing here has been confirmed since the redeploy, and everything else assumes
it works. Do this first.

Closes or advances: **44** (conversation), **45** (text chat), **287** (voice
session quality), **321** steps 3-5 (quiet mode), **288** (STT golden audio).

**288 is not blocked.** The fixtures are committed under `cmd/mavsttd/testdata/`
and `make test-stt-golden` runs today. This plan said otherwise until 02-08-2026.

Steps 1 and 3-6 were run on 02-08-2026 and pass.

**Step 2 no longer needs a person, and step 9 has a number now** (04-08-2026).
`POST /api/ptt` takes raw PCM16 16kHz mono and answers with audio plus an
`X-Reply-Text` header, so the committed STT fixtures stand in for a microphone:

```sh
tail -c +45 cmd/mavsttd/testdata/ru_query.wav > /tmp/q.pcm
curl -s --noproxy '*' -D /tmp/h -o /tmp/reply.pcm -X POST \
  http://127.0.0.1:9201/api/ptt --data-binary @/tmp/q.pcm \
  -H 'Content-Type: application/octet-stream' -m 180
```

That covers audio in → STT → router → phrasing → TTS audio out. It leaves only
browser microphone capture needing a person, and the wake path needing a machine.
Do not post `en_act.wav` without deciding first: it is a mutating act.

**Steps 7 and 8 still cannot run, but 15 is no longer the reason** (04-08-2026).
The desk presence poster is installed on workpc. It is a `maven-desk` systemd
user timer on a 60s cadence, gated by hypridle at 120s idle. `desk_active` facts
now arrive, and the first landed at 18:43.

What blocks the two steps now is that no rule wants to fire. `/trace` shows all
five at `predicate`, none inert:

| rule | sev | why it is false |
|---|---|---|
| water | 1 | needs ≥3h since the last `water` fact; step 2's `ru_fact` wrote one |
| meal | 1 | needs ≥6h since a `meal` fact; none exists |
| break | 2 | needs both `desk_active` and a `break` fact; `break` has never been written |
| service_down | 4 | no kuma monitor is down |
| netdata_critical | 3 | nothing critical |

So the honest way to run step 8 is to wait three hours after the last `water`
fact, or to write one antedated. Do not read the water rule's silence as a defect.

**The sev4 telegram reach works** (04-08-2026). Resuming a paused kuma monitor
for paperless, which is genuinely down, put a real `service_down` through the
whole path with presence away:

```
23:03 voicesink: no live voice session for service_down, falling through to away channels
/notifications: 19:03 | service_down | telegram | pending | Сервис перестал отвечать.
                04.08 23:03 | nudge | service_down | telegram | sent | 23:03
```

`ChannelsFor(Sev4, Away)` returned telegram, the send succeeded, and the row
holds at `pending` because sev4 repeats until acked. The 15:51 row shows the
same rule reaching `acted` earlier, so the ack path works too.

The body was `Сервис перестал отвечать.`, which names no service. That is a bug
and it is deterministic, filed as **534**. `nudgeValues` fills `{service}` from
`State.Fact("service_down")`, an exact key mavpoll stopped writing when
per-monitor facts landed. Nine of the ten templates carry `{service}`, so all
nine are rejected as unfillable. The one nameless variant is left as the only
usable one, every time. The stub and LLM phrasers both call `loop.DownServices`
and get it right. The template path is the one that runs.

**Presence itself has a real defect, filed as 532.** `SavePresenceState` has no
caller outside tests, so the singleton row is never written. The gate is fine,
because it reads the bucket `GatherState` computes in memory each tick. Two
things follow. Hysteresis is dead, because `lastBucket` is always cold-start `Away`
and the 0.30-0.55 hold band never applies. And every presence readout lies:
`/dash` shows `away — score 0.00 (never)` with fresh `desk_active` facts arriving
every 60s. Do not trust that number while checking anything else here.

Steps 1 and 3-6 do not need a browser. `POST /api/chat` takes a form-encoded
`text=` field and a cookie jar, and answers with the rendered `/chat` page:

```sh
curl -s --noproxy '*' -c jar -b jar -L -X POST \
  http://127.0.0.1:9201/api/chat --data-urlencode 'text=привет'
```

Parse the whole page, not the last text node. The page carries nav and footer
text. A naive tail of the Cyrillic nodes returns the wrong string, which makes
turns look misaligned when they are not.

1. Open `http://127.0.0.1:9201/chat` and hold a short conversation in Russian.
   Watch for three things: she answers in feminine forms (`рада`, `поняла`), she
   says `ты` and never `вы`, and no pet names appear.
   **Passes** (02-08-2026, five turns): `я рада`, `поняла`, `помогла`,
   `проверила`, `записала`, `грустна`, `ты` throughout, no pet names.
2. Press push-to-talk on `/dash`. Say `привет`. Confirm a spoken reply comes
   back. This covers browser mic to STT to core to TTS as one path. It does
   **not** cover the wake word or the voice-activity gate, and no step here
   does — see below.
   **Passes below the browser** (04-08-2026, three fixtures through `/api/ptt`):
   HTTP 200, `audio/l16;rate=16000;channels=1`, and real speech back. `ru_query`
   answered `на 04.08.2026 ничего нет.` in 3.82s of audio at RMS 3865, `ru_fact`
   answered `отметила: water = выпил`, `ru_reminder` answered `хорошо, напомню.`
   at `intent=reminder`.
   **Passes in the browser too** (04-08-2026), and it needed no person. Headless
   Chrome takes a fake microphone, so the whole browser half runs unattended:

   ```sh
   chrome --headless=new --remote-debugging-port=9333 --remote-allow-origins='*' \
     --use-fake-device-for-media-stream --use-fake-ui-for-media-stream \
     --use-file-for-fake-audio-capture=cmd/mavsttd/testdata/ru_query.wav%noloop
   ```

   Then drive it over the debug protocol: click `#btn`, wait, click again, read
   `#status` and `#log`. That covers `getUserMedia`, `MediaRecorder`, the webm
   decode and the hand-written resample to 16k Int16. It logged
   `sending 188160 bytes`, which is 5.88s at 16k mono, and got the reply back.

   **The button is on `/`, not `/dash`.** `handleVoice` serves it at the root
   (`main.go:332`). `/dash` is the presence and fact dashboard and carries no
   `#btn`. This step said `/dash` until 04-08-2026.

   One defect fell out, filed as **533**. The reply logged as
   `на+04.08.2026+ничего+нет.` The header is escaped with `url.QueryEscape`,
   which writes a space as `+`, then decoded with `decodeURIComponent`, which
   leaves `+` alone. Transcript only, the audio is fine.
3. Say `тихий режим`. Expect `тихий режим включён. буду реже напоминать.` **Passes.**
4. Say `выключи тихий режим`. Expect `тихий режим выключен.` Negation must win. **Passes.**
5. Say `в комнате тихо`. Quiet mode must NOT flip. Confirm on `/history` that no
   `quiet_hours` fact was written. **Passes**: no row written. She answers `пока
   не умею отвечать на этот вопрос.`, so it lands on `IntentSystem` with no arm.
6. Say `включи режим тишины`, then `сделай потише`. Both must flip quiet mode
   on. These are the noun form and the comparative, added 01-08-2026. **Both pass.**
7. Wait for a nudge, then say `потом` within twenty minutes. Expect `хорошо,
   вернусь к этому позже.` and the nudge row on `/notifications` reading
   `snoozed`. Say `потом` again with nothing pending: it must route as an
   ordinary utterance, not be swallowed.
8. Wait for the water nudge, then say `выпил воды`. Expect the ordinary fact
   reply and nothing extra. She must not congratulate you. Check
   `/notifications`: the row reads `acted`. Then trigger another nudge and say
   `готово`. Expect `отлично, отметила.` and the same outcome.
9. Note anything where she is slow, cuts off, or talks over herself. That is
   287's whole content and it has no written acceptance criteria yet.
   **First evidence, in text** (02-08-2026): nothing breaks, but answers wander
   and stitch unrelated topics. Asked whether he should move flats, she opened
   with the weather. That is 287, and it is a phrasing problem, not a loop problem.
   **The slowness now has a cause and a number** (04-08-2026). A spoken turn
   takes 32 to 34 seconds. One phrasing call is 30.0s of that. STT is 1.0s
   and routing is under 10ms. Both interactive calls decoded exactly 512 tokens,
   which is the phrasing cap. Both were truncated, to produce a reply of
   under 25 characters.
   The cause is `responseGrammar`, not the model. Its last rule is
   `ws ::= [ \t\n]*`, and `*` is unbounded, so the model emits `{` and then
   satisfies `ws` with whitespace until `max_tokens` stops it. Reproduced on a
   second server: at `repeat_penalty` 1.0 it runs to 512 and returns
   `finish_reason=length`, at 1.3 it stops at 24. Bounding the rule to
   `[ \t\n]{0,4}` gives a clean stop at 33 tokens three times out of three with
   no penalty at all.
   Only some callers are exposed. `internal/llm.Req` sends `repeat_penalty` and
   the replier sets it to 1.3, so that path is protected by accident. `chatReq`
   in the phraser sends no penalty, so `PhraseChat`, `PhraseQuery`,
   `PhraseNudge` and `PhraseReminder` all run at the default 1.0. Filed as
   **531**.
   Two guesses were wrong on the way and are recorded so nobody repeats them.
   It is not reasoning tokens: the probe returned `reasoning_content` of length
   0, and the grammar constrains output from the first token. It is not the
   `--cache-ram 512` limit either: that is MiB of prompt cache and the 512 that
   was hit is a token count.
   The wandering is a second thing and stays on 287.

**The wake path cannot be checked here, and that is now the decision rather
than a gap.** `mavwaked` and `mavenclient` appear in no compose file and run as
no host process. They are not going to. They belong on a client machine in the
room he is standing in, because homesrv's microphone is real and in the wrong
room — **463**, written up in `docs/plans/17-where-the-voice-loop-runs.md`.

So the wake word and the VAD gate are covered by their unit tests and by
nothing else, and no session at this box changes that. Checking them needs a
machine with a microphone running both binaries against a TCP-listening mavend.
`ipc.Dial` already speaks `tcp://host:port?token=...`, so the work is a machine
and a config line, not protocol work. Until then, **287** can only be
half-answered, and step 2 above is push-to-talk, not the voice loop.

**319's single-token bug is fixed** (01-08-2026). Single-word Russian utterances no longer come
back as `не совсем поняла — можешь переформулировать?`. `привет` and `поужинал`
both pass now: `thinSingleToken` spares social singles and any token carrying a
verb ending, and only thins a bare nominal like `вода`. A one-word utterance that
still gets clarified in this session is a new case for the lexicon, not the old bug.

---

## Session 2: measurement (half a day, mostly waiting)

Closes or advances: **320** items 2-4, **278** (make the eval lab routine).
Also **248** (memory evaluation), **319** (the margin gate) and **323** (the
startup timeout arm).

The resident llama-server cannot be reached by the eval harness. It binds
`--host 127.0.0.1 --port 0` inside the container, so the port is kernel-assigned
and never published. Start a second one on a fixed port instead:

```sh
llama-server -m /mnt/hdd1/llms/qwen3/Qwen3-1.7B-UD-Q4_K_XL.gguf \
  --host 127.0.0.1 --port 18100 -c 4096 -ngl 99 --no-webui
```

`-c 4096` matters. The recorded numbers were measured at that context size, and
a mismatch invalidates the comparison.

Then:

```sh
make eval-models MAVEN_LLM_URL=http://127.0.0.1:18100   # want ~72.7% cascade
make eval-router                                         # classifier baseline
MAVEN_LLM_URL=http://127.0.0.1:18100 make eval-phrasing  # persona checks, slow
make eval-recall
```

A large miss against 72.7% means the deploy differs from the bench harness.

**Run on 02-08-2026 @ af9d213. The deploy matches the bench.** `eval-models`
scored 56 of 77: 72.7% full, 77.9% intent-only, 2 false clarifies and 1 missed.
That is the recorded figure to the decimal, and calendar sat at 2 of 2, so the
stage 0 agenda rules hold. `eval-phrasing` scored 21 of 27 on the talk fixture
against a recorded 20, and the 15 nudge templates passed every check.

Two numbers in this repo were wrong, and both flattered the resident model.

- **The classifier is not 36.8% and not 31ms.** `make eval-router` reports
  `classifier+onnx: 53/77 (68.8% full)` at p50 16.6µs. The figure repeated here
  and in `CLAUDE.md` predates the stage 0 rules and the seed additions. Both now
  score inside that baseline. The accuracy gap the router buys is
  roughly 4 points, not 36. Re-argue the trade on the real numbers: **464**.
- **Router latency was measured under contention again.** p50 1.126s, p95 1.58s,
  max 3.24s, against a recorded p50 825ms. The resident model was serving the
  daemon on the same iGPU throughout. Do not record this as a regression, and do
  not record it as a measurement either. Stop the stack before timing the router.

`classifier+hash` scores 19.5%, which is the no-ONNX degraded path and is not the
failure floor the deploy uses. Do not quote it as the classifier baseline.

Then three things to decide while the numbers are in front of you:

- **319 is done.** 359 gave the LLM path a real confidence signal.
  `thinSingleToken` was narrowed on 01-08-2026, and agenda questions moved to
  stage 0. Missed clarify sits at 1 of 6 and false clarifies at 2. Item 2 point 2
  closed on 02-08-2026: the `make eval-recall` margin sweep is the distribution
  that was asked for, and `0.008` sits at the knee.

  | delta | answered | false recall |
  |---|---|---|
  | 0.005 | 18/27 | 2/5 |
  | **0.008** | **18/27** | **1/5** |
  | 0.010 | 16/27 | 1/5 |

  It removes four of five false recalls at no cost in answers, and the next step
  costs two answers for nothing. The hand-picked value survives on evidence.
- **278's real ask** is making the eval lab routine rather than building it. It
  is built. Decide whether it runs on a timer, on every merge, or on demand, and
  the task can close.
- **248** is the memory evaluation loop. It ships, it writes notes, and it cannot
  speak. `make eval-recall` covers the retrieval half. The open question is whether
  a written evaluation nobody reads is worth the tick.

**323 is down to one check.** PR #90 covered the spawn path and took phraser
coverage to 76.9%. Only the 60s startup timeout arm is untested, because testing it
needs a `StartupTimeout` field on `Config` rather than a test-only hack. While you
are on the box, time a cold 1.7B load off spinning disk. If it runs near 60s, the
default is too tight and the field earns itself twice.

Warm, it is nowhere near. A second llama-server answered `/health` 1.8s after
launch at `n_ctx 4096` on 02-08-2026. That is page cache, so it does not settle
the question. A cold read needs a cache drop, which needs root.

**`CheckFeminine` has a false positive.** On 02-08-2026 it failed
`query-notes-do-not-answer` for `ты заплатил`, calling it masculine
self-reference. Masculine second person is correct, because the owner is male.
The check matches a masculine
past-tense verb before `за` without confirming the subject is `я`. Fix it in
`internal/phraser/eval/checks.go` before trusting a phrasing score to the case.
The real talk-fixture score on that run is 22 of 27, not 21. Tracked as **462**.

Item 4 of **320** needs a permission I do not have. Kill the `llama-server`
pid under `maven-mavend-1`, post a turn, and confirm it still completes
through the classifier. Either grant it or run it yourself. It is the only
check that the failure floor catches a mid-session model death.

---

## Session 3: the interaction batch (a day, or five sittings)

These need real use rather than a command, grouped by what one sitting covers.

**Morning and delivery** (**280**, **281**, **128**, **283**, **285**):
open `/morning`, walk the seven required behaviours, then check the four
interruption outcomes and the digest gap. The presence half of this sitting
cannot run. `desk-active.sh` is on workpc, but no systemd user unit enables it,
so no `desk_active` fact has ever been written. That is **15** and needs you at
that machine.
**283** is the event intake envelope every reach shares, so a delivery check
exercises it whether you name it or not. **285** is not verification: the bridge
framework works and the remaining ask is more adapters. Decide which reach comes
next, or park it.

Run 02-08-2026. **280 is blocked.** No morning routine is configured (**472**).
`morning.Item` also has no required-versus-optional field, so behaviour 1 cannot
hold whatever you configure (**473**). **281's digest gap is closed**, and
its presence rule passes on inspection. Three of its five items need traffic the
box has not had. **283 is blocked**: nothing feeds the intake journal. **128
found the worst defect of the whole session, see below.**

Three of 472's five blockers were cleared the same day, in `deploy/mavend.json`
and `docker-compose.yml`.

- A `morning_routines` block, one routine `утро` 08:00-11:00 with medicine,
  water and pets. It is live: the dispatcher logged `dropped morning:утро (sev1,
  presence=away)`, so the plan builds and the nudge is proposed. 280's
  behaviours and 128 step 11 are checkable now. 473 still stands.
- `-ambient-token` on mavweb, value in a gitignored `/.env` that docker compose
  reads for interpolation. `/api/ambient` answers 401 without the token and 201
  with it, storing `calendar_event_20260802_Standup`. 283 step 5 and 128 step 8
  are unblocked. The token is a flag, so it shows in `ps` inside that container.
  The zenmoney and IMAP secrets are read from files instead. Ingest also
  reads the notification's wall clock as UTC and stores a 14:30 meeting at 18:30
  (**482**).
- `feeds` (two sources), `crawl.on_demand` and `netscan.enabled`. The intake
  journal now fills: `/events` holds `scan:lan` and `ambient:notif` rows.

Two are not mine to clear. No sibling has a `token` in `deploy/mavend.json`, so
273 steps 6 and 8 need a credential decision. Nexus has no entities and Praxis no
attention items, so 272 step 3 needs seed data whose content is the owner's call.

For **285**, two facts bear on the choice. Synapse is already running on this box
and healthy, so a Matrix reach has a live target and needs no new service. And
mavweb is already a PWA with a service worker, which 285 itself calls the highest
value adapter left. Today's reaches are ntfy, telegram and voice.

**Query sources** (**258**, **286**): ask her something the RSS feeds answer and
something only a ZIM answers, with the search block on. Live search leads and the
ZIMs are the fallback since 02-08-2026. **286**'s remaining half is doc and
git ingestion, which is build work, not a check.

**Do not read `/trace` for this.** `/trace` is the nudge-rule trace: rule,
severity, predicate, gate, selected. No query-source field exists anywhere in the
codebase. The only evidence of which query source claimed a turn is the
`voice: search:` and `voice: kiwix:` lines in `docker compose logs mavend`
(`actions_query.go:589` and `:660`).

Run 02-08-2026, 20 turns. **Search leads and the personal boundary holds.** Every
world question that reached the boundary was claimed by search. All three
personal questions produced no search and no kiwix line at all.

The rest of this sitting went badly. **Kiwix has zero live coverage.** SearXNG
returns four results for everything, including two invented nonsense terms. So
`querySearch` always claims, and Kiwix is unreachable code as deployed. The ZIM
half of the 02-08-2026 decision is unverified. A ZIM answer cannot signal a
silent search failure, because a ZIM answer cannot happen.
**Ordering defects** in feeds and calendar, plus 258 step 1's utterance not
working: **474**. And the sitting independently found stage 2 of **470**.

**Tasks and calendar** (**129**, **130**, **127**, **126**, **246**): capture a
task by voice, confirm it lands, check prioritisation ordering is not nonsense.
**246** (mail reader) also exercises the `IngestMail` rung that moved to
`AuthWrite` this morning.

Run 02-08-2026. **129 passes.** The page and the spoken answer agree on ordering.
The undistinguished task carries no invented reason on either surface, which is
the thing 129 asks for. **130 fails outright** and **127 half fails**:
**467**, **469**. **246 cannot be run**: `mavmaild` is commented out in
`docker-compose.yml` and there is no `email` block, so nothing in steps 4-13 is
reachable. The `IngestMail` rung does sit at `AuthWrite`
(`internal/auth/policy.go:96`, asserted in `auth_test.go:421`), verified by
reading only.

**Routines and patterns** (**43**, **46**, **247**, **254**): these need history
to detect against. If the database is thin after the outage, they may have
nothing to propose, which is not a failure. Check `/routines` before
concluding anything.

Run 02-08-2026. The answer is the middle case: **the detector ran and found
nothing.** The tick loop is live, and `detectPatterns` is called unconditionally
at `cmd/mavend/tick.go:227`. It has run about 25 times since the restart. It
finds nothing because the events table is empty upstream of it. Rows land there
only from `pattern.Extract` at fact-write time, and `Extract` requires the fact
value to match a closed 7-action lexicon. All 200 facts on `/history` are
`page_heartbeat`, `netdata_alarm`, `quiet_hours`, `name`, `service_down` and
`рост`. Not one lexicon hit, so no event can exist, let alone the four one pair
needs. **46 step 5 passes**: `/routines` renders `noticed 0` with the empty state
and the hint string.

Two things block this sitting, and both are build work. The seeding recipe on
**43** goes through `sqlite3` and cannot work. And `pattern.Detect` has no
minimum-interval floor, so seeding by hand mints a permanent false routine
(**468**). Do not try to seed a pattern with four fast chat turns.

**Ecosystem** (**272**, **273**, **276**): nexus, hexis and praxis are wired and
logged clean at boot.

Run 02-08-2026, read-only half. All three answer `/health` 200 and `/ecosystem`
lists 18 Hexis capabilities with correct read-only and mutating badges. **272 and
273 are blocked on empty data**, not on code. Nexus holds no entities, Praxis
holds no attention items, and the Calls panel has never recorded a call. See
**472**, and read its warning first. 273's trace fix has never been validated
here. An empty Calls panel is exactly what the old bug looked like. The page is
`/ecosystem`, not `/siblings`.

**276 ran 02-08-2026 and the suite is sound.** 17 `TestEcosystem_` cases pass
under `-race`, not the 10 the task describes. The mutation check bites: patching
the Nexus-error branch of `handleHexisAct` to `return ""` fails
`TestEcosystem_MalformedNexusResponseFailsClosed` on the expected line.

Steps 4 and 6 could not be checked through chat, because no utterance reaches
Praxis (**475**). «что требует внимания» routes to `intent=query` and is answered
by the search leg, identically whether `ecosystem-praxis-1` is up or stopped. The
degraded string never appears because its branch is never entered. Step 5 is
blocked the same way: `перезапусти muzick indexer` clarifies on
`HasFn:false`, and the router had already rewritten the entity name to
`музик индексер` (**476**).

Both steps were checked on `/ecosystem` instead, which reads Praxis directly.
With Praxis stopped the card reads `praxis — unreachable` while Nexus and Hexis
keep rendering. On `docker start` the card returns to `nothing needs attention.`
with no mavend restart. Independent degradation and recovery both hold.

**Workstation offload** (**492**): never run, and added to this plan on
2026-08-04. It covers **485**, which shipped in PR #97. Three states, one rule:
silent when the workstation would only do the job better, named when the resident
model cannot do the job at all.

1. **Card free.** mavgpud 200, llama-server holding gemma-4-12b. A routing turn
   and a phrased reply both complete through the workstation. Confirm that from
   the mavgpud request log, not from the answer sounding good. Nothing in the
   answer says where it was phrased.
2. **Card held.** Start a training run so mavgpud yields and answers 503. The same
   turns complete on Qwen3-1.7B with no mention of the fallback. Then kill the
   card mid-utterance, with a request in flight. That is the case no unit test
   reaches and the one most likely to hang.
3. **Machine asleep.** Suspend workpc. It must be indistinguishable from held.
   Bring it back and confirm the prober re-admits it inside one 15s interval, with
   no mavend restart.

Two things are likely wrong. A remote that accepts the connection and then never
answers is worse than a 503. `timeout` is 90s, so measure what a turn waits. And
two models mean two prompt renderings: `check_prompt_parity.py` guards Go against
the relabelling prompt, not gemma against Qwen, so confirm `{"response","mood"}`
parses from both.

**workpc is running training as of 2026-08-04**, so the held state is available
today and the free state is not. Run step 2 first, out of order.

Write down one number at the end. Read the mavgpud journal and record the
fraction of a working week the card is free. That is what **488** left open, and
it decides whether the offload is worth carrying.

**Operations** (**249**, **250**): both ran 02-08-2026. The code is correct and
neither lever can be pulled on this box. See **477**.

**250** passes steps 1, 2, 3, 9 and 10 on the deploy. The capability announces
itself. `/models` names the model llama-server reports, not the config filename.
Asking her to switch models does nothing. Removing `swap_models` renders `swap
not configured`. Step 4's refusal half passes at HTTP 403, and the 403 comes from
mavend rather than mavweb. WebAuthn is unconfigured, so the web gate fails open
and the wire gate fails closed. Steps 5 to 8 need a passkey assertion nothing on
this box can produce. They pass in test: 13 swap cases and 7 page cases covering
drain, mid-swap refusal, rollback, failed rollback and the not-owned refusal.

**249** passes steps 1 and 2. Step 3 stops it. `mavupdate` health-checks over
`/run/maven/mavend.sock`, which is `srw------- 1 10001 999` inside a docker
volume. The host owner cannot traverse `/var/lib/docker/volumes` and cannot
connect to a socket owned by an in-container uid. `mavupdate` assumes a
host-installed daemon and the deploy is containers. Do not sudo around this.

---

## Housekeeping (done 02-08-2026, and this section was mostly wrong)

This section claimed eleven tasks were not verification work. **Three were not.
The other eight are.** Every one of the eight has shipped, tested code behind it.
The error ran one way: it wrote off work that is ready to check. Do not trust a
"nothing is built" line in this plan without grepping for the package first.

Relabelled to `Blocked:`, claim verified:

- **125** zenmoney. `internal/zenmoney/` ships and is tested against a fixture.
  `deploy/zenmoney.token` does not exist and the compose mount is commented out.
  One token unblocks it.
- **256** Home Assistant. `internal/smarthome/` ships, the `smarthome` block sits
  in `deploy/mavend.json` at `enabled: false`, and 8123 and 1883 are closed.
- **14** cold-start unlock. The seam is real at `cmd/mavend/main.go:128` and
  `internal/webauthn/prf.go` is in place. `lockedAPI` is gone, replaced by
  `Server.Check` in `internal/ipc/server.go`. Gated on an authenticator that
  implements the WebAuthn PRF extension, which is hardware, not code.

Left alone, because the claim here was false:

- **284** simulator. `cmd/mavend/simulator_test.go`, three scenarios under
  `cmd/mavend/testdata/scenarios/`, and a `simulate` target at `Makefile:98`.
  **Run 02-08-2026: all three scenarios pass**, plus the determinism and
  backwards-step guards. One defect found, see below.
- **288** STT golden audio. Four WAVs and `golden_v1.json` are committed under
  `cmd/mavsttd/testdata/`, the make targets exist, and `models/stt/ggml-small.bin`
  is on the box. Session 1 lists 288 as blocked on fixtures, which is wrong.
  **Run 02-08-2026: all four pass**, WER at or under ceiling with no drift.

| fixture | transcript | WER | ceiling |
|---|---|---|---|
| ru_reminder | `Напомни мне через час позвонить маме.` | 0.00 | 0.10 |
| ru_fact | `А отметь, что я выпил воды.` | 0.20 | 0.25 |
| ru_query | `Что у меня сегодня по календарю?` | 0.00 | 0.10 |
| en_act | `Restart the web server and check the disk space.` | 0.00 | 0.10 |

That also settles a session 1 worry indirectly: whisper.cpp works on Vulkan
after the redeploy. Only the mic and the wake path remain unproven.

**The simulator routes with an empty seed set.** Every `make simulate` run logs
`loaded 0 seed examples from models/seeds`, seven times per scenario. The test
runs from `cmd/mavend`, and the seed path is relative to the repo root. The
scenarios still pass, which means they pass without the classifier having any
seeds to match against. Whatever 284 is proving, it is not proving the routing
the deploy runs. Fix the path before trusting a green simulator.
- **257** Bluetooth. The bluez half is genuinely absent. The LAN-scan half shipped
  (`internal/netscan/`), and steps 1-9 run today. Only step 10 is Bluetooth, so
  relabelling the whole task would bury real pending work.
- **251** MCP, **253** hearing, **259** crawler. All three ship
  (`internal/mcp/`, `internal/capture/`, `internal/crawl/`) with no external gate.
  Fully checkable. `259`'s step 1 wants no `crawl` block in `deploy/mavend.json`,
  and there is none, so it is already set up correctly.
- **252** vision and **255** speaker recognition. Both ship. Each is blocked only
  on a model download: a vision gguf with mmproj, and a speaker embedding model.
  Neither is present under `/mnt/hdd1`. Their refusal-path steps run today.

So the honest split is three blocked on a credential or hardware, two blocked on
a download, and six ready to check. That is roughly a session of real QA this
plan had written off as backlog.

**All six ran on 02-08-2026.** Every one of them is code-correct and stops at the
deploy. The pattern repeats often enough to be the headline: the packages pass,
and the box cannot reach them.

**251, MCP.** Steps 1, 2, 3, 4 and 13 pass. Package tests green under `-race`.
Off-by-default is clean, and the SSRF refusal is exact: without `allow_private`
the log reads `refusing to connect to a private address: 127.0.0.1` and `/tools`
shows the server down with zero proposals. Steps 5 to 12 are blocked. `ss -lntp`
shows the Vikunja MCP server on `127.0.0.1:9100` only, so no container reaches it
at any address (**478**). `allow_private` does work, measured both ways.

**253, hearing.** Steps 1, 2 and 17 pass. `internal/capture` covers 90.3%. Steps
7 to 16 are blocked on something nobody can work around: no shipped client calls
`CaptureStart`. There is no `cmd/mavheard`, no mavweb route, and `mavenclient`
never calls it (**480**). Two of its QA steps are also stale.

**Four stale QA steps were rewritten on 04-08-2026** under **480**, against the
code rather than against what the plans said. All four failed the same way: the
daemon was right and the step described an older daemon.

| Step | Said | Says now |
|---|---|---|
| 253/3 | boots with the methods unknown | refuses to boot, `config.go:1651` |
| 253/10 | no `:transcript` note by default | true only with a summary present |
| 255/5 | `speaker: enrolment on, recognition BLOCKED` | that line is gone, the capability stays off |
| 252/3 | `vision: stored image <id-prefix>` | `vision: stored <id>`, `vision.go:199` |

Two of them are worth reading past the correction. 253/10 was false in exactly
the scenario 253/16 creates, because `writeNotes` saves the transcript whenever
the summary is empty so a dead llama-server does not lose the meeting. And 255/5
changed because `Recognizes()` was written as the gate, documented as one, and
never called — calling it turned `enabled` with no model from a half-working
capability into a refusal. Enrolling into a store nothing can match against is
not a working half.

**257, netscan.** Steps 2, 3 and 9 pass at unit level. Step 1 fails. Steps 4 to 8
need the block enabled. Step 10 is Bluetooth and stays skipped.

**259, crawler.** Steps 1 and 15 pass. Step 2 fails. Steps 3 to 14 need a `crawl`
block that nobody has written.

Both were configured later the same day, and both work. `netscan.enabled: true`
answers `какие устройства в сети?` with `нашла 3 устройства, из них 2 с вебом, 2 с
ssh. список записала.` and the scan lands in the intake journal as `scan:lan`.
`crawl.on_demand: true` answers `посмотри https://lwn.net — что там пишут?` from
the real page. So **479** is one defect, not the routing defect it was filed as.
An unconfigured capability declines its own turn instead of naming the gap.
Nothing is wrong with the routing.

257 step 1 and 259 step 2 fail the same way and share a task (**479**). An
unconfigured capability does not name the gap, so the question escapes to web
search. `какие устройства в сети?` was answered with a general article about
network hardware. That is his LAN going to an upstream engine.

**252 vision and 255 speaker.** Both confirmed blocked. The disk claim was
re-verified rather than taken on trust: 16 text-only ggufs under `/mnt/hdd1`, no
mmproj and no speaker embedding model. Everything not needing the model passes,
including the two refusals that matter. `TestNewLocalRefusesNonPrivateEndpoints`
rejects `https://api.openai.com`, and forget really deletes
(`internal/store/memory.go:145` is a real `DELETE`, not a tombstone). Vision is
19/19, speaker 22/22, media 16/16.

**470 got worse, then closed.** Both poisoned facts showed `voided` on
`/history` and the defect survived. Re-measured at 15:42, after four restarts:
`почему небо синее?` still answered `какая последняя версия языка Go?` with no
`search:` line. What came back was the question he typed, not the value the fact
held. So the poison was a vector in the memory index, and `revert` did not
remove it.

Repaired in two parts. 470 stopped the writes: a question is never a fact, and a
void drops the key's vectors. 493 fixed what the index holds. A fact is indexed
as the fact and not as the utterance, and a correction drops its superseded
vector too.

A poisoned box now repairs itself on the next start. `RepairFactVectors`
re-embeds every fact vector from the fact it names, and deletes the voided and
superseded ones. It runs once, guarded by a marker, and logs what it did.

---

## Needs you specifically

Not QA. These are blocked on a decision or a credential only you have.

| # | what |
|---|---|
| 16 | Create the Kuma API key. `-kuma-key uk5_mavpoll-key` in `docker-compose.yml` is still the placeholder. |
| 15 | Enable the `desk_active` units on workpc. The script is there; the timer is `not-found`, so the strongest presence signal writes nothing. Blocks the presence half of session 3. |
| 122 | Finish the CPT run for Qwen3-1.7B. The persona fix depends on it. |
| 355 | Deploy the Hexis auth change. Was blocked on Maven being under construction, which it no longer is. The client half is vendored and wired. |
| 357 | Decide whether entity-existence validation is the permanent target guard or whether blessing lands in Nexus. |
| 275 | Hexis native API and MCP parity. |
| — | Decide on `-require-stepup`. Making it the default needs WebAuthn configured first, or it locks you out of your own admin surfaces. |

317 and 354 closed on 01-08-2026. The step-up gate now covers `POST /api/chat` and
`/routines`, and the nginx template is locked down with a `maven.<domain>` block for
mavweb. The `-require-stepup` default is still your call.

---

## Not this repo

Two open tasks sit on the Maven board and are not Maven work. Move them or note
where they land, so the board stops reading as 50 things Maven owes.

- **358** replace the rowid execution cursor with a real seq column. This is Hexis,
  and it must land before any execution retention or pruning does.
- **362** mirror the router prompt reorder into the relabelling prompt. This is the
  training workspace, enforced by `llm/check_prompt_parity.py` there, not here.

---

## Suggested order

1. Session 1. If the voice loop is broken, nothing else matters.
2. The `-require-stepup` and Kuma decisions. Five minutes, and it unblocks **16**.
3. Session 2. **Run on 02-08-2026.** The numbers came back worse for the router
   than the docs claimed. The classifier is 68.8%, not 36.8%, and 16.6µs, not
   31ms. The router buys about 4 points of accuracy for four orders of magnitude
   of latency. Whether that still earns its place is now an open question.
4. Housekeeping. Cheap, and it makes the remaining backlog honest.
5. Session 3, split whichever way suits you. Five of its six sittings ran on
   02-08-2026. Read the per-sitting notes before repeating any of them.
6. The workstation offload sitting (**492**), which has never run. It is last
   because it is newest, not because it matters least. It is the one sitting whose
   subject changes state on its own.

The next thing to fix is not in this plan. Four defects say the same sentence:
a capability is built and no utterance reaches it. **466** (a clarify is global),
**467** (capture is act-routed), **475** (attention is act-routed), **476** (the
router rewrites entity names). Routing is where the work is.
