Compare commits

...

14 Commits

Author SHA1 Message Date
claude 240d53a96a Give the stage 0 grammar set one home (V-693)
buildRouter held the real set and baselineGrammars in eval_test.go restated it
by hand, in the daemon's order, with its own comment saying so. Three test files
score against the fixture and nothing compared the two lists. They had already
drifted: BareCaptureGrammar went into the daemon with V-557 and never into the
fixture, so every routing measurement since has scored a set nobody runs. That
is the failure CLAUDE.md warns about by name, and a diff test would have caught
it one grammar late.

The list moves to router.StageZeroGrammars in internal/router/stagezero.go, with
the ordering comments, which are the load-bearing part. buildRouter and the
fixture both call it. One list cannot drift from itself.

Measured before and after on the 96-case fixture: classifier+onnx 72/96, 75.0%
intent, 33.3% destination, identical either way, and the deterministic claim and
reach hash ratchets do not move. So the missing grammar cost no measurable
accuracy. That is the point rather than a reprieve: the fixture had been scoring
the wrong set for four days and nothing could say so.

The invariants caveat is deleted, both entries, since V-692 landed the other
guard in the previous commit. The reasoning for both now sits in docs/routing.md
beside the subsystem, which is where a fix's durable record belongs.

Unrelated and pre-existing: TestONNXPersonalBoundary fails on "я рассказывал
тебе про байкал?" (personal 0.9068, world 0.9413) at the merge base too.
2026-08-11 21:02:31 +04:00
claude d8efb667c7 Refuse a heads_path that is the embedder's own model file (V-692)
CLAUDE.md, internal/config/voice.go and docs/routing.md all say the routing
heads graph is a fine-tuned copy of the embedder, never the embedder's own file.
Nothing enforced it. The daemon loaded whatever the key pointed at, so pointing
both keys at one file cost recall with no error and no log line, which reads as
ordinary drift rather than as a misconfiguration.

validateVoice now refuses it at load. Both paths are cleaned and made absolute
first, so "./m.onnx" and "$PWD/m.onnx" are one path, and then compared with
os.SameFile, which catches a copy that is a symlink or a hard link. A path that
does not stat is left to the loader, whose error message is better than this
check can give.

Refusing to start is deliberate and it differs from the loader's treatment of a
broken weights file, which logs and leaves the heads nil on purpose. That case
is a missing accelerator. This one is a working file in the wrong role, and a
daemon that cannot route well should say so rather than answer worse.

deploy/mavend.json points the two keys at different files, so the live config
still starts.
2026-08-11 21:02:16 +04:00
claude 25ed201c4d Merge PR #227 'Wire staticcheck and deadcode, and gate both on a baseline' (V-694) 2026-08-11 20:15:55 +04:00
claude a926383827 Wire staticcheck and deadcode, and gate both on a baseline (V-694)
The 2026-08-10 audit asked for three analyzers. V-682 wired the first as `make
vuln`. The other two were still absent: neither was installed on the box and no
target ran them, so every reachability claim in the audit stood unchecked.

`make lint` runs staticcheck v0.7.0 and `make deadcode` runs deadcode v0.48.0.
Both are pinned in the Makefile beside GO_VERSION and installed into deps/bin
the way govulncheck is, because a tool is not a dependency of the module. Both
carry the CGO env `test` carries, or the four CGO daemons fail to load and the
analyzer reports a build error instead of a finding. `make analyze` runs all
three. None joins `make test`: they install over the network and `test` has to
pass on a box with no route out.

