bae81b66c8
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>
700 lines
33 KiB
Markdown
700 lines
33 KiB
Markdown
# 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?
|