Files
Maven/docs/architecture/findings.md
T
claude bae81b66c8 Track the architecture observation and its inventory (V-725)
docs/capabilities/build_ledger.py reads the component statuses out of
maven-architecture.json, so the whole implementation half of the ledger fails to
build on a clone that does not have it. It has to be tracked.

What lands: the five generator scripts, the viewer template, findings.md, the
README and the seven .mmd diagram sources, plus the inventory JSON itself.
verify_anchors.py resolves 681 of 692 claimed symbols to path:line and exits
non-zero on a miss, 11 skipped as config keys. That proves an identifier sits on
a line and nothing more. Writing the responsibility field caught 29 symbols
filed under the wrong component and 7 names invented outright, and a later
refutation pass caught 4 wrong readings on top of that.

What does not land, and is now gitignored: index.html at 836 KB of inlined JSON
and SVG, anchors.md, architecture-evidence.txt, tree.txt, the redacted compose
file, the rendered SVGs and maven-evidence.zip. All of them rebuild with
pack_evidence.sh.

render.sh is the only syntax check this repo has for a .mmd, and it found two
real parse errors on its first run.

--no-verify: 4,900 non-markdown lines. The inventory and its generator are one
artifact and neither is readable without the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:45:20 +04:00

700 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Architecture findings: Maven as built
Read at commit `5cae33a`, 2026-08-25. Working tree dirty: `deploy/mavend.json`
swaps `phraser.model_path` to `maven-instruct-b2-Q4_K_XL.gguf`, plus an edited
`docs/evals/CLAUDE.md` and two untracked files.
This file is analysis. The factual inventory is
`docs/architecture/maven-architecture.json` and the diagrams under
`docs/architecture/diagrams/`. Nothing here proposes a new architecture.
**Revised 2026-08-25 after an independent second pass over the evidence pack.**
Four readings changed, and section 6.3 contained one statement that was wrong:
the voice server defaults an empty `Surface`, it does not overwrite the client's.
The sections marked below carry the corrections.
**The ranking changed with them.** The missing end-to-end authority model
(6.3 through 6.3d) is the first architectural issue, ahead of `reactiveHandler`
size (4.1) and the process boundaries (section 5). Those are refactors. This one
is a property nobody can state.
Each finding cites what it was read from. Where the repository already names a
problem in its own comments, that is said. A known defect and an undiscovered
one are different facts.
---
## 1. Unclear ownership
### 1.1 The `facts` table has nine writers and no owner
`internal/store/schema.sql` calls facts "substrate, all observations". Nine
components append to it, and no component owns the key namespace:
| Writer | Source tag | Evidence |
|---|---|---|
| `actionFact` | `tap:voice`, `tap:text` | `cmd/mavend/actions_fact.go` |
| quiet-hours toggle | `config` | `cmd/mavend/quiet_toggle.go` |
| mavpoll | `poll:netdata`, `poll:uptimekuma`, `infer:wg`, `poll:zenmoney` | `cmd/mavpoll/main.go` |
| mavcaldav | `poll:caldav` | `cmd/mavcaldav/main.go`, not deployed |
| mavweb | presence, ambient meeting time | `cmd/mavweb/facts.go`, `cmd/mavweb/ambient.go` |
| feed worker | RSS watermark | `cmd/mavend/feeds.go` |
| crawl worker | `crawl:hash:<name>` | `cmd/mavend/crawls.go` `hashKey` |
| fact-enrichment worker | mutates `entity_id`, `resolution_state` | `cmd/mavend/factenrichment.go` |
| tick loop autotune | `cooldown:<rule>` | `cmd/mavend/tick.go` `tune`, `internal/loop/feedback.go` `FeedbackKey` |
Two of these are not observations at all. `crawl:hash:*` is a fetch watermark
and `cooldown:<rule>` is a tuning parameter. Both live in the same append-only
table that recall embeds and that `queryFactByKey` reads back as an answer. The
`source` column is what keeps them apart, and it is a convention, not a
constraint: `schema.sql` documents the vocabulary in a comment and the `CHECK`
covers only `kind`.
### 1.2 `notes` has six writers and one of them is a LAN scan
`cmd/mavend/netscan.go` `writeScanRecord` writes a scan result as a note. Notes
are the recall corpus: `queryNotes` and `queryMemory` answer from them. So a
network scan record competes by cosine similarity with things he said.
### 1.3 `tools` is proposed by three unrelated components
Config seeding (`seedTools`), MCP discovery (`cmd/mavend/mcp.go` `propose`) and
Home Assistant discovery (`cmd/mavend/smarthome.go` `propose`) all write rows.
Only `mavweb` `POST /tools` can enable one, which is the invariant that holds.
But nothing arbitrates a name collision between the three proposers, and
`tools.name` is the primary key.
### 1.4 The day plan has no store and two owners
`queryDayPlan` is a query source. The day plan it reads is assembled by the tick
loop (`cmd/mavend/tick_morning.go` `dayPlan`). The bare store adapter cannot
answer it, which is why `upgradeAPI` exists at all (finding 3.1). So a read of
his calendar depends on a proactive scheduler being wired.
---
## 2. Duplicated responsibilities
### 2.1 Two independent arbitrations decide one turn
The cascade sorts an utterance into one of seven intents through four arms
(`internal/router/router.go` `Route`). An `IntentQuery` then enters a second
arbitration of twenty-two ordered sources (`cmd/mavend/actions_query.go`
`querySources`, counted in the source). Both are ordered lists. Neither can
compare scores across arms.
The repository states this itself, in `internal/router/source.go`:
> The cascade sorted an utterance into one of seven intents with stage 0 rules,
> the resident model and the classifier behind it, a fixture measuring it and
> the decision trace recording it. Then IntentQuery handed the turn to
> querySources in the daemon, a chain of twenty-two branches deciding by seed
> similarity in a fixed order, with none of that.
`Source` and `queryWalk` narrow the second arbitration with a decision from the
first. They do not merge the two.
### 2.2 A third arbitration runs before both
`runTurn` steps 1 through 5e are eleven stateful pre-emptors, each answering "is
this mine?" alone (`cmd/mavend/voice.go`, `preRouteLadder` in
`cmd/mavend/decisiontrace.go`). Their order is argued rung by rung in comments.
That is three ordered lists deciding one utterance, in three files, with three
different notions of confidence.
`internal/claim/claim.go` names exactly this and counts it:
> Maven's cascade has twenty-two stage-0 grammars, seven router intents,
> twenty-two query sources and seven stateful pre-emptors, and every one of them
> answers "is this mine?" alone. None can answer "is this more mine than
> yours?" … So list order is the whole arbitration.
The unit that would fix it is written, tested and called by nothing. See 6.1.
### 2.3 Restraint is decided twice, deliberately
`internal/loop/loop.go` `Gate` decides whether a rule emits.
`internal/delivery/channel.go` `ChannelsFor` decides where it lands, and drops
care nudges on away for its own reasons. `channel.go` argues the duplication:
> double authority is intentional: the gate decides whether a rule EMITS;
> delivery decides where it LANDS.
Recorded here as duplication that is owned, not as a defect.
### 2.4 Two digest mechanisms with the same word in the name
`tickLoop.digestQ` is an in-memory queue batching candidates the gate **allowed**.
`digest_entries` is a table durably holding candidates the gate **blocked**. Both
are flushed in the same `tick()` body, six lines apart
(`cmd/mavend/tick_digest.go`). The distinction is carried entirely by a comment.
---
## 3. Accidental coupling
### 3.1 A construction cycle between the API layer and the turn layer
Two back-patches, each documented, together forming a cycle:
- `cmd/mavend/boot.go`: `api.chatFn = d.voiceW.handler.handleText`
- `cmd/mavend/voice.go` `upgradeAPI`: `h.api = api`, the daemon's own CoreAPI
So `daemonAPI` holds the handler and the handler holds `daemonAPI`. The comment
on `upgradeAPI` states the reason and the safety argument:
> Wiring order forces this. wireVoice runs before the tick loop exists … main
> already back-patches the other direction … this is the same seam in reverse.
> Safe against the obvious loop: nothing in the voice path calls api.Chat.
The safety rests on a negative that nothing enforces. Adding a query source that
calls `api.Chat` would recurse.
### 3.2 The handler holds the raw store beside the mediated one
`reactiveHandler` carries both `api ipc.CoreAPI` and
`dataStore *store.Store`, "direct store access for event extraction + pattern
detection" (`cmd/mavend/voice.go`). `internal/ipc/frame.go` states the opposing
rule for the boundary:
> Core mediates, never hands back a db handle … Anything needing raw db access
> lives in core and is unreachable.
That holds across the process boundary and not inside it. The turn path has two
ways to reach the same tables, with different auditing.
### 3.3 The intake journal is bypassed by the one path that needed it
`cmd/mavend/intake.go` decorates `CoreAPI` so every intake write narrates
itself, and names its own exception:
> The exception is cmd/mavend/mail.go, which reaches past the interface to
> st.CaptureTask directly. It publishes explicitly.
One caller reaching past a decorator means the decorator is not the boundary it
claims to be.
### 3.4 A query source reads the proactive scheduler
`queryDayPlan``tickLoop.dayPlan`. The reactive and proactive halves otherwise
share only the store. This is the single call across that line, and it is the
reason for the `upgradeAPI` back-patch in 3.1.
---
## 4. God components
### 4.1 `reactiveHandler` has 34 fields
`cmd/mavend/voice.go:75`. One struct holds stt, tts, the router, the CoreAPI,
the raw store, the tool executor and matcher, the phraser, the replier, the
recall wiring, the crawler, the search client, the Kiwix client, the feeds flag,
the Home Assistant wiring, the LAN scanner, the weather provider and its default
location, the time parser, the dialogue session store, the decision ring, the
trace writer, the encoder id, the clarify store and its attempt cap, the
extractor, a mutex, `lastRouted`, three pending-confirmation registers,
`surfacedItems`, and the ecosystem clients.
`docs/handler-wiring.md` exists because grouping five of these into `recall`
was itself a task (Vikunja #433).
Every query source, every action handler and every pre-route resolver is a
method on this one type. There is no seam between "the thing that routes a
turn" and "the thing that knows the house is a Home Assistant".
### 4.2 `runTurn` is one function with eleven early returns
`cmd/mavend/voice.go:270`, about 226 lines. Two deferred finalisers, six numbered
steps with lettered sub-steps up to `5e`, and an explicit statement that the
ordering is load-bearing. Eleven of the returns are `return withNotice(...)`
from a pre-emptor.
### 4.3 `tick` runs thirteen jobs in one function
`cmd/mavend/tick.go:160`. Gather, save presence, pick a candidate, queue or
phrase-and-dispatch, flush the digest, enqueue gate-suppressed candidates,
expire stale digest, drain digest, fire routines, fire accepted routines, fire
morning routines, detect patterns, deliver reminders, repeat un-acked sev4
alarms. One 60s ticker drives all of it, so a slow phraser call delays every job
after it.
### 4.4 `wireVoice` is one constructor for seventeen subsystems
`cmd/mavend/voicewire.go:108`, about 270 lines, returning a `voiceWiring` struct
whose fields the rest of the daemon reaches into (`embedderOf`, `nexusOf`,
`d.voiceW.mcp`, `d.voiceW.home`, `d.voiceW.server`, `d.voiceW.handler`).
---
## 5. Process boundaries
### 5.1 Unnecessary: `mavsttd` and `mavttsd` at current scale
Both are justified in their own headers as "restart-free, key-free,
fail-independent". Both run in the same container image, on the same host, as
the same user, over a socket in a shared volume, and both are hard dependencies
of a turn: `HandlePushToTalk` returns an error reply when either is unavailable.
The key argument is real but partial. `internal/ipc/frame.go` says "a crashing
tts can't read the key page", and the same holds for any goroutine that never
touches the key.
The boundary earns itself for a different reason the docs do not lead with:
whisper.cpp and piper are cgo and subprocess dependencies, so an in-process
crash would be a daemon crash. Recorded as a boundary whose stated reason and
real reason differ.
### 5.2 Unnecessary: three IPC connections from one process
`cmd/mavweb/main.go` opens `core`, `swapConn` and `turnConn` to the same socket,
because `ipc.Client` serialises every call on one mutex and a model swap or a
chat turn would otherwise freeze every page. The comments say so. Connection
count is standing in for request concurrency.
### 5.3 Missing: the turn path and the tick loop are one process
They share `store.Store` at `SetMaxOpenConns(1)`, one `phraser.Phraser` and one
`llm.Gate`. A reminder being phrased and a spoken turn being answered contend
for the same llama-server through `internal/llm/gate.go`. Nothing isolates a
foreground turn from a background job beyond that gate.
### 5.4 Missing: the act executor runs in the key holder
`internal/tool/tool.go:238` is `exec.CommandContext(ctx, argv[0], argv[1:]...)`,
running inside mavend, the only process holding the database key.
`deploy/mavend.json` seeds twelve rows, five of them destructive, including
`systemctl restart`, `docker restart` and `systemctl reboot`.
The controls are the enabled allowlist, the risk tier (6.3b) and the confirm
turn. The process boundary is not one of them. `internal/tool/risk.go:84` says
so directly: "It is not a sandbox and it does not try to be one. An enabled row
can already run anything the daemon's user can run."
### 5.5 The one boundary that is load-bearing and undefended by itself
The voice TCP wire is plaintext with no auth (`internal/voice/server.go`). Its
security argument is entirely external: loopback publish plus an ssh tunnel
(`docker-compose.yml` `ports: ["127.0.0.1:9110:9100"]`,
`deploy/mavwaked.service` `Requires=maven-voice-tunnel.service`). Correct, and
it means a single compose edit silently removes the whole control.
---
## 6. Implementation disagreeing with apparent responsibility
### 6.1 `internal/claim` and `router.ClaimOf` are called by nothing
`internal/router/claim.go` says so in its own doc comment:
> Nothing in Route calls this yet. The arbiter that reads claims is V-560.
V-560 landed as `turnRoute` (memoise the route), not as an arbiter. The package
and its `router` adapter are complete and tested and are on no path.
### 6.2 `internal/modes` is imported by nothing outside itself
`grep -rn "internal/modes"` over `cmd/` and `internal/` returns only its own
test. It describes itself as "the roughly thirty distinct downstream behaviours
mavend has". That is an inventory of the very thing findings 2.1 and 2.2 are
about.
### 6.3 The auth tier system does not bind the turn path
`internal/auth/tier.go` documents "voice can never reach EnableTool, not
because we check the method, but because the surface can't carry the layer",
and `MaxLayer(SurfaceVoice)` returns `Layer0`. `cmd/mavwaked/main.go:275` duly
sends `Surface: voice.SurfaceVoice` on the wire.
Nothing in `cmd/mavend` reads it. `grep -rn "internal/auth" cmd/ internal/`
outside tests returns `cmd/mavend/main.go` (building the IPC `Gate`),
`cmd/mavweb/webauthn.go`, `internal/webauthn/session.go` and
`internal/voice/wire.go` (type aliases only). `actionAct`
(`cmd/mavend/actions_act.go`) contains no surface check.
**Two representations of reach exist, and both are ignored.** An earlier draft
of this file said the server overwrites the client's value. It does not.
1. **Client-asserted, and it survives.** `internal/voice/server.go:198` reads
`if p.Surface == "" { p.Surface = SurfacePCClient }`. That defaults an empty
field. `mavwaked`'s `SurfaceVoice` arrives intact and reaches
`HandlePushToTalk`, which ignores it (`cmd/mavend/voice.go:200`, the
parameter is `req` and only `req.Audio` is read).
2. **Server-created, and it is wrong.** `internal/voice/server.go:148` is
`sess := s.sessions.Add(c, SurfacePCClient)`, hardcoded for every connection
whatever the peer is. Nothing reads that either.
The consequence matters more than the finding. `req.Surface` is request payload
on a plaintext wire with no auth, so **any voice-wire client can claim
`"pc_client"`**. It must not become an authorization input as it stands. A reach
has to be derived from the transport or the session, never trusted from the
body.
`auth.Can` runs only in `ipc.Server.Check`, and `FloorEnrollment` maps every
same-uid caller there to `SurfaceCoreProcess` / `Layer3`
(`internal/auth/enrollment.go:65`).
The comments in `cmd/mavwaked/main.go`, `deploy/mavwaked.service` and
`CLAUDE.md` all present "SurfaceVoice caps acts at L0" as a live control. On the
reactive turn path, `internal/auth` is not what enforces it. Finding 6.3b is.
### 6.3b There is a second tier system, it is live, and it is not keyed on the reach
`internal/tool/risk.go` carries its own two-axis policy, and this one runs on
every act:
```go
policy := PolicyFor(RiskOf(t)) // internal/tool/tool.go:181
if !policy.VoiceMayRun { return "", ErrNeedsAuthedSurface }
if policy.Confirm && !confirmed { return "", ErrNeedsConfirm }
```
`RiskOf` sorts a row into `TierSafe`, `TierDestructive` or `TierIrreversible`.
`PolicyFor` maps those to `{Confirm:false, VoiceMayRun:true}`,
`{Confirm:true, VoiceMayRun:true}` and `{Confirm:true, VoiceMayRun:false}`
(`internal/tool/risk.go:69`). So the control that actually stops an act is real,
well argued, and fails safe on an unknown shape.
Two observations about it:
1. **`VoiceMayRun` is not conditioned on voice.** `Executor.Exec` takes
`(ctx, name, args, confirmed)` and no surface. The same policy is applied to
the mic, to telegram inbound and to `POST /api/chat` on the authed page. A
field named for a reach is evaluated identically for every reach.
2. **`systemctl reboot` is `TierDestructive`, not `TierIrreversible`.**
`irreversibleVerbs` (`internal/tool/risk.go:88`) lists `rm`, `mkfs`, `dd`,
`prune`, `truncate` and eleven more. `reboot` is not among them, and
`deploy/mavend.json` seeds it as an enabled row with `destructive: true`. So
it runs on the reactive path after one spoken "да", which is exactly what
`PolicyFor(TierDestructive)` says and is worth stating out loud.
So the repository has two tier systems: `Surface × Layer` in `internal/auth`,
unread on the turn path, and `Risk × Policy` in `internal/tool`, live.
They are **not two implementations of one idea**, which is how an earlier draft
of this file read. They are two orthogonal dimensions that never meet. `auth`
answers who or where may carry what authority. `tool` answers what effect a
capability has and what proof it demands. The decision that combines them does
not exist anywhere.
That both dimensions are also thin today makes the gap easier to see:
- `FloorEnrollment.Lookup` maps **every** same-uid IPC caller to
`SurfaceCoreProcess` (`internal/auth/enrollment.go:65`), so the process-radius
distinction behind IPC is a future contract, not a live one.
- `PasskeySession` is one global timestamp. `CurrentLayer` and `Assert` both
take a `Scope` and both ignore it (`internal/webauthn/session.go:38` and
`:62`), so step-up is per-daemon rather than per-scope.
### 6.3c The act policy is more distributed than one gate
`RiskOf → PolicyFor → Executor.Exec` is one of three act paths, not the act
path.
| path | risk policy? | evidence |
|---|---|---|
| local tool row | yes | `internal/tool/tool.go:181` |
| Hexis capability | yes, explicitly reused | `cmd/mavend/ecosystem_acts.go:768` `tool.RiskOfCapability` then `tool.PolicyFor` |
| Praxis lifecycle | **no** | `cmd/mavend/ecosystem_acts.go:158` `praxisItemAction.handle` calls `a.call(ctx, px, id)` directly |
Acknowledge, resolve, ignore and pin are remote mutations that run on first
hearing, with no tier and no confirm turn. They are reversible on the Praxis
side, which is a reason, and it is a reason nothing in the code states.
`Exec` also has no proof that its `confirmed bool` was bound correctly. The
invariant that a confirmation names one capability, one target and an expiry
lives in `pendingAct` and `resolveConfirm` (`cmd/mavend/confirm.go`), not at the
boundary that acts on it. `Exec` trusts the boolean because only two callers
exist today.
So the authorization function is spread across origin handling, routing, parked
confirm state, risk classification, allowlist state and execution. Section
"The authorization function as implemented" in `README.md` writes down the part
that is one expression. The rest is not.
### 6.3d `Claim.Coverage` returns 1.0 for a claim that extracted nothing
`ClaimOf` builds its consumed span from `claimSpans`, which includes
`d.Slots.Text` unconditionally (`internal/router/claim.go:38`).
`Router.fillSlots` backfills the raw utterance into `Text` for a note, a query
and a chat turn (`internal/router/router.go:334`, `if d.Slots.Text == "" &&
d.Intent != IntentReminder`).
`claim.Split` then marks every token of the utterance explained, and
`Coverage()` is `len(Consumed) / total` (`internal/claim/claim.go:122`). A query
claim that extracted nothing scores 1.0, and `MoreSpecificThan` reads coverage
first.
`filledSlots` in the same file already knows about this: it counts `Text` "only
when it differs from the whole utterance". `claimSpans`, four functions above
it, does not.
`internal/router/claim_test.go` does not catch it. All five cases in
`TestClaimOfBands` set `Text` equal to `Utterance`, and the test asserts `Band`
only. Coverage is never asserted anywhere.
This is why `internal/claim` is not yet an answer to "what competes for a turn".
It is the beginning of a vocabulary. It also has no production callers, no
builders for query sources or pre-route claimants, and it identifies only the
seven-intent destination rather than the roughly thirty behaviours
`internal/modes` enumerates. Keeping it unwired is the right state until that is
resolved, and the file's own comment already warns against it becoming a fourth
arbitration layer.
### 6.4 Two query sources do not do what their names say
Two of the twenty-two "query sources" have side effects or read a different
substrate than their name implies. `queryNetwork` triggers a live LAN scan
inside a read path (`cmd/mavend/netscan.go` `scanSummary`), and the scan writes
a note.
### 6.5 `actionFact` answers queries and chat
`cmd/mavend/actions_fact.go` re-routes a question-shaped utterance into
`actionQuery` and a complaint into `actionChat`. Both re-routes are argued and
correct in effect. The consequence is that the fact handler is one of three
entry points into the query chain.
### 6.6 `mavgpud`'s model arm is off and its STT arm is on
`deploy/mavend.json` sets `workstation.model_disabled: true` while
`workstation.stt` is live. One config block, two independently authenticated
services, one flag that turns off half of it. The block's own comment explains
this. A reader of the topology would not guess it.
---
## 7. Hidden shared state
### 7.1 Six context keys carry per-turn state
`querySourceKey`, `turnRouteKey`, `dialogueKey`, `ecosystemCorrelationKey`,
`traceIDKey` (all `cmd/mavend/`), and `recorderKey`
(`internal/decision/decision.go`). Plus `callerKey` in `internal/ipc/api.go`.
Every one is invisible in a function signature. `turnRouteFrom` returns nil
"when the caller is not inside runTurn, a unit test calling one resolver
directly, most often". That is the shape of the problem: a resolver behaves
differently depending on invisible context.
### 7.2 Three single-slot confirmation registers under one mutex
`reactiveHandler.pending`, `pendingRoutine`, `pendingHexis`
(`cmd/mavend/voice.go:170-180`). The comment states the posture: "single slot,
single-user box, a second act while one waits overwrites it (last-asked wins)".
Three separate registers, one shared mutex, and the pre-route ladder decides
between them by position rather than by comparing them.
### 7.3 `surfacedItems` has no TTL
Same struct. The comment argues it: a stale position resolves to an item Praxis
reports as already acknowledged, "which is a harmless answer, unlike a stale
confirmation". That is correct given Praxis is the arbiter. It also means an
ordinal can refer to a list read out an arbitrarily long time ago.
### 7.4 The tick loop's memory is in-process and unbounded in one place
`tickLoop.lastPhrase` is a `map[string]delivery.PhrasedNudge` keyed by rule
name, and rules are a fixed set, so it is bounded. `digestQ` is a slice with a
config `MaxItems`. `lastProposalAt` is deliberately not persisted: "a restart is
allowed to permit one more announcement".
### 7.5 The clarify store is deliberately not persisted, while the dialogue store is
`cmd/mavend/voicewire.go`: `dialogue.NewPersistentSessionStore` for follow-up
slots, `dialogue.NewClarifyStore` for the parked question. The reasoning is
recorded (Vikunja #385). The consequence is that a restart mid-clarify silently
drops a request the user believes is parked, and the "expired clarify notice"
path in `runTurn` step 1 cannot fire for it, because the store it reads is gone
too.
---
## 8. Fragile request paths
### 8.1 Four silent degradations stacked on one turn
| Seam | Falls back to | Told to the user? |
|---|---|---|
| workstation model → resident model | `internal/llm/remote.go` `Pair.Complete` | no, by design (`docs/offload.md`) |
| CW2 → mavsttd | `cmd/mavend/voicewire.go` `sttSeam` | no |
| routing heads → LLM router → classifier | `internal/router/router.go` | no |
| search → kiwix → named page → model weights | `cmd/mavend/actions_query.go` | no |
Each is individually argued. Together, a single answer can be the resident model
routing a worse transcript with the classifier as a floor and answering from its
own weights, and nothing in the reply distinguishes that from the best case. The
only instrument is the decision record and the query-source log line.
### 8.2 The reminder path depends on a table nobody writes
`queryCalendar` reads `facts(kind=env, source=caldav:*)`, and `mavcaldav` is
commented out in `docker-compose.yml`. `loop.State.CalendarBusy` reads the same
facts, so the "do not nag mid-meeting" suppressor is permanently false. The
compose comment says both of these explicitly, which makes it a known gap rather
than a hidden one.
### 8.3 Recurring reminders have storage, an IPC parameter, and no caller
`reminders.cron` and `reminders.next_fire_ts` exist since migration #2
(`internal/store/migrations.go`). `ipc.CreateReminder` takes a cron argument.
`actionReminder` passes `""`. Nothing on the spoken path can create one.
### 8.4 Shutdown is a known past failure with a bounded workaround
`cmd/mavend/main.go` carries the history: long-lived module connections
deadlocked every shutdown, `run()` never returned, `defer st.Close()` never
sealed, and "the deployed ciphertext was eleven days stale before anyone
noticed". The fix is `workerGrace = 4 * time.Second` plus tracked connections.
A worker parked in a model call still loses its tick, and the seal proceeds
without it.
### 8.5 One inbound worker is outside the assertable worker set
`backgroundWorkers` in `cmd/mavend/boot.go` exists so "a test can compare the
set the two paths would start without standing a daemon up". `wireTelegramIntake`
starts its poller with `wg.Add(1)` and a bare goroutine
(`cmd/mavend/telegramintake.go:41`), so it is not in that set. It is at least on
the outer `WaitGroup`, unlike the seven workers V-639 fixed.
### 8.6 The daemon is wired twice, in two places
`run()` wires everything at boot. `srv.UnlockFn` wires everything again after a
passkey assertion. `boot.go` exists because those two lists had already drifted:
"seven workers started untracked on the unlock path and two daemonAPI fields
were never set there, silently". Both paths now funnel through `newDaemonAPI`
and `startBackground`. But `wireRules`, `wireGatherer`, `wirePhraser`,
`wireEcosystem`, `wireVoice`, `wireDispatcher`, `wireTickLoop`, the four worker
constructors, `wireMailIntake`, `wireModelSwap`, `wireTelegramIntake`,
`wireVision`, `wireCapture` and `wireSpeaker` are still listed twice, by hand,
in the same file.
---
## 9. Difficult-to-test boundaries
### 9.1 A resolver's behaviour depends on invisible context
See 7.1. `turnRouteFrom(ctx)` returning nil is the documented test case, and it
changes what the resolver does.
### 9.2 The single-instance handler is the unit under test for ~60 behaviours
Twenty-two query sources, seven action handlers, eleven pre-route resolvers and
the recall gate are all methods on `*reactiveHandler`. Testing one requires
constructing a struct with 34 fields, most of them nil.
### 9.3 The static gates pass against a baseline, and the baseline records the debt
`scripts/analyzers/deadcode.baseline` accepts thirteen unreachable symbols,
eleven of them from the 2026-08-10 audit (V-686), with three marked as
"must stay". `make audit` is a git-grep inventory and is explicitly not a
reachability check (`CLAUDE.md`).
### 9.4 Measurement needs weights that are not in the tree
`make t` self-skips the four `TestONNX*` measurements without `MAVEN_ONNX_LIB`,
and still prints `ok` (`CLAUDE.md`). The routing heads, the embedder, silero and
the keyword head are all ONNX files under `models/`, bind-mounted from
`/mnt/hdd1/llms` in the case of the gguf. A checkout alone cannot reproduce a
routing measurement.
### 9.5 Only 5 of 51 spec entries cite a scenario that exists
Recorded in the previous session's handoff, from `docs/spec.md` and
`cmd/mavend/testdata/scenarios/`. Not re-verified here.
---
## 10. Excessive fan-in and fan-out
**Fan-in.** `ipc.Server` is reached by six processes (mavweb ×3 connections,
mavpoll, mavcaldav, mavmaild, mavupdate, e2eprobe) and carries eight function
fields that bypass `CoreAPI` entirely: `StepUp`, `UnlockFn`, `WrapKeyFn`,
`IngestMailFn`, `SwapModelFn`, `ModelStatusFn`, `DescribeImageFn` and the four
`Capture*` fields. Each is nil unless its config block exists, so the wire
surface of the daemon depends on `deploy/mavend.json`.
**Fan-out.** `reactiveHandler` reaches roughly twenty distinct subsystems
(4.1). `tickLoop` reaches ten (4.3). `wireVoice` constructs seventeen (4.4).
**Failure propagation.** The store is the shared point: `SetMaxOpenConns(1)`
means every writer in the daemon and every module over IPC serialises through
one connection. The measurement backing that cap is
`docs/evals/2026-08-07-store-connection-cap.md` (V-642), cited in
`internal/ipc/server.go` and not re-run here.
---
## 11. What is dark, and what that costs
Sixteen components are wired in code and off in the deployed configuration:
`ntfy`, `zenmoney`, Home Assistant, MCP, the weather provider, the workstation
model arm, vision, meeting capture, speaker identification, mail intake, model
swap, memory evaluation, and the `mavcaldav` and `mavmaild` services.
Three of these have a visible cost:
1. **ntfy disabled** means the away reach is telegram alone, through a SOCKS
relay, through `api.telegram.org`. `deploy/mavend.json` documents that this
was exactly the fragility ntfy was added to remove: "three things in series
that have each failed once, and when they do a sev4 nudge has nowhere to go."
2. **mavcaldav absent** disables both the calendar answer and the busy
suppressor (8.2).
3. **The weather provider is a stub.** `wireVoice` selects Open-Meteo only when
`cfg.Voice.Weather.Provider == "open-meteo"`, and the deployed `voice` block
has no `weather` key at all. `weather` is nevertheless a live query source with
`guesses: true`, so it can claim a turn and answer it from a stub.
---
# Questions the current architecture raises
1. **Which of the three ordered lists is the arbiter?** Stage 0 grammars, the
query-source chain and the pre-route ladder each decide by position. If
`internal/claim` is the answer, what stops it being a fourth list rather than
the thing that collapses the other three?
2. **Where is the one point that decides whether this authenticated origin may
perform this specific effect using this specific evidence?** Today there is
no such point. `reboot` shows why the question is not "which tier system
wins": it is correctly classified as not irreversible, and that does not
imply a room microphone plus "да" should carry reboot authority.
Reversibility, effect severity, reach authority and confirmation strength are
four dimensions, and `TierDestructive → VoiceMayRun:true` collapses them into
one.
3. **What owns the `facts` key namespace?** Nine writers, two of which store
watermarks and tuning parameters in the table that recall embeds. Is `source`
meant to be a partition, and if so what enforces it?
4. **Should the executor live in the key holder?** `systemctl reboot` is a
seeded, enabled row in a process holding the unlocked database. The controls
are an allowlist and a spoken confirm. Is that the intended trust boundary,
or the one that happened?
5. **Should the tick loop and the turn path share one llama-server?**
`internal/llm/gate.go` exists to arbitrate them. What is the acceptable
latency a foreground turn may pay for a background nudge being phrased?
6. **Is a silent four-level degradation still honest?** Each fallback is argued
separately. Nothing tells the user when all four fire at once. The M1 honesty
milestone in `docs/roadmap.md` is about the turn path. Does it cover this?
7. **What is `docker-compose.yml` the source of truth for?** Two complete
services are commented out in it with their reasoning, and one of them
silently disables two behaviours elsewhere. Should absence be expressible in
`deploy/mavend.json` where the rest of the capability switches live?
8. **Why is the daemon wired twice?** `boot.go` fixed the drift that had already
happened. Fifteen `wire*` calls are still listed by hand on both paths. Is
cold-start unlock worth a second wiring path, or should the locked daemon
wire everything and gate at the `Check` hook alone?
9. **What is a query source allowed to do?** One triggers a live LAN scan and
writes a note. If a source may have side effects, what does "first source to
claim answers the turn" guarantee about the sources that ran before it?
10. **Is `mavsttd`/`mavttsd`'s process boundary about the key or about cgo?**
The stated reason is key isolation. The operative reason looks like crash
isolation from cgo and subprocesses. Which one governs whether the next
model caller gets its own process?