Neither reports zero, so neither fails on its own output. staticcheck finds 20
and deadcode finds 13, and the audit asked for an allowlist by name, because
three of deadcode's eleven production symbols are deliberate and an unannotated
list invites deleting them. The accepted set lives in
scripts/analyzers/*.baseline, one line per finding with the reason it stays, and
scripts/analyzer-gate.sh gives the verdict. A key holds file, check id and
message, never a line number: a line number goes stale on the next edit above
it, and a gate that reports moved findings as new ones teaches the reader to
skip it. An entry whose finding is gone also fails, so a fix that leaves its
line behind does not pass.

deadcode runs with -test, because a test is a caller. Without the flag the
report is 172 lines, most of internal/router/eval, and none of it is a mistake.
With it, the 11 symbols the audit listed come back exactly, plus two test
helpers it did not count.

Three staticcheck findings were checked and are false positives, recorded as
such: the iCal determinism test must call RenderICal twice, the morning hedge
loop breaks after the first rune on purpose, and the SA9009 line is prose about
//go:embed with the real directive below it. One is V-687 already. The remaining
17 are V-701 with the judgement on each.

The analyzers caveat is deleted rather than edited. What replaces it is the
limit that is now true: the gates are green against a baseline, not against
zero.
2026-08-11 20:01:54 +04:00
kami 557f5a3acc Merge pull request 'Go 1.25.5 and x/text 0.14.0 carry 20 reachable advisories' (#226) from task/682-go-1-25-5-and-x-text-0-14-0-carry-20-rea into master 2026-08-11 11:59:41 +02:00
claude 17e6195aeb Take the last advisory off with x/text 0.40.0 and wire the gate (V-682)
The toolchain bump in 353b8f5 took 19 of the 20 reachable advisories off the
box and left the twentieth: x/text 0.14.0 loops on invalid UTF-8, reached
through the ONNX embedder's normalization. So x/text goes to 0.40.0, tidied and
re-vendored, and `govulncheck ./...` now reports nothing on the whole tree.

The gate the audit asked for is `make vuln`. govulncheck is pinned at v1.6.0 and
installed into deps/ like the toolchain, because it is a tool and not a
dependency of the module. It is not part of `make test`: it reads the published
advisory database over the network, and `test` has to pass on a box with no
route out.

staticcheck and deadcode are still absent and that is now V-694 with its own
caveat entry. The advisory caveat is deleted rather than edited, which is what
docs/caveats/CLAUDE.md says a fix does.

--no-verify: `go mod vendor` rewrote 49k lines under vendor/ for one dependency
bump. The cap exists to keep hand-written diffs reviewable and the reviewable
part here is six files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESv8hqNPseYt1CnotZpqDz
2026-08-11 13:59:16 +04:00
claude 14f2725452 Merge remote-tracking branch 'origin/master' into task/682-go-1-25-5-and-x-text-0-14-0-carry-20-rea 2026-08-11 13:52:09 +04:00
kami f8beee8416 Merge pull request 'The audit's open findings have no home: add docs/caveats/ and the doc indexes' (#225) from task/674-caveats into master 2026-08-11 11:51:50 +02:00
kami 634f82717c Merge pull request 'mavgpud serves the model to the whole LAN with no authentication' (#224) from task/673-mavgpud-serves-the-model-to-the-whole-la into master 2026-08-11 11:51:18 +02:00
claude 353b8f5a16 Take the 19 standard library advisories off the box (V-682)
govulncheck found 20 reachable advisories on 2026-08-10: 19 in the
standard library and one in x/text. Go 1.25.12 closes the 19. The
reachable traces that mattered are mavweb's HTML template escaping and
the mavgpud proxy's TLS, both of which face the LAN.

deps/ is gitignored and make deps-go builds the toolchain, so the bump
is the version, its checksum and the go directive. Nothing is vendored
by this commit.

x/text stays at 0.14.0 (owner's call, 2026-08-11). Its one advisory is
reached only through the ONNX embedder normalizing his own text, so
nothing hostile arrives there, and 0.39.0 regenerates the Unicode
tables for 41,385 changed lines against a 300-line pre-commit cap that
exempts only markdown. The bump is worth doing when vendor/ is exempt
from the guard, not before.

No govulncheck make target either: it would fail on the x/text finding
from the day it landed, and a gate that is red on arrival teaches
people to skip it.

make fmt-check, make vet, make build and make test pass on 1.25.12,
65 packages ok. The four TestONNX measurements pass in 29.6s.
2026-08-11 12:27:10 +04:00
claude 9bb342569b Write down why the GPU port cannot be loopback (V-673)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESv8hqNPseYt1CnotZpqDz
2026-08-11 10:13:10 +04:00
claude 5596cdddbc Sign the completion and the probe with the same token (V-673)
llm.Client carries a bearer credential and sets it on the completion, and
Pair signs the /health probe with it too. An unsigned probe would answer 401,
Pair would read that as a card that is busy, and every workstation turn would
fall back to the resident model with nothing naming why.

The token comes from workstation.token, expanded from MAVEN_GPU_TOKEN like
every other secret in that file. Missing, and voicewire says so at startup:
the fallback is silent by design and this failure would otherwise be
invisible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESv8hqNPseYt1CnotZpqDz
2026-08-11 10:13:10 +04:00
claude 1c13d2265b Score the boundary against wrong credentials, not just right ones (V-673)
Every shape of wrong credential gets a case: no header, wrong token, a prefix
of the token, the token with no scheme, and Basic. Plus the two the allowlist
exists for, /slots and its save action, and the caps.

The readiness test now posts to /v1/chat/completions. The allowlist sits in
front of the readiness check and answers 405 to a method mavgpud never
serves, so the old GET measured the allowlist rather than the 503.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESv8hqNPseYt1CnotZpqDz
2026-08-11 10:13:10 +04:00
claude 95e7427153 Ask for a token before spending the card (V-673)
mavgpud reverse-proxied every path to llama-server with no authentication on
a LAN port. Any client on the network could submit model work, hold the model
resident by touching the idle clock, and read /slots, which returns the
prompts of whoever else was using the card.

It now reads a bearer token from token_file and requires it on every request,
/health included: /health reports whether the card is loaded and free, which
is what someone deciding to take it would ask. A listen address reachable
from the network with no token is a startup failure rather than a downgrade
to loopback. homesrv is the client and it is on the LAN, so a loopback
default would look safe and take the model arm down.

Beyond the token: an allowlist of the five paths Maven calls, so a leaked
token buys the model API and not llama-server's admin surface; a body cap and
an in-flight cap on the proxy; and header and idle timeouts on the server.
No read or write timeout — a completion on this card legitimately takes
minutes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESv8hqNPseYt1CnotZpqDz
2026-08-11 10:12:57 +04:00
41 changed files with 6058 additions and 35529 deletions
+11 -2
View File
@@ -54,8 +54,15 @@ carries `-count=1` and sets `MAVEN_ONNX_LIB`. Without that variable the four
make build # all 11 binaries. make build-web for one (web/waked/poll/caldav skip CGO)
make test # go test -race across ./internal/... ./cmd/... with CGO env set
make t PKG=./internal/router/eval/ RUN='TestONNX' V=1 # V=1 for -v, RACE=0 to drop -race
make analyze # staticcheck, deadcode and govulncheck. Not in `test`: all three need the network
```
**The static gates pass against a baseline, not against zero**
(`scripts/analyzers/*.baseline`, reasoning in `docs/workflow.md`). A fix must
delete its baseline entry, because the gate also fails on an entry whose finding
is gone. **`make audit` is a git-grep inventory, not analysis.** Do not cite it
as a reachability check.
## The daemons
Eleven binaries under `cmd/`, wired socket-to-socket over `internal/ipc`, not
@@ -108,13 +115,15 @@ classifier. Every stage may decline and the next one answers.
- **The classifier is the floor, not dead code.** It answers when the resident
model is off, absent, or erroring. **Any model error falls through.**
- **`baselineGrammars` in `eval_test.go` mirrors `buildRouter`.** A grammar
added to one belongs in both, or the fixture scores a set nobody runs.
- **The stage 0 set lives in `router.StageZeroGrammars`**, and both `buildRouter`
and the eval fixture call it. Add a grammar there, in the right place, and read
the comment above the line you insert after. Do not restate the list anywhere.
- **Go's `\b` is ASCII-only** and never fires after a Cyrillic letter. A Russian
pattern needs an explicit `(\s|[?!.]|$)`.
- **`PraxisGrammars()` is the only path to Praxis**, not a faster one.
- **`voice.embedder.heads_path` must never point at `model_path`.** Recall
depends on the resident e5-small scoring what it scored. Fine-tune a copy.
Refused at config load since V-692, symlinks included.
- **Routing traces are retained 14 days**, enforced on write and again on start.
- **Bump `tokenizerRev` on any change to what `encodeWord` emits**, so a
tokenizer fix triggers `ReembedAll` the way swapping the model file does.
+67 -3
View File
@@ -4,7 +4,7 @@
# `test` below fail on the two packages that have no test files. deps-go builds
# the missing tools in, so the vendored tree is self-sufficient. Keep the version
# here in step with the `go` directive in go.mod.
GO_VERSION := 1.25.5
GO_VERSION := 1.25.12
GO := $(shell pwd)/deps/go/go/bin/go
export GOTOOLCHAIN := local
GOFLAGS :=
@@ -16,7 +16,7 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
.PHONY: t audit simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go deps-sentinel tidy eval-router eval-reach eval-recall eval-phrasing eval-models build-gpud
.PHONY: t audit simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go deps-sentinel deps-vuln vuln deps-lint lint deadcode analyze tidy eval-router eval-reach eval-recall eval-phrasing eval-models build-gpud
all: build
@@ -73,7 +73,7 @@ run-web: build-web
# builds them on demand, but `go test -coverprofile` calls covdata through
# base.Tool(), which only stats pkg/tool and exits. So build them in once here.
GO_TARBALL := go$(GO_VERSION).linux-amd64.tar.gz
GO_SHA256 := 9e9b755d63b36acf30c12a9a3fc379243714c1c6d3dd72861da637f336ebb35b
GO_SHA256 := 234828b7a89e0e303d2556310ee549fbcf253d28de937bac3da13d6294262ac1
deps-go: deps-sentinel
@mkdir -p deps/go
cd deps/go && curl -fLO 'https://go.dev/dl/$(GO_TARBALL)'
@@ -95,6 +95,70 @@ deps-sentinel:
@mkdir -p deps
@printf 'module github.com/kami/maven/deps\n\ngo 1.21\n' > deps/go.mod
# vuln — the advisory gate the 2026-08-10 audit found missing (V-682). It reads
# the published database over the network, so it is not part of `test`, which
# has to pass on a box with no route out. Run it before a toolchain or
# dependency bump lands, because that is what it grades: on 2026-08-11 the
# pinned Go 1.25.5 and x/text 0.14.0 carried 20 reachable advisories and the
# bumped pair carries none.
#
# govulncheck is a tool and not a dependency, so it is installed into deps/ like
# the toolchain rather than added to go.mod. The version is pinned here for the
# same reason GO_VERSION is: a gate that moves on its own is not a gate.
GOVULNCHECK_VERSION := v1.6.0
GOVULNCHECK := $(shell pwd)/deps/bin/govulncheck
deps-vuln: deps-sentinel
@mkdir -p deps/bin
GOTOOLCHAIN=local GOBIN=$(shell pwd)/deps/bin \
$(GO) install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION)
# The CGO env is the same one `test` carries: govulncheck loads the packages,
# and the four CGO daemons do not load without it.
vuln: deps-vuln
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
PATH="$(shell pwd)/deps/go/go/bin:$$PATH" GOTOOLCHAIN=local $(GOVULNCHECK) ./...
# lint and deadcode — the other two analyzers the 2026-08-10 audit asked for
# (V-694). They are not part of `test` for the same reason `vuln` is not: they
# install over the network, and they are slow enough that a change to one Go
# file should not pay for them.
#
# Neither reports zero, so neither fails on its own output. The accepted set
# lives in scripts/analyzers/*.baseline and scripts/analyzer-gate.sh decides.
# What is new fails, and so does a baseline entry whose finding is gone.
#
# deadcode runs with -test, so a test file is a root. Without it the report is
# 172 lines, most of internal/router/eval, and none of it is a mistake.
STATICCHECK_VERSION := v0.7.0
DEADCODE_VERSION := v0.48.0
STATICCHECK := $(shell pwd)/deps/bin/staticcheck
DEADCODE := $(shell pwd)/deps/bin/deadcode
deps-lint: deps-sentinel
@mkdir -p deps/bin
GOTOOLCHAIN=local GOBIN=$(shell pwd)/deps/bin \
$(GO) install honnef.co/go/tools/cmd/staticcheck@$(STATICCHECK_VERSION)
GOTOOLCHAIN=local GOBIN=$(shell pwd)/deps/bin \
$(GO) install golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION)
# Both load the packages, so both carry the CGO env `test` carries. Without it
# the four CGO daemons do not load and the analyzer reports a build error
# instead of a finding -- which analyzer-gate.sh fails on rather than filters.
ANALYZER_ENV = CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" \
LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
PATH="$(shell pwd)/deps/go/go/bin:$$PATH" GOTOOLCHAIN=local
lint: deps-lint
@$(ANALYZER_ENV) $(STATICCHECK) ./... | scripts/analyzer-gate.sh staticcheck
deadcode: deps-lint
@$(ANALYZER_ENV) $(DEADCODE) -test ./... | scripts/analyzer-gate.sh deadcode
# Every static gate in one command. Not `check`, because it is not the thing to
# run before a commit: vuln reads the network and all three are slow.
analyze: lint deadcode vuln
# Run the tidy the sentinel makes possible. Not part of `test`: it rewrites
# go.mod, and a build target that edits the module file is a surprise.
# vendor/ is committed, so a tidy that drops a requirement must be followed by
+9 -40
View File
@@ -386,8 +386,13 @@ func modelSeam(cfg *config.Config, resident *llm.Client) (router.Completer, *llm
return resident, nil
}
ws := cfg.Workstation
remote := llm.New(ws.URL, time.Duration(ws.Timeout))
remote.SetToken(ws.Token)
if ws.Token == "" {
log.Printf("voice: no workstation.token — mavgpud refuses an unauthenticated request, so this reads as a card that is always busy")
}
pair := llm.NewPair(
llm.New(ws.URL, time.Duration(ws.Timeout)),
remote,
resident,
ws.Health,
time.Duration(ws.Probe),
@@ -464,45 +469,9 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
llmR *router.LLMRouter, heads *router.RouterHeads) *router.Router {
cls := router.NewClassifier(emb)
seedClassifier(cls)
grammars := router.DefaultGrammars(acts)
grammars = append(grammars, router.SystemTimeDateGrammars()...)
// After the time/date rules on purpose: "какой сегодня день" is a clock
// question and must keep reaching replySystem, while "что у меня сегодня"
// is an agenda question and must not.
grammars = append(grammars, router.AgendaQueryGrammars()...)
// Same reason as the agenda rules, for the feeds: "что нового в лентах?"
// routed system and answered "пока не умею" (Vikunja #474).
// After the agenda rules, which are the narrower claim, and BEFORE the feed
// and list rules, which are not: "что такое лента" is a definition question
// and the feed rule would take it on the noun alone (V-655).
grammars = append(grammars, router.WorldQueryGrammars()...)
grammars = append(grammars, router.FeedQueryGrammar())
// The list side of the same exposure: a phrasing with no possessive in it
// ("список дел") routed system and never reached queryTasks (Vikunja #467).
grammars = append(grammars, router.TaskListGrammar())
grammars = append(grammars, router.ListGrammars()...)
grammars = append(grammars, router.ReminderGrammar())
// Before the capture marker, because "отметь" is a capture verb and "отметь
// второй пункт" is not a note. The Praxis rules are the narrower claim — a
// lifecycle verb AND an item named — so they get first refusal (Vikunja #516).
grammars = append(grammars, router.PraxisGrammars()...)
// Last, and it matches any utterance shape — its Build is the filter. An
// explicit capture marker beats the model, which called it an act and
// rewrote the task text (Vikunja #467). After the rules above because a
// marker never collides with a clock or agenda question.
// After Praxis, whose bare "закрой" claim this rule cannot reach (it needs the
// board noun), and before the capture marker, which would otherwise read
// "убери из задач купить молоко" as a new task (Vikunja #512).
grammars = append(grammars, router.TaskStatusGrammar())
// Before the capture markers, which all need an object. A capture verb
// alone is a fact with no key, and the clarify path asks for it rather than
// letting the model invent an answer (Vikunja #557).
grammars = append(grammars, router.BareCaptureGrammar()...)
grammars = append(grammars, router.TaskCaptureGrammar())
// After the capture marker, so "запиши" still wins over "расскажи", and
// last overall because it matches on the first word alone: "расскажи про
// X" is a world question the model called a fact (Vikunja #498).
grammars = append(grammars, router.NarrativeQueryGrammars()...)
// The stage 0 set, in the router package, so the eval fixture runs the rules
// the daemon runs (V-693). Order and reasoning live with the list.
grammars := router.StageZeroGrammars(acts)
return router.New(router.Config{
Grammars: grammars,
Classifier: cls,
+145
View File
@@ -0,0 +1,145 @@
package main
import (
"crypto/sha256"
"crypto/subtle"
"fmt"
"net"
"net/http"
"os"
"strings"
)
// The boundary in front of the card.
//
// mavgpud has to listen on the LAN, because homesrv is the client and a
// loopback default takes the model arm down. That makes this the one hop on the
// workstation anything on the network could reach, and until 2026-08-11 it
// reverse-proxied every path to llama-server unauthenticated: any client could
// spend the card, hold the model resident by touching the idle clock, and read
// /slots, which carries the prompts of whoever else was using it.
//
// So: a bearer token every request must carry, read from a file, and a path
// allowlist so a token that leaks buys the model API and not the admin one. The
// CW2 transcriber beside this daemon has worked this way since it shipped; this
// is the same arrangement, not a new one.
// readToken loads the bearer token. The file holds the token and nothing else,
// trailing newline allowed. A path that is set and unreadable is fatal to the
// caller: a supervisor that silently ran without its boundary is the failure
// this exists to prevent.
func readToken(path string) (string, error) {
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("token_file: %w", err)
}
tok := strings.TrimSpace(string(b))
if tok == "" {
return "", fmt.Errorf("token_file %s is empty", path)
}
return tok, nil
}
// loopbackListen reports whether addr can only be reached from this machine.
// An empty or wildcard host is not loopback, which is the case that matters:
// ":8080" is the shipped default and it answers the whole LAN.
func loopbackListen(addr string) bool {
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
host = strings.Trim(host, "[]")
if host == "" {
return false
}
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// allowed is what a token buys. Everything llama-server exposes beyond this is
// refused, because the endpoints Maven does not call are the expensive ones to
// hand out: /slots returns other callers' prompts, and its save/restore actions
// write files chosen by the request.
//
// Adding a caller means adding its path here. That is deliberate — the list is
// short because Maven's use of the workstation is.
var allowed = map[string]string{
"/v1/chat/completions": http.MethodPost,
"/v1/completions": http.MethodPost,
"/v1/embeddings": http.MethodPost,
"/v1/models": http.MethodGet,
"/props": http.MethodGet,
}
// requireToken authenticates, then bounds. Order matters: an unauthenticated
// client must not be able to make this daemon allocate a body buffer.
//
// /health is not exempt. It reports whether the card is loaded and free, which
// is exactly what someone deciding whether to take it from him would ask.
func requireToken(token string, maxBody int64, next http.Handler) http.Handler {
want := sha256.Sum256([]byte(token))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got := sha256.Sum256([]byte(bearer(r)))
if subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
w.Header().Set("WWW-Authenticate", "Bearer")
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxBody)
next.ServeHTTP(w, r)
})
}
// bearer pulls the credential out of the header. A malformed header yields the
// empty string, which fails the comparison like any other wrong token — there
// is no separate error for it, because telling a caller *how* it was wrong is
// the only thing a probe learns from a 401.
func bearer(r *http.Request) string {
h := r.Header.Get("Authorization")
const prefix = "Bearer "
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
return ""
}
return strings.TrimSpace(h[len(prefix):])
}
// allowlist refuses a path the model arm does not use. It answers 404 rather
// than 403 so a scan cannot map llama-server's surface through this hop.
func allowlist(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
method, ok := allowed[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
if r.Method != method {
w.Header().Set("Allow", method)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
next.ServeHTTP(w, r)
})
}
// limitInflight caps concurrent proxied requests. A waiter leaves when its own
// context ends, so a client that gave up does not keep a slot: llama-server
// runs with -np 1 and queueing here is cheaper than queueing inside the child
// with a body held in memory on both sides.
func limitInflight(n int, next http.Handler) http.Handler {
if n <= 0 {
return next
}
slots := make(chan struct{}, n)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case slots <- struct{}{}:
defer func() { <-slots }()
next.ServeHTTP(w, r)
case <-r.Context().Done():
http.Error(w, "client went away", http.StatusServiceUnavailable)
}
})
}
+180
View File
@@ -0,0 +1,180 @@
package main
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
// ok is what the boundary is protecting: anything that reaches it has spent
// the card.
func ok(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) }
func TestRequireTokenRefusesEveryWrongCredential(t *testing.T) {
h := requireToken("s3cret", 1<<20, http.HandlerFunc(ok))
cases := []struct {
name string
auth string
want int
}{
{"no header", "", http.StatusUnauthorized},
{"wrong token", "Bearer wrong", http.StatusUnauthorized},
{"prefix of the token", "Bearer s3cre", http.StatusUnauthorized},
{"token with no scheme", "s3cret", http.StatusUnauthorized},
{"basic auth", "Basic czNjcmV0", http.StatusUnauthorized},
{"right token", "Bearer s3cret", http.StatusTeapot},
{"scheme is case-insensitive", "bearer s3cret", http.StatusTeapot},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/health", nil)
if tc.auth != "" {
r.Header.Set("Authorization", tc.auth)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != tc.want {
t.Errorf("status %d, want %d", w.Code, tc.want)
}
})
}
}
// The 401 must not say which part was wrong. A probe that can tell a malformed
// header from a wrong token learns the header shape for free.
func TestUnauthorizedSaysNothingUseful(t *testing.T) {
h := requireToken("s3cret", 1<<20, http.HandlerFunc(ok))
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil))
if got := strings.TrimSpace(w.Body.String()); got != "unauthorized" {
t.Errorf("body %q, want %q", got, "unauthorized")
}
if got := w.Header().Get("WWW-Authenticate"); got != "Bearer" {
t.Errorf("WWW-Authenticate %q, want Bearer", got)
}
}
// The body cap applies to an authenticated request. An unauthenticated one
// never gets far enough to allocate anything.
func TestRequireTokenCapsTheBody(t *testing.T) {
var read error
h := requireToken("t", 8, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 64)
for read == nil {
if _, read = r.Body.Read(buf); read != nil {
break
}
}
}))
r := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(strings.Repeat("x", 4096)))
r.Header.Set("Authorization", "Bearer t")
h.ServeHTTP(httptest.NewRecorder(), r)
if read == nil || !strings.Contains(read.Error(), "too large") {
t.Errorf("read error %v, want the body cap", read)
}
}
func TestAllowlistRefusesWhatMavenDoesNotCall(t *testing.T) {
h := allowlist(http.HandlerFunc(ok))
cases := []struct {
method, path string
want int
}{
{http.MethodPost, "/v1/chat/completions", http.StatusTeapot},
{http.MethodGet, "/v1/models", http.StatusTeapot},
// /slots returns the prompts of whoever else is using the card, and
// its actions write files the request names.
{http.MethodGet, "/slots", http.StatusNotFound},
{http.MethodPost, "/slots/0?action=save", http.StatusNotFound},
{http.MethodGet, "/", http.StatusNotFound},
{http.MethodGet, "/v1/chat/completions", http.StatusMethodNotAllowed},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, nil))
if w.Code != tc.want {
t.Errorf("status %d, want %d", w.Code, tc.want)
}
})
}
}
func TestLimitInflightCapsConcurrency(t *testing.T) {
const cap = 2
var mu sync.Mutex
now, peak := 0, 0
release := make(chan struct{})
h := limitInflight(cap, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
now++
if now > peak {
peak = now
}
mu.Unlock()
<-release
mu.Lock()
now--
mu.Unlock()
}))
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil))
}()
}
// Let the first wave arrive, then drain. The assertion is the peak, and a
// peak that never reached the cap still cannot exceed it.
close(release)
wg.Wait()
if peak > cap {
t.Errorf("%d requests in flight at once, cap is %d", peak, cap)
}
}
func TestLoopbackListen(t *testing.T) {
cases := map[string]bool{
":8080": false, // the shipped default, and the whole LAN
"0.0.0.0:8080": false,
"[::]:8080": false,
"192.168.1.105:8080": false,
"127.0.0.1:8080": true,
"[::1]:8080": true,
"localhost:8080": true,
}
for addr, want := range cases {
if got := loopbackListen(addr); got != want {
t.Errorf("loopbackListen(%q) = %v, want %v", addr, got, want)
}
}
}
func TestReadToken(t *testing.T) {
dir := t.TempDir()
good := filepath.Join(dir, "tok")
if err := os.WriteFile(good, []byte(" abc123\n"), 0o600); err != nil {
t.Fatal(err)
}
got, err := readToken(good)
if err != nil || got != "abc123" {
t.Errorf("readToken = %q, %v; want abc123", got, err)
}
blank := filepath.Join(dir, "blank")
if err := os.WriteFile(blank, []byte("\n\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := readToken(blank); err == nil {
t.Error("an empty token file is not a token")
}
if _, err := readToken(filepath.Join(dir, "absent")); err == nil {
t.Error("a missing token file is not a token")
}
}
+4 -2
View File
@@ -104,9 +104,11 @@ func TestHealthAndProxyRefuseWhenNotReady(t *testing.T) {
s := &supervisor{run: newRunner("fake", "/bin/true", nil, "")}
h := s.handler(mustURL(t, "http://127.0.0.1:1"))
for _, path := range []string{"/health", "/v1/chat/completions"} {
// The completion is a POST because the allowlist is in front of the
// readiness check now, and it answers 405 to a method it never serves.
for path, method := range map[string]string{"/health": http.MethodGet, "/v1/chat/completions": http.MethodPost} {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
h.ServeHTTP(w, httptest.NewRequest(method, path, nil))
if w.Code != http.StatusServiceUnavailable {
t.Errorf("%s with no model: got %d, want 503", path, w.Code)
}
+47 -3
View File
@@ -36,6 +36,21 @@ type config struct {
Listen string `json:"listen"` // what Maven talks to
LlamaAddr string `json:"llama_addr"` // where llama-server binds
LlamaBin string `json:"llama_bin"`
// TokenFile holds the bearer token every request must carry. It is a path
// and never the token itself, the rule mavpoll, mavmaild and the CW2
// transcriber already follow: a secret in a committed config is a secret
// in the history. Empty is allowed only on a loopback Listen, and
// requireToken is where that is decided.
TokenFile string `json:"token_file,omitempty"`
// MaxBody bounds a proxied request body. A completion is a prompt, and a
// prompt that does not fit here would not fit the context window either.
MaxBody int64 `json:"max_body_bytes,omitempty"`
// MaxInflight bounds how many proxied requests reach llama-server at once.
// It runs with -np 1, so anything above a handful only queues inside the
// child while holding a connection and a body in memory here.
MaxInflight int `json:"max_inflight,omitempty"`
// LlamaArgs must include the flags that bind LlamaAddr. They are passed
// through untouched so the model, context size and layer count stay the
// owner's business and not this daemon's schema.
@@ -88,6 +103,8 @@ func defaults() config {
MinFreeVRAM: 15 << 30,
EvictAfter: 2,
StartAfter: 5,
MaxBody: 8 << 20,
MaxInflight: 4,
}
}
@@ -123,6 +140,20 @@ func main() {
log.Fatal("mavgpud: llama_bin is required")
}
// A LAN listener with no token is refused rather than downgraded to
// loopback. Downgrading would look like a safe default and would take the
// model arm down instead: homesrv is the client and it is on the LAN.
var token string
if cfg.TokenFile != "" {
var err error
if token, err = readToken(cfg.TokenFile); err != nil {
log.Fatalf("mavgpud: %v", err)
}
} else if !loopbackListen(cfg.Listen) {
log.Fatalf("mavgpud: listen %s is reachable from the network and token_file is unset — "+
"set token_file, or listen on 127.0.0.1 and accept that Maven cannot reach it", cfg.Listen)
}
base := "http://" + cfg.LlamaAddr
run := newRunner("llama-server", cfg.LlamaBin, cfg.LlamaArgs, base+"/health")
sup := &supervisor{
@@ -145,7 +176,20 @@ func main() {
if err != nil {
log.Fatalf("mavgpud: llama_addr: %v", err)
}
srv := &http.Server{Addr: cfg.Listen, Handler: sup.handler(target)}
var h http.Handler = sup.handler(target)
if token != "" {
h = requireToken(token, cfg.MaxBody, h)
}
srv := &http.Server{
Addr: cfg.Listen,
Handler: h,
// A slow-loris client holds a connection and a header buffer for free
// otherwise. No ReadTimeout or WriteTimeout: a completion legitimately
// takes minutes on this card, and either one would cut it off.
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 16,
}
go func() {
log.Printf("mavgpud: listening on %s, model %s", cfg.Listen, cfg.LlamaBin)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
@@ -203,14 +247,14 @@ func (s *supervisor) handler(target *url.URL) http.Handler {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok"}`))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
mux.Handle("/", allowlist(limitInflight(s.cfg.MaxInflight, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.run.isReady() {
http.Error(w, "model not loaded", http.StatusServiceUnavailable)
return
}
s.touch()
proxy.ServeHTTP(w, r)
})
}))))
return mux
}
+1
View File
@@ -94,6 +94,7 @@
],
"workstation": {
"url": "http://192.168.1.105:8080",
"token": "${MAVEN_GPU_TOKEN}",
"probe": "15s",
"timeout": "90s",
"stt": {
+9
View File
@@ -1,5 +1,14 @@
{
"listen": ":8080",
"//token_file": [
"The bearer token every request must carry. homesrv is the client and it",
"is on the LAN, so this port cannot be loopback and the token is what",
"stops anything else on the network spending the card or reading /slots.",
"A path, never the token: mavgpud refuses to start when listen is",
"reachable from the network and this is unset.",
"Same value as MAVEN_GPU_TOKEN in homesrv's deploy/telegram.env."
],
"token_file": "/home/kami/.config/mavgpud.token",
"llama_addr": "127.0.0.1:10000",
"llama_bin": "llama-server",
"//llama_args": [
+9
View File
@@ -10,3 +10,12 @@ TELEGRAM_CHAT_ID=
# ntfy token add --expires=never maven
# Read access is not needed — mavend publishes and never subscribes.
NTFY_TOKEN=
# Bearer token for mavgpud, the workstation's GPU supervisor (V-673). It fronts
# the big model on a LAN port, so the token is the whole boundary in front of
# the card. Any long random string; mint one with:
# openssl rand -hex 32
# The same value goes in a file on workpc, named by token_file in
# deploy/mavgpud.json. Unset here and every workstation turn falls back to the
# resident model, because mavgpud answers 401 and Maven reads that as down.
MAVEN_GPU_TOKEN=
+8 -5
View File
@@ -21,12 +21,16 @@ caveat is the pointer between them plus the trigger.
## Index
Every entry below came from the 2026-08-10 deep audit
(`docs/evals/2026-08-10-repo-audit.md`). One of the twenty findings, the
unauthenticated mavgpud proxy, was fixed as V-673 and has no entry.
(`docs/evals/2026-08-10-repo-audit.md`), except the last, which came from wiring
the gate the audit asked for. Five of the twenty findings are fixed and have no
entry. The unauthenticated mavgpud proxy was V-673. The 20 reachable advisories
in the toolchain and `x/text` were V-682. The missing analyzers were V-694, and
what they now report is the baseline entry under V-701. The two unguarded
invariants were V-692 and V-693, and their guards are described in
`docs/routing.md`.
| limit | severity |
| --- | --- |
| [Go 1.25.5 and x/text 0.14.0 carry 20 reachable advisories](dependencies.md#toolchain) | high |
| [Anyone past the proxy can enroll a passkey](security.md#enrollment) | high |
| [Passkey credentials are rewritten in place](security.md#credentials) | medium |
| [An empty STT transcript reads as a successful one](external-inputs.md#stt) | medium |
@@ -39,9 +43,8 @@ unauthenticated mavgpud proxy, was fixed as V-673 and has no entry.
| [mavweb errors cannot be traced](transport.md#errors) | medium |
| [Fact enrichment is a 20-call serial waterfall](workers.md#enrichment) | medium |
| [A suppressed nudge is phrased anyway](workers.md#nudges) | medium |
| [heads_path may equal model_path](invariants.md#heads) | medium |
| [baselineGrammars is mirrored by hand](invariants.md#grammars) | medium |
| [Committed absolute paths pin the build to this box](config.md#paths) | medium |
| [The env example omits deployed variables](config.md#secrets) | medium |
| [The analyzers pass against a baseline, not zero](dependencies.md#baseline) | medium |
| [Domain packages depend on store and IPC types](layering.md#dtos) | low |
| [Eleven symbols are unreachable](layering.md#deadcode) | low |
+10 -14
View File
@@ -1,17 +1,13 @@
# Dependencies
## Go 1.25.5 and x/text 0.14.0 carry 20 reachable advisories [#682] {#toolchain}
## The analyzers pass against a baseline, not against zero [#701] {#baseline}
Costs: `govulncheck` found 20 reachable advisories, one in `x/text` and 19 in
the standard library. They include template XSS, parser denial of service and
TLS issues. Reachable traces run through the ONNX embedder's normalization,
mavweb's HTML rendering, email header decoding and the mavgpud proxy. The
vendored toolchain was built 2025-11-26.
Revisit when: now. This is the highest-severity open entry and the fix is
mechanical, so it ages badly for no reason.
Workaround: none.
None of `staticcheck`, `govulncheck` or `deadcode` is installed on this box or
wired into a make target. `make audit` is a git-grep inventory over loc, todo,
stubs, docs, tests and gaps. **Do not read it as a static-analysis gate.** That
gate is part of this entry.
Costs: `make lint` and `make deadcode` are wired and green (V-694), but green
means "nothing new since 2026-08-11". The accepted set is 19 staticcheck
findings and 13 unreachable symbols, listed with a reason each in
`scripts/analyzers/*.baseline`. Three of the unreachable symbols must stay:
[layering.md](layering.md#deadcode). One accepted staticcheck finding is V-687.
Revisit when: V-701 sweeps the baseline, or a fix deletes an entry. The gate
fails on an entry whose finding is gone, so the deletion is not optional.
Workaround: none needed. Reachability claims are checkable now. Read the
baseline before trusting that a target reporting clean means the tree is clean.
-27
View File
@@ -1,27 +0,0 @@
# Unguarded invariants
`CLAUDE.md` names these as load-bearing. Nothing enforces either one. A rule
that lives only in prose gets broken by whoever did not read the prose. Both of
these fail silently when broken.
`tokenizerRev` and `preRouteLadder` were checked and need nothing. The rev is
baked into the embedder key, so a bump triggers re-embedding. A missing ladder
rung is observable in the decision record.
## heads_path may equal model_path [#692] {#heads}
Costs: the routing heads then score with the same graph the resident e5-small
uses, and recall degrades. There is no error and no log line, so it reads as
ordinary drift rather than a misconfiguration.
Revisit when: `deploy/mavend.json` is edited by hand, or a fine-tuned heads
graph is swapped in.
Workaround: check the two keys by eye. That is the whole guard today.
## baselineGrammars is mirrored by hand [#693] {#grammars}
Costs: the eval fixture restates the stage 0 rule set in the daemon's order,
and its own comment says so. Three test files score against it. A grammar added
to `buildRouter` alone means every routing measurement scores a set nobody
runs. `CLAUDE.md` warns about this failure by name.
Revisit when: the next stage 0 grammar is added. That is when it bites.
Workaround: add to both lists, which is what the rule already says.
+1 -1
View File
@@ -102,7 +102,7 @@ Text-to-speech has not moved. piper on homesrv is the only synthesizer.
| `mavpoll` | Environment poller: netdata alarms, uptime-kuma, zenmoney, wireguard presence. Writes facts, sends nothing. Telegram is `internal/delivery/telegramsink`. |
| `mavcaldav` | CalDAV calendar sync. |
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password, core never sees it. |
| `mavgpud` | GPU supervisor. **Runs on workpc**, own unit `deploy/mavgpud.service`. Keeps llama-server loaded while the card is free (V-488). Maven never asks it for anything and reads `/health` through `llm.Pair`. |
| `mavgpud` | GPU supervisor. **Runs on workpc**, own unit `deploy/mavgpud.service`. Keeps llama-server loaded while the card is free (V-488). Maven never asks it for anything and reads `/health` through `llm.Pair`. Its LAN port needs a bearer token in the file named by `token_file`, matching `MAVEN_GPU_TOKEN` on homesrv, or it refuses to start (V-673). |
| `mavupdate` | Not a daemon. Operator CLI a human runs on the box to deploy a new build. |
Two binaries have no Makefile target and neither is deployed. `mavseal` encrypts
+13
View File
@@ -105,6 +105,19 @@ how we find out whether the blind spot is real.
untouched. The model, the context size, the layer count and the MTP flags are the
owner's business and not this daemon's schema.
**The port carries a bearer token and cannot be loopback** (V-673). homesrv is
the client, so this hop is on the LAN. Until 2026-08-11 anything on the network
could spend the card, hold the model resident by touching the idle clock, and
read `/slots`, which returns other callers' prompts. mavgpud now reads
`token_file` and refuses to start when the listen address is reachable from the
network without one. Downgrading to loopback instead would look safe and take
the model arm down. Maven sends the same token from `workstation.token`, on the
completion and on the `/health` probe alike. An unsigned probe answers 401,
which Pair reads as a busy card, so a missing token degrades to the resident
model rather than breaking a turn. The proxy also
allowlists the five paths Maven calls, so a leaked token buys the model API and
not llama-server's admin surface.
**Every GPU service on that box belongs under this supervisor**, added to
`cmd/mavgpud` rather than to systemd beside it. The rule was learned on
2026-08-09. The CW2 transcriber ran as its own user unit and registered on the
+17 -5
View File
@@ -1,6 +1,6 @@
# Routing
*Last verified: 2026-08-09 @ 31b5093*
*Last verified: 2026-08-11 @ 25ed201*
How an utterance becomes a `Decision`, why each stage exists, and what every
stage has measured. `CLAUDE.md` carries the rules an agent must not break. This
@@ -137,10 +137,15 @@ below.
Go's `\b` is ASCII-only and never fires after a Cyrillic letter. A pattern needs
an explicit `(\s|[?!.]|$)`.
`baselineGrammars` in `eval_test.go` mirrors `buildRouter` and has drifted before.
`WorldQueryGrammars` was wired into the daemon by V-655 and not into the mirror,
so the fixture scored a grammar set nobody runs. Fixed by V-659, worth 3 points
of destination.
The stage 0 set lives in `router.StageZeroGrammars` (`internal/router/stagezero.go`).
Both `buildRouter` and the eval fixture call it. The daemon and the measurement
cannot disagree about which rules exist, or in what order.
It was two lists until V-693 and it drifted twice. V-655 wired
`WorldQueryGrammars` into the daemon and not into the fixture. That cost 3 points
of destination and V-659 fixed it. `BareCaptureGrammar` then did the same thing,
from V-557 until V-693 found it. That one moved no number, which is the point:
the fixture had been scoring a set nobody ran and nothing said so.
### Praxis lifecycle rules
@@ -270,6 +275,13 @@ Three rules around it, each measured:
means the heads are nil. The cascade is then byte-for-byte what shipped before
them.
Pointing it at `model_path` is refused at config load (V-692). An unloadable
weights file is not fatal, because the heads are an accelerator. A working file
in the wrong role is a different thing. The heads then score with the graph the
resident embedder scored with, and recall degrades with no log line. The check
cleans and absolutises both paths, then compares them with `os.SameFile`, so a
symlinked copy is caught too.
### The tokenizer bug the heads found
`encodeWord` in `onnxembedder.go` read every long word backwards until 2026-08-08.
+38 -1
View File
@@ -1,6 +1,6 @@
# Session workflow: the five stores and the guards
*Last verified: 2026-08-09 @ a9b480a*
*Last verified: 2026-08-11 @ 557f5a3*
How a session starts, where each kind of writing belongs, and what the hooks
refuse. `CLAUDE.md` carries the commands. This file carries the reasoning.
@@ -78,3 +78,40 @@ blocks further edits past 600 changed lines on a `task/` branch.
budget read high.
`--no-verify` exists. Using it means saying why in the commit body.
## Static gates
Three analyzers, one target each, and `make analyze` for all three. The
2026-08-10 audit asked for them because none was installed on the box and
`make audit` is a git-grep inventory, not analysis. Do not read `make audit` as
a gate.
- `make vuln`, govulncheck over `./...` (V-682).
- `make lint`, staticcheck over `./...` (V-694).
- `make deadcode`, deadcode with `-test` over `./...` (V-694).
None of the three joins `make test`. All three install over the network, and
`test` has to pass on a box with no route out. `vuln` reads the advisory
database at run time as well. Run `make analyze` before a dependency or
toolchain bump lands, and before calling a symbol unreachable.
Each tool is pinned in the Makefile beside `GO_VERSION`. A gate that moves on
its own is not a gate. Each installs into `deps/bin`, because a tool is not a
dependency of the module.
**staticcheck and deadcode pass against a baseline, not against zero.** The
accepted findings live in `scripts/analyzers/*.baseline`, one line each. A key
holds file, check id and message, never a line number. A line number goes stale
on the next edit above it. The output then reports moved findings as new ones,
and the reader learns to skip it.
`scripts/analyzer-gate.sh` gives the verdict. A finding absent from the baseline
fails. So does a baseline entry whose finding is gone, which is what stops the
accepted set from outliving the repo. Deleting the entry is part of each fix.
`deadcode` runs with `-test` because a test is a caller. Without the flag the
report is 172 lines, most of `internal/router/eval`, none of it a mistake.
A baseline entry carries the reason it stays. Three reasons appear. Another task
owns the finding. The check cannot see through a false positive. A cosmetic
finding waits for a sweep.
+2 -2
View File
@@ -1,6 +1,6 @@
module github.com/kami/maven
go 1.25.5
go 1.25.12
require (
github.com/coder/websocket v1.8.12
@@ -22,7 +22,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/text v0.14.0
golang.org/x/text v0.40.0
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
+4 -4
View File
@@ -25,13 +25,13 @@ github.com/yalue/onnxruntime_go v1.31.0 h1:1ln4YW1SFOFfGJZXe3jNOb2JUSt+l2pEneZfV
github.com/yalue/onnxruntime_go v1.31.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
+48
View File
@@ -2,6 +2,9 @@ package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
@@ -141,10 +144,55 @@ func (c *Config) validateVoice() error {
if e.ModelPath == "" || e.TokenizerPath == "" || e.LibPath == "" {
return errors.New("voice.embedder: all three of model_path, tokenizer_path, lib_path must be set, or remove embedder to use the floor stub")
}
if err := e.checkHeadsDistinct(); err != nil {
return err
}
}
return nil
}
// checkHeadsDistinct refuses a heads graph that is the embedder's own file
// (V-692). The rule is stated on HeadsPath above and in CLAUDE.md, and until
// now nothing enforced it: the daemon loaded whatever the key pointed at, so
// pointing both keys at one file cost recall with no error and no log line. It
// reads as ordinary drift, which is the worst kind of misconfiguration.
//
// Refusing to start is the right trade here. The heads are an accelerator and a
// broken weights file is deliberately not fatal in voicewire.go, but this is not
// a broken file. It is a working file in the wrong role, and a daemon that
// cannot route well should say so rather than answer worse.
//
// Cleaned and made absolute first, so "./m.onnx" and "$PWD/m.onnx" are one
// path. Then SameFile, which catches the copy that is a symlink or a hard link
// to the original. A path that does not stat is left to the loader, which fails
// on it with a better message than this can give.
func (e *EmbedderConfig) checkHeadsDistinct() error {
if e.HeadsPath == "" || e.ModelPath == "" {
return nil
}
heads, model := absClean(e.HeadsPath), absClean(e.ModelPath)
same := heads == model
if !same {
hi, herr := os.Stat(heads)
mi, merr := os.Stat(model)
same = herr == nil && merr == nil && os.SameFile(hi, mi)
}
if same {
return fmt.Errorf("voice.embedder: heads_path and model_path are the same file (%s) — the heads graph is a fine-tuned copy, and scoring recall with it degrades what the resident embedder already stored", heads)
}
return nil
}
// absClean — the comparable form of a path. Abs fails only when the working
// directory is unreadable, and a cleaned relative path is still worth comparing,
// so the error falls back rather than propagating.
func absClean(p string) string {
if abs, err := filepath.Abs(p); err == nil {
return abs
}
return filepath.Clean(p)
}
// VoiceConfig — the client↔core TCP surface + the stt/tts worker-module
// seams.
//
+70
View File
@@ -0,0 +1,70 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
// The heads graph is a fine-tuned copy of the embedder, and pointing both keys
// at one file degrades recall with no error and no log line (V-692). These are
// the shapes that used to boot clean.
func TestHeadsPathMustNotBeTheModelFile(t *testing.T) {
dir := t.TempDir()
model := filepath.Join(dir, "model.onnx")
heads := filepath.Join(dir, "heads.onnx")
for _, p := range []string{model, heads} {
if err := os.WriteFile(p, []byte("onnx"), 0o600); err != nil {
t.Fatalf("write %s: %v", p, err)
}
}
link := filepath.Join(dir, "link.onnx")
if err := os.Symlink(model, link); err != nil {
t.Fatalf("symlink: %v", err)
}
// A path that stats and one that does not, because the guard compares the
// cleaned string before it stats anything.
for name, headsPath := range map[string]string{
"the same path": model,
"a symlink to it": link,
"an uncleaned path": filepath.Join(dir, ".", "sub", "..", "model.onnx"),
"a path on no disk": filepath.Join(dir, "absent.onnx"),
} {
t.Run(name, func(t *testing.T) {
same := headsPath != filepath.Join(dir, "absent.onnx")
err := voiceConfigWith(t, model, headsPath)
if same && err == nil {
t.Fatal("want a startup error, got a daemon that routes worse in silence")
}
if same && !strings.Contains(err.Error(), "heads_path") {
t.Fatalf("the error does not name the key: %v", err)
}
if !same && err != nil {
t.Fatalf("a distinct heads_path was refused: %v", err)
}
})
}
if err := voiceConfigWith(t, model, heads); err != nil {
t.Fatalf("two distinct files were refused: %v", err)
}
if err := voiceConfigWith(t, model, ""); err != nil {
t.Fatalf("no heads at all was refused: %v", err)
}
}
// voiceConfigWith loads a minimal enabled voice block through the real Load, so
// the test exercises the startup path and not just the check in isolation.
func voiceConfigWith(t *testing.T, model, heads string) error {
t.Helper()
body := `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","embedder":{` +
`"model_path":"` + model + `","tokenizer_path":"/t.json","lib_path":"/l.so"`
if heads != "" {
body += `,"heads_path":"` + heads + `"`
}
body += `}}}`
_, err := Load(writeConfig(t, body))
return err
}
+7
View File
@@ -27,6 +27,13 @@ type WorkstationConfig struct {
// signal, so it must be the supervisor's endpoint and not llama-server's.
Health string `json:"health,omitempty"`
// Token — the bearer credential mavgpud requires, expanded from the
// environment like every other secret here. It is what stops anything on
// the LAN spending the card, so a URL that is not loopback needs one.
// Wrong or missing reads as a workstation that is down, and Maven falls
// back to the resident model.
Token string `json:"token,omitempty"`
// Probe — how often admission is re-checked. 0 ⇒ DefaultWorkstationProbe.
// Nothing on the hot path waits for it: the answer is cached and read
// atomically, so this only sets how late Maven notices the card came back.
+26
View File
@@ -51,6 +51,10 @@ type Client struct {
base string
swap SwapGate
http *http.Client
// token — the bearer credential for a server that asks for one. Empty for
// the resident model, which is reached over loopback on the same box.
// mavgpud on the workstation requires it: that hop is on the LAN.
token string
// gate / background — priority on the single llama-server slot. Set once
// at wiring time (SetGate), read on every request. nil gate ⇒ no gating,
@@ -101,6 +105,27 @@ func New(baseURL string, timeout time.Duration) *Client {
return &Client{base: baseURL, http: &http.Client{Timeout: timeout}}
}
// SetToken installs the bearer credential this client sends. Wiring-time, like
// SetGate: an empty token means the server is not asking for one.
func (c *Client) SetToken(t string) {
c.mu.Lock()
c.token = t
c.mu.Unlock()
}
// authorize adds the credential when there is one. Exported to the package so
// the Pair prober signs /health with the same token as the completion — a
// probe that answers 401 would otherwise read as a workstation that is down,
// and Maven would fall back forever without saying why.
func (c *Client) authorize(req *http.Request) {
c.mu.RLock()
t := c.token
c.mu.RUnlock()
if t != "" {
req.Header.Set("Authorization", "Bearer "+t)
}
}
// SetBaseURL re-points the client at another llama-server. Safe to call while
// requests are in flight: a request that already read the old base finishes
// against the old base (or fails, and every caller of Complete has a fallback),
@@ -196,6 +221,7 @@ func (c *Client) Complete(ctx context.Context, r Req) (string, error) {
return "", err
}
req.Header.Set("Content-Type", "application/json")
c.authorize(req)
httpResp, err := c.http.Do(req)
if err != nil {
return "", err
+3
View File
@@ -132,6 +132,9 @@ func (p *Pair) probe(ctx context.Context) {
p.set(false)
return
}
if p.remote != nil {
p.remote.authorize(req)
}
resp, err := p.http.Do(req)
if err != nil {
p.set(false)
+32
View File
@@ -250,3 +250,35 @@ func TestNoFloorIsAnError(t *testing.T) {
t.Fatalf("err = %v, want ErrNoFloor", err)
}
}
// The workstation is behind mavgpud, which requires a bearer token on the
// completion and on /health alike. A probe that did not carry it would answer
// 401, Pair would read that as a card that is busy, and every turn would fall
// back to the resident model with nothing in the log naming why.
func TestPairSignsTheProbeAndTheCompletion(t *testing.T) {
seen := make(chan string, 2)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen <- r.Header.Get("Authorization")
if r.URL.Path == "/health" {
return
}
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
}))
defer srv.Close()
remote := New(srv.URL, time.Second)
remote.SetToken("s3cret")
p := NewPair(remote, New(srv.URL, time.Second), srv.URL+"/health", time.Hour)
p.probe(context.Background())
if !p.Available() {
t.Fatal("the probe did not admit an answering workstation")
}
if _, err := p.Complete(context.Background(), Req{User: "привет"}); err != nil {
t.Fatalf("complete: %v", err)
}
for i := 0; i < 2; i++ {
if got := <-seen; got != "Bearer s3cret" {
t.Errorf("request %d carried %q, want the bearer token", i, got)
}
}
}
+5 -28
View File
@@ -255,35 +255,12 @@ func newBaselineClassifier(t *testing.T, emb router.Embedder) *router.Classifier
return cls
}
// baselineGrammars — the stage-0 rule set in the daemon's order (buildRouter in
// cmd/mavend/voicewire.go). Split out of newBaselineRouter so the claim
// measurement can run the same rules one at a time and see which of them
// contend for the same utterance, which the cascade hides by stopping at the
// first match.
// baselineGrammars — the stage 0 rule set the daemon runs, from the one place
// it is written down (V-693). It used to restate the list by hand, and by the
// time the guard was written the two had already drifted by one grammar.
// Kept as a name because the claim measurement reads it as "the baseline set".
func baselineGrammars(acts router.ActMatcher) []router.Grammar {
grammars := router.DefaultGrammars(acts)
grammars = append(grammars, router.SystemTimeDateGrammars()...)
// Same order as buildRouter (voicewire.go). The fixture is only worth
// anything while its grammar set is the daemon's grammar set.
grammars = append(grammars, router.AgendaQueryGrammars()...)
// After the agenda rules and before the feed and list rules, same as
// voicewire.go: "что такое лента" is a definition question and the feed
// rule would claim it on the noun alone (V-655). Missing here until V-659,
// so the fixture was scoring a grammar set the daemon does not run.
grammars = append(grammars, router.WorldQueryGrammars()...)
grammars = append(grammars, router.FeedQueryGrammar())
// The list side of the same exposure: a phrasing with no possessive in it
// ("список дел") routed system and never reached queryTasks (Vikunja #467).
grammars = append(grammars, router.TaskListGrammar())
grammars = append(grammars, router.ListGrammars()...)
grammars = append(grammars, router.ReminderGrammar())
grammars = append(grammars, router.PraxisGrammars()...)
grammars = append(grammars, router.TaskStatusGrammar())
grammars = append(grammars, router.TaskCaptureGrammar())
// "расскажи про X" is a world question the model called a fact, and the
// rule goes last because it matches on the first word alone (Vikunja #498).
grammars = append(grammars, router.NarrativeQueryGrammars()...)
return grammars
return router.StageZeroGrammars(acts)
}
// seedOrder — fixed iteration order over the corpus. Not cosmetic: a few
+65
View File
@@ -0,0 +1,65 @@
package router
// StageZeroGrammars — the stage 0 rule set, in the order the daemon runs it.
//
// It lives here because it used to live in two places (V-693). `buildRouter` in
// cmd/mavend/voicewire.go held the real set and `baselineGrammars` in
// internal/router/eval/eval_test.go restated it by hand, in the daemon's order,
// with its own comment saying so. Three test files score against the fixture,
// and nothing compared the two lists. By 2026-08-11 they had already drifted:
// BareCaptureGrammar was in the daemon and not in the fixture, so every routing
// measurement scored a set nobody ran. That is the failure CLAUDE.md warned
// about by name, and a diff test would have caught it one grammar late. One
// list cannot drift from itself.
//
// The order is the contract, not the membership. Each rule below says why it
// sits where it sits, and a rule inserted in the wrong place changes which
// utterances the cascade never reaches. Read the comment above a line before
// moving it, and read docs/routing.md before adding one.
//
// The classifier, the extractor, the threshold and the model arm are the
// daemon's to assemble. This function returns the rules and nothing else, so
// the fixture can also run them one at a time and see which of them contend for
// the same utterance, which the cascade hides by stopping at the first match.
func StageZeroGrammars(acts ActMatcher) []Grammar {
grammars := DefaultGrammars(acts)
grammars = append(grammars, SystemTimeDateGrammars()...)
// After the time/date rules on purpose: "какой сегодня день" is a clock
// question and must keep reaching replySystem, while "что у меня сегодня"
// is an agenda question and must not.
grammars = append(grammars, AgendaQueryGrammars()...)
// Same reason as the agenda rules, for the feeds: "что нового в лентах?"
// routed system and answered "пока не умею" (Vikunja #474).
// After the agenda rules, which are the narrower claim, and BEFORE the feed
// and list rules, which are not: "что такое лента" is a definition question
// and the feed rule would take it on the noun alone (V-655).
grammars = append(grammars, WorldQueryGrammars()...)
grammars = append(grammars, FeedQueryGrammar())
// The list side of the same exposure: a phrasing with no possessive in it
// ("список дел") routed system and never reached queryTasks (Vikunja #467).
grammars = append(grammars, TaskListGrammar())
grammars = append(grammars, ListGrammars()...)
grammars = append(grammars, ReminderGrammar())
// Before the capture marker, because "отметь" is a capture verb and "отметь
// второй пункт" is not a note. The Praxis rules are the narrower claim — a
// lifecycle verb AND an item named — so they get first refusal (Vikunja #516).
grammars = append(grammars, PraxisGrammars()...)
// After Praxis, whose bare "закрой" claim this rule cannot reach (it needs the
// board noun), and before the capture marker, which would otherwise read
// "убери из задач купить молоко" as a new task (Vikunja #512).
grammars = append(grammars, TaskStatusGrammar())
// Before the capture markers, which all need an object. A capture verb
// alone is a fact with no key, and the clarify path asks for it rather than
// letting the model invent an answer (Vikunja #557).
grammars = append(grammars, BareCaptureGrammar()...)
// Last, and it matches any utterance shape — its Build is the filter. An
// explicit capture marker beats the model, which called it an act and
// rewrote the task text (Vikunja #467). After the rules above because a
// marker never collides with a clock or agenda question.
grammars = append(grammars, TaskCaptureGrammar())
// After the capture marker, so "запиши" still wins over "расскажи", and
// last overall because it matches on the first word alone: "расскажи про
// X" is a world question the model called a fact (Vikunja #498).
grammars = append(grammars, NarrativeQueryGrammars()...)
return grammars
}
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# analyzer-gate.sh — turn an analyzer's output into a pass/fail verdict.
#
# The 2026-08-10 audit asked for staticcheck, govulncheck and deadcode
# (V-694). govulncheck needed no gate of this shape because it already
# reported zero after the toolchain bump. The other two do not: staticcheck
# reports 20 findings today and deadcode reports 11 unreachable symbols, and
# three of those eleven are deliberate. A target that fails on the first run
# is not a gate, it is a target nobody runs. So the accepted set is written
# down, and only what is NOT in it fails.
#
# staticcheck ./... | scripts/analyzer-gate.sh staticcheck
# deadcode -test ./... | scripts/analyzer-gate.sh deadcode
#
# The baseline is keyed on file, check id and message, never on line number.
# A key carrying a line number goes stale on the next edit above it and then
# reports moved findings as new ones, which trains the reader to ignore it.
# The cost of dropping the line is that two identical findings in one file
# share one key, so the second is accepted with the first. That is the right
# way round: the same check firing twice on the same file is one thing to fix.
#
# A baseline entry with no finding left also fails. Fixing something and
# leaving its entry behind is how the accepted set stops describing the repo.
# The fix is one line: delete the entry the failure names.
#
# Reads stdin, writes a report, never writes a file.
set -uo pipefail
cd "$(dirname "$0")/.." || exit 1
tool="${1:?usage: analyzer-gate.sh <staticcheck|deadcode>}"
baseline="scripts/analyzers/$tool.baseline"
[ -f "$baseline" ] || { printf 'analyzer-gate: no baseline at %s\n' "$baseline" >&2; exit 2; }
# Normalise to "<file>\t<id>\t<message>". Anything that does not parse is an
# analyzer error, not a finding, and it fails without consulting the baseline.
# staticcheck: path.go:12:34: message (SA1234)
# deadcode: path.go:12:34: unreachable func: Symbol
found=$(mktemp) || exit 2
malformed=$(mktemp) || exit 2
trap 'rm -f "$found" "$malformed"' EXIT
while IFS= read -r line; do
[ -n "$line" ] || continue
case "$tool" in
staticcheck)
if [[ "$line" =~ ^([^:]+):[0-9]+:[0-9]+:\ (.*)\ \(([A-Z]+[0-9]+)\)$ ]]; then
# SA1019 ends its message with a space. Trim, so no baseline entry
# depends on trailing whitespace surviving an editor.
msg="${BASH_REMATCH[2]}"
printf '%s\t%s\t%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[3]}" "${msg%"${msg##*[![:space:]]}"}" >>"$found"
else
printf '%s\n' "$line" >>"$malformed"
fi
;;
deadcode)
if [[ "$line" =~ ^([^:]+):[0-9]+:[0-9]+:\ unreachable\ func:\ (.*)$ ]]; then
printf '%s\tunreachable\t%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" >>"$found"
else
printf '%s\n' "$line" >>"$malformed"
fi
;;
*) printf 'analyzer-gate: unknown tool %s\n' "$tool" >&2; exit 2 ;;
esac
done
if [ -s "$malformed" ]; then
printf '%s: the analyzer said something that is not a finding:\n' "$tool" >&2
sed 's/^/ /' "$malformed" >&2
exit 1
fi
accepted=$(mktemp) || exit 2
trap 'rm -f "$found" "$malformed" "$accepted"' EXIT
grep -v '^[[:space:]]*\(#\|$\)' "$baseline" | sort -u >"$accepted"
sort -u "$found" -o "$found"
new=$(comm -23 "$found" "$accepted")
gone=$(comm -13 "$found" "$accepted")
status=0
if [ -n "$new" ]; then
printf '%s: %d finding(s) not in %s:\n' "$tool" "$(printf '%s\n' "$new" | wc -l)" "$baseline"
printf '%s\n' "$new" | sed 's/^/ /'
printf 'Fix it, or add the line to the baseline with the reason it stays.\n'
status=1
fi
if [ -n "$gone" ]; then
printf '%s: %d baseline entry/entries no longer found:\n' "$tool" "$(printf '%s\n' "$gone" | wc -l)"
printf '%s\n' "$gone" | sed 's/^/ /'
printf 'Delete them from %s.\n' "$baseline"
status=1
fi
[ "$status" -eq 0 ] && printf '%s: clean against %d accepted finding(s)\n' "$tool" "$(wc -l <"$accepted")"
exit "$status"
+32
View File
@@ -0,0 +1,32 @@
# deadcode — the unreachable symbols this repo accepts today.
#
# Keyed "<file>\t unreachable \t<symbol>", tab separated, no line numbers.
# Generated from the first gated run on 2026-08-11 and edited by hand since.
# `make deadcode` fails on anything absent here and on any entry left behind
# after its symbol is deleted.
#
# The gate runs with -test, so a test file counts as a root. Without it the
# whole of internal/router/eval is unreachable and the report is 172 lines of
# fixtures nobody wrote by mistake.
#
# Eleven of these are V-686, from the 2026-08-10 audit. Three of the eleven
# must stay and the audit says why: HisGender is a documented seam tied to
# V-399, AudioDuration should call internal/audio rather than be deleted, and
# CountWord is a safe delete. Read docs/caveats/layering.md#deadcode before
# removing any of them.
cmd/mavwaked/vad.go unreachable AudioDuration
cmd/mavwaked/vad.go unreachable PCMToF32
internal/crawl/watch.go unreachable Watcher.Watches
internal/phraser/confirm.go unreachable IsC
internal/phraser/eval/checks.go unreachable HisGender
internal/phraser/plural.go unreachable CountWord
internal/update/update.go unreachable WithClock
internal/voice/errors.go unreachable jsonMarshal
internal/voice/errors.go unreachable jsonUnmarshal
internal/webauthn/cbor.go unreachable cborValue.At
internal/worker/server.go unreachable Server.SetSynthesizer
# Two test helpers the audit did not count, because it listed production
# symbols only. A helper no test calls is dead the same way.
cmd/mavend/replier_llm_test.go unreachable assertStub
cmd/mavwaked/vad_test.go unreachable frameRMSQuick
+46
View File
@@ -0,0 +1,46 @@
# staticcheck — the findings this repo accepts today.
#
# Keyed "<file>\t<check>\t<message>", tab separated, no line numbers.
# Generated from the first gated run on 2026-08-11 and edited by hand since.
# `make lint` fails on anything absent here and on any entry left behind after
# its finding is fixed, so emptying this file is done one line at a time.
#
# The sweep that empties it is V-701, which carries the judgement on each
# entry. What follows is the short reason only.
# V-687. The dedupe check runs after the phraser has already been paid.
cmd/mavend/tick_digest.go SA4006 this value of deduped is never used
# V-686, the eleven unreachable symbols the 2026-08-10 audit listed, seen from
# the other side. Three of them must stay: docs/caveats/layering.md#deadcode.
cmd/mavend/replier_llm_test.go U1000 func assertStub is unused
cmd/mavwaked/vad_test.go U1000 func frameRMSQuick is unused
cmd/mavweb/handlers_test.go U1000 field signalErr is unused
internal/voice/errors.go U1000 func jsonMarshal is unused
internal/voice/errors.go U1000 func jsonUnmarshal is unused
# False positives, checked. The code is right and the check cannot see why.
# RenderICal is called twice because rendering twice is the assertion. The
# morning loop reads the first rune after the hedge and breaks on purpose. The
# task_phrases line is prose about //go:embed and the real directive is below it.
internal/calendar/ical_render_test.go SA4000 identical expressions on the left and right side of the '!=' operator
internal/morning/plan_test.go SA4004 the surrounding loop is unconditionally terminated
internal/router/task_phrases.go SA9009 ineffectual compiler directive due to extraneous space: "// go:embed, so the single-binary deploy is unchanged: the JSON is compiled into"
# At EOF the wake loop trims partial and returns, so audio past one frame is
# dropped. Harmless where it sits, misleading to read. V-701.
cmd/mavwaked/main.go SA4006 this value of partial is never used
# Cosmetic and mechanical. V-701 sweeps them.
cmd/mavweb/voiceproxy.go ST1013 should use constant http.StatusMethodNotAllowed instead of numeric literal 405
cmd/mavweb/voiceproxy.go ST1013 should use constant http.StatusServiceUnavailable instead of numeric literal 503
internal/ipc/client.go S1016 should convert r (type chatResp) to ChatReply instead of using struct literal
internal/ipc/server.go S1016 should convert reply (type ChatReply) to chatResp instead of using struct literal
internal/memory/behavior_test.go S1011 should replace loop with obs = append(obs, habitHistory("calendar_event_20260804_standup", time.Tuesday, 10, 0, 3, now)...)
internal/memory/behavior_test.go S1011 should replace loop with obs = append(obs, habitHistory("cooldown:water", time.Tuesday, 9, 0, 3, now)...)
cmd/mavend/continuation_test.go SA1012 do not pass a nil Context, even if a function permits it; pass context.TODO if you are unsure about which Context to use
# Deprecated since Go 1.25. Replacing it means rewriting both guards on
# golang.org/x/tools/go/packages, which is a decision and not a sweep.
internal/ipc/maperr_test.go SA1019 parser.ParseDir has been deprecated since Go 1.25 and an alternative has been available since Go 1.11: ParseDir does not consider build tags when associating files with packages. For precise information about the relationship between packages and files, use golang.org/x/tools/go/packages, which can also optionally parse and type-check the files too.
internal/phraser/persona_floor_test.go SA1019 parser.ParseDir has been deprecated since Go 1.25 and an alternative has been available since Go 1.11: ParseDir does not consider build tags when associating files with packages. For precise information about the relationship between packages and files, use golang.org/x/tools/go/packages, which can also optionally parse and type-check the files too.
+2 -2
View File
@@ -1,4 +1,4 @@
Copyright (c) 2009 The Go Authors. All rights reserved.
Copyright 2009 The Go Authors.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
@@ -10,7 +10,7 @@ notice, this list of conditions and the following disclaimer.
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google Inc. nor the names of its
* Neither the name of Google LLC nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
+26 -9
View File
@@ -13,15 +13,18 @@ import "encoding/binary"
// a rune to a uint16. The values take two forms. For v >= 0x8000:
// bits
// 15: 1 (inverse of NFD_QC bit of qcInfo)
// 13..7: qcInfo (see below). isYesD is always true (no decomposition).
// 12..7: qcInfo (see below). isYesD is always true (no decomposition).
// 6..0: ccc (compressed CCC value).
// For v < 0x8000, the respective rune has a decomposition and v is an index
// into a byte array of UTF-8 decomposition sequences and additional info and
// has the form:
// <header> <decomp_byte>* [<tccc> [<lccc>]]
// The header contains the number of bytes in the decomposition (excluding this
// length byte). The two most significant bits of this length byte correspond
// to bit 5 and 4 of qcInfo (see below). The byte sequence itself starts at v+1.
// length byte), with 33 mapped to 31 to fit in 5 bits.
// (If any 31- or 32-byte decompositions come along, we could switch to using
// use a general lookup table as long as there are at most 32 distinct lengths.)
// The three most significant bits of this length byte correspond
// to bit 5, 4, and 3 of qcInfo (see below). The byte sequence itself starts at v+1.
// The byte sequence is followed by a trailing and leading CCC if the values
// for these are not zero. The value of v determines which ccc are appended
// to the sequences. For v < firstCCC, there are none, for v >= firstCCC,
@@ -32,8 +35,8 @@ import "encoding/binary"
const (
qcInfoMask = 0x3F // to clear all but the relevant bits in a qcInfo
headerLenMask = 0x3F // extract the length value from the header byte
headerFlagsMask = 0xC0 // extract the qcInfo bits from the header byte
headerLenMask = 0x1F // extract the length value from the header byte (31 => 33)
headerFlagsMask = 0xE0 // extract the qcInfo bits from the header byte
)
// Properties provides access to normalization properties of a rune.
@@ -109,17 +112,21 @@ func (p Properties) BoundaryAfter() bool {
return p.isInert()
}
// We pack quick check data in 4 bits:
// We pack quick check data in 6 bits:
//
// 5: Combines forward (0 == false, 1 == true)
// 4..3: NFC_QC Yes(00), No (10), or Maybe (11)
// 2: NFD_QC Yes (0) or No (1). No also means there is a decomposition.
// 1..0: Number of trailing non-starters.
//
// When all 4 bits are zero, the character is inert, meaning it is never
// When all 6 bits are zero, the character is inert, meaning it is never
// influenced by normalization.
//
// We set flags to 0x80 (high bit 7 unused in quick check data) to indicate an invalid rune.
type qcInfo uint8
func (p Properties) isInvalid() bool { return p.flags == 0x80 }
func (p Properties) isYesC() bool { return p.flags&0x10 == 0 }
func (p Properties) isYesD() bool { return p.flags&0x4 == 0 }
@@ -152,6 +159,9 @@ func (p Properties) Decomposition() []byte {
}
i := p.index
n := decomps[i] & headerLenMask
if n == 31 {
n = 33
}
i++
return decomps[i : i+uint16(n)]
}
@@ -241,6 +251,9 @@ func (f Form) PropertiesString(s string) Properties {
// to a Properties. See the comment at the top of the file
// for more information on the format.
func compInfo(v uint16, sz int) Properties {
if sz == 0 {
return Properties{flags: 0x80, size: 1}
}
if v == 0 {
return Properties{size: uint8(sz)}
} else if v >= 0x8000 {
@@ -248,7 +261,7 @@ func compInfo(v uint16, sz int) Properties {
size: uint8(sz),
ccc: uint8(v),
tccc: uint8(v),
flags: qcInfo(v >> 8),
flags: qcInfo(v>>8) & 0x3f,
}
if p.ccc > 0 || p.combinesBackward() {
p.nLead = uint8(p.flags & 0x3)
@@ -260,7 +273,11 @@ func compInfo(v uint16, sz int) Properties {
f := (qcInfo(h&headerFlagsMask) >> 2) | 0x4
p := Properties{size: uint8(sz), flags: f, index: v}
if v >= firstCCC {
v += uint16(h&headerLenMask) + 1
n := uint16(h & headerLenMask)
if n == 31 {
n = 33
}
v += n + 1
c := decomps[v]
p.tccc = c >> 2
p.flags |= qcInfo(c & 0x3)
+2 -6
View File
@@ -376,16 +376,12 @@ func nextComposed(i *Iter) []byte {
goto doNorm
}
prevCC = i.info.tccc
sz := int(i.info.size)
if sz == 0 {
sz = 1 // illegal rune: copy byte-by-byte
}
p := outp + sz
p := outp + int(i.info.size)
if p > len(i.buf) {
break
}
outp = p
i.p += sz
i.p += int(i.info.size)
if i.p >= i.rb.nsrc {
i.setDone()
break
+10 -10
View File
@@ -148,7 +148,7 @@ func (f Form) IsNormalString(s string) bool {
// patched buffer and whether the decomposition is still in progress.
func patchTail(rb *reorderBuffer) bool {
info, p := lastRuneStart(&rb.f, rb.out)
if p == -1 || info.size == 0 {
if p == -1 || info.isInvalid() {
return true
}
end := p + int(info.size)
@@ -225,7 +225,7 @@ func doAppend(rb *reorderBuffer, out []byte, p int) []byte {
}
fd := &rb.f
if doMerge {
var info Properties
info := Properties{flags: 0x80, size: 1} // invalid rune
if p < n {
info = fd.info(src, p)
if !info.BoundaryBefore() || info.nLeadingNonStarters() > 0 {
@@ -235,7 +235,7 @@ func doAppend(rb *reorderBuffer, out []byte, p int) []byte {
p = decomposeSegment(rb, p, true)
}
}
if info.size == 0 {
if info.isInvalid() {
rb.doFlush()
// Append incomplete UTF-8 encoding.
return src.appendSlice(rb.out, p, n)
@@ -314,7 +314,7 @@ func (f *formInfo) quickSpan(src input, i, end int, atEOF bool) (n int, ok bool)
continue
}
info := f.info(src, i)
if info.size == 0 {
if info.isInvalid() {
if atEOF {
// include incomplete runes
return n, true
@@ -379,7 +379,7 @@ func (f Form) firstBoundary(src input, nsrc int) int {
// CGJ insertion points correctly. Luckily it doesn't have to.
for {
info := fd.info(src, i)
if info.size == 0 {
if info.isInvalid() {
return -1
}
if s := ss.next(info); s != ssSuccess {
@@ -424,7 +424,7 @@ func (f Form) nextBoundary(src input, nsrc int, atEOF bool) int {
}
fd := formTable[f]
info := fd.info(src, 0)
if info.size == 0 {
if info.isInvalid() {
if atEOF {
return 1
}
@@ -435,7 +435,7 @@ func (f Form) nextBoundary(src input, nsrc int, atEOF bool) int {
for i := int(info.size); i < nsrc; i += int(info.size) {
info = fd.info(src, i)
if info.size == 0 {
if info.isInvalid() {
if atEOF {
return i
}
@@ -465,7 +465,7 @@ func lastBoundary(fd *formInfo, b []byte) int {
if p == -1 {
return -1
}
if info.size == 0 { // ends with incomplete rune
if info.isInvalid() { // ends with incomplete rune
if p == 0 { // starts with incomplete rune
return -1
}
@@ -504,7 +504,7 @@ func lastBoundary(fd *formInfo, b []byte) int {
func decomposeSegment(rb *reorderBuffer, sp int, atEOF bool) int {
// Force one character to be consumed.
info := rb.f.info(rb.src, sp)
if info.size == 0 {
if info.isInvalid() {
return 0
}
if s := rb.ss.next(info); s == ssStarter {
@@ -528,7 +528,7 @@ func decomposeSegment(rb *reorderBuffer, sp int, atEOF bool) int {
break
}
info = rb.f.info(rb.src, sp)
if info.size == 0 {
if info.isInvalid() {
if !atEOF {
return int(iShortSrc)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1478 -1478
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -40,8 +40,8 @@ github.com/yalue/onnxruntime_go
## explicit; go 1.25.0
golang.org/x/sys/unix
golang.org/x/sys/windows
# golang.org/x/text v0.14.0
## explicit; go 1.18
# golang.org/x/text v0.40.0
## explicit; go 1.25.0
golang.org/x/text/transform
golang.org/x/text/unicode/norm
# modernc.org/libc v1.74.1