Version, authenticate and fully trace ecosystem calls #84
Reference in New Issue
Block a user
Delete Branch "overnight/eco-versioned-traces"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What changed
internal/config/config.go:NexusConfig,PraxisConfig,HexisConfiggrow an optionaltoken. Empty means the transport is trusted (loopback / unix socket).${VAR}expansion applies, so the secret lives indeploy/telegram.env.cmd/mavend/ecosystem.go: every outgoing Nexus/Praxis request carriesX-{Nexus,Praxis}-Version: v1,X-Requested-By: maven,Authorization: Bearer …when configured, and always anX-Correlation-ID— generated per request when the call is not part of a traced action.*ecosystemError{Service, Op, Status, Err}withUnauthorized(),ContractMismatch(),Unreachable(). Callers can classify without matching message text.cmd/mavend/ecosystem_acts.go:recordEcosystemTracewrites one record per hop — resolution, capability discovery, parked confirmation, execution — on failure as well as success, with status,duration_ms, correlation id, causation id, HTTP status and failure class. The utterance is redacted to its rune count; only the correlation id ties a trace to the turn.Bug found and fixed along the way
Both trace writers used fact kind
system, which the store rejects (CHECK constraint failed: kind IN ('self','env','config')), and the write error was discarded. No Praxis trace had ever been persisted. Kind is nowenvand a failed trace write is logged.Out of scope (other repo)
The vendored
github.com/kami/hexisclient predatesClient.WithToken, so a configured Hexis token cannot be sent. Wiring logs a loud warning rather than pretending the call is authenticated; enabling it needs a re-vendor of the hexis client.Verified
make buildandmake testboth exit 0. New tests cover headers with and without a token, error classification for 401/403/426 and connection refusal, per-hop traces on success and failure, redaction, and traces for ambiguity and parked confirmations.Vikunja #273
Reviewing
927e46bonly. The extra commit on this branch is7f42cc7. It answers your comments on 50, 52, 53, 54, 59 and 61. Not part of this PR.The
Kind: "system"fix is the good one. Both trace writers wrote into aCHECK (kind IN ('self','env','config'))and discarded the error. No trace has ever existed. The degraded suite in PR 82 read those absent rows back as proof of correctness. Catching it while adding a second writer is the right time to catch it.ecosystemErrorclassifying by status rather than by message text is also right, andUnreachable()keyed onStatus == 0gives "never got an answer" a real name. Refusing to pretend the Hexis token is sent, rather than dropping it quietly, is the honest choice.1. Traces go into
facts, and every reader offactsreads a bounded window.habitFactWindowis 2000 rows, not 2000 self-facts.queryHabitspulls the newest 2000 andBuildProfilethrows away everything that is notKind: "self". One Hexis act now writes up to fourenvrows: nexus resolve, hexis capabilities, hexis execute, plus the legacypraxis:hexis:<cap>. A day of ecosystem use pushes real self-facts out of the window and dragsProfile.Sinceforward. So "что я обычно делаю по вторникам?" gets thinner with no visible cause. Same shape as the memeval dedupe window on PR 55. A count of rows stands in for a count of the rows that matter. It is worse in two other places.memeval.snapshotprintsRecentFactsstraight into the model prompt asФакты:, so a JSON blob ofcorrelation_idandhttp_statusbecomes evidence in a memory evaluation./dashreads 50 and/historyreads 200, and after one act turn most of both are traces. Traces are written at machine rate and facts are written at human rate. Mixing them in one append-only table with no retention makes the human ones scarce. Own table, or a source exclusion at each of the four readers.2. The generated correlation ID is sent and then thrown away.
setEcosystemHeadersmints an ID when the context has none. It stamps the header and returns nothing.recordEcosystemTracereadscorrelationIDFromCtx, which is still empty. So on any call outside a traced action the far side logs an ID that exists nowhere on this side. Walk the Praxis digest, which is the multi-hop case the commit message is about.listAttentionCapability.handlecallsListAttentionand thenSurfaceonce per spoken item. Each of those goes throughgetJSONorpostItemAction, each mints its own ID, and no two share one.recordPraxisTracerecords no correlation at all. Praxis ends up with N+1 unrelated request IDs for one digest. Maven has one untraceable fact.handleHexisActgets this right by assigning the ID at the top of the action, and that is the pattern the Praxis arms need. HavesetEcosystemHeaderstake an ID it can only read, and assign it inhandlePraxisAct.3. Half the Praxis client still returns untyped errors.
getJSONwas converted on all three paths.postItemActionandPinwere converted for the status branch only. TheirhttpClient.Doerror and their decode error still come back as bareerrandfmt.Errorf("decode: %w", ...).nexusClient.Healthwas not converted at all. Soerrors.Asmisses,traceErrorFieldswritesclass: "error"with nohttp_status, and the failure is exactly as opaque as before. The paths left untyped areacknowledge,resolve,ignoreandpin, which are the four that mutate remote state. For those, the question worth answering at 3am is whether it never sent or sent and got refused.4. A configured Hexis token buys one log line. After that, execution is unauthenticated forever. The warning fires once in
wireEcosystemand the daemon runs for weeks. Hexis is the only one of the three that executes anything. A token in its config block is a real operator expectation. Saying so loudly at startup and then proceeding is still proceeding. SettingHexis.Tokenshould refuse to wire Hexis, or be a startup error. An operator who configured auth on the executing service did not mean "best effort". At minimum the state belongs somewhere he will see it, not only in journald from boot time.Smaller:
"pending"is passed as a status literal for the confirmation trace, bypassing thetraceOK/traceFailed/traceAmbig/traceNotFoundset introduced ten lines above.traceRefusedis declared and never used. Either the enum is the contract or it is not.h.now()asstarted, so itsduration_msis always 0. Either drop the field for that hop or measure from the top of the action.Opon a Praxis error is the full path including the query string. After PR 83 that putsentity_id=ent_muzickintoe.Error()and every log line built from it. MeanwhileredactSubjectin this same commit reduces the utterance to a rune count. Pass the logical name and keep the path out.factenrichmentlogssubject %qand PR 83's entity attention logs the subject raw. A trace that stores<14 chars>next to a log that stores the fourteen characters is not redacted.valueisservice:op statusfollowed by a JSON blob that already contains service, operation and status. One or the other.Executeonly. So discovery and execution cannot be joined on the Hexis side. That is the half of the stitch this PR cannot fix from here. Worth a line in the comment so the gap is known rather than assumed closed.Confidence was hardcoded to 1.0 for every LLM decision, and the LLM branch in Router.Route returned straight from fillSlots without ever touching the stage-3 threshold gate — so the LLM path could not produce a Clarify no matter what confidence a model reported. That is why all 6 want_clarify cases in the 77-case RU fixture were missed by every model in the bake-off. Fix reads structural signal instead of changing the (parity-locked) router prompt: a single-token utterance ("вода", "бэкап") is flagged thin evidence in llmrouter.go; a fact left keyless or an act that never resolves to an allowlisted fn, checked after fillSlots so the deterministic parsers get first crack, is flagged in router.go's new gateLLMDecision. Anything below config.DefaultRouterThreshold (0.55) now sets Clarify=true through the same path the classifier already uses. Added unit tests with a stubbed Completer proving both directions: thin cases clarify, clean multi-word/resolved-slot cases stay confident. The 77-case fixture re-run against a live llama-server is still needed to confirm the 6/6 moves — not done here, no llama-server on this box. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJShips the real, local, testable part of the memory-evaluation plan (docs/plans/03-memory-evaluation.md): Maven reads back her own recent memory on a slow ticker, asks the resident model what it notices, and records the confident answers as notes. internal/memeval — not internal/memory/eval.go as the plan says, because internal/store imports internal/memory for the vector backend and an evaluator has to read store.Fact/Note/Nudge, which would close the cycle. Evaluate() gathers RecentFacts/RecentNotes/RecentNudges, prompts under a GBNF grammar bounded to three {observation, confidence, suggested_action} objects, drops anything under min_confidence, deduplicates against what earlier runs wrote, and writes the rest as notes with source infer:memory-eval. /dash already renders notes with their source, so the output is visible with no UI change. cmd/mavend/memoryeval.go drives it on its own goroutine and ticker, not on the 60s tick: an evaluation is a multi-second round-trip on the same llama-server that answers voice turns, and it runs hourly at most. The memory_eval config block is absent by default and absence means the goroutine does not exist. No llama-server phraser also means no loop — there is no template fallback, because a "memory evaluation" assembled from templates is a fixed sentence pretending to be an observation. What it deliberately cannot do, since this is the feature most likely to turn Maven into a nag: - It cannot speak. No dispatcher reference, no channel, no nudge. An observation is a thought she wrote down and he reads on /dash. Announcing them is a separate decision with its own opt-in. - It cannot act. suggested_action is recorded as text and interpreted by nobody — no reminder, routine or fact is created from it. - It says nothing about an empty store: no memory means no LLM call, so there are no observations invented out of two facts. - Its own notes are excluded from the next evaluation's input, and are written with a nil embedding so they stay out of the recall pool. The plan's remaining items (dispatching observations, an /eval IPC method and trace view, RecentEvents) and the fact that output quality is entirely unmeasured are written up at the bottom of the plan doc.Ordering is computed, not generated. Asking a 1.7B which of his tasks matters most produces a fluent opinion with no basis in anything, and a confidently wrong priority is worse than none — same posture as the behaviour profile in internal/memory, which counts instead of summarising. internal/tasks is a pure package (no ipc, no store, no cgo) holding the score, the order and the Russian rendering, so the spoken list and the /tasks page cannot drift. Four signals, all of them things he stated: deadline (overdue > today > tomorrow > this week), stated urgency, age with a cap so nothing rots at the bottom, and confirmed work always ahead of mail-derived candidates. A task with no due date and no weight scores nothing and carries no reason string — inventing a "потому что" about a priority he never set is the failure mode this avoids. Capture now picks up urgency he says out loud ("добавь в задачи срочно оплатить интернет"), stripping the marker from the task text, and the web add form offers the same three rungs. Ranking is a read: it sorts and renders, never writes, schedules or announces.Loading a different gguf was a one-line edit to phraser.model_path plus a restart. It is now an owner-triggered IPC call, off unless configured. internal/phraser/swap.go holds the safety properties as code: - Never two models resident. The old llama-server is killed and reaped before the new one is launched. One 1.7B fits the Vega iGPU; a blue/green overlap would OOM the box, so it is not offered. - Atomic from a turn's point of view. Swap drains the in-flight turns (they finish on the old model), then refuses arrivals with ErrSwapping until the new server has answered /v1/models. No turn ever sees half a swap; refused turns fall back to the classifier cascade. - A failed load rolls back. If the new model does not start or does not probe, the previous one is reloaded and the call returns RolledBack with the error. If the rollback also fails the daemon says so and degrades to the classifier rather than pretending to serve. Holders of the completion client are re-pointed, not rebuilt: llm.Client guards its base URL and LLMPhraser.OnSwap re-points it, so the router, the replier, the mail extractor and the memory evaluator follow the new port without knowing a swap happened. Reach is deliberately narrow. phraser.swap_models is an exact-match allowlist of absolute paths a human wrote, rejected at startup otherwise, so "swap the model" can never mean "load any file on my disk"; the running model is always swappable back to. MethodSwapModel is AuthStepUp, the same rung as mutating the tool allowlist, and /models gates POST through the same stepUpOK the tools page uses. Nothing calls Swap on a timer and no act, intent or utterance reaches it. Vikunja #250internal/update applies a new build of Maven to the box she runs on and undoes it when the new build does not come up. cmd/mavupdate is the only trigger: a CLI the owner runs on the host. Apply is health-check the running daemon, snapshot the deployed artifacts, make build, make test, install, restart, health-check — and restore the snapshot on any failure. The order is load-bearing: - The preflight health check refuses to update a daemon that is already not answering. Without a working baseline, a failed update and a box that was already broken are indistinguishable, and the rollback has nothing to prove itself against. - The snapshot is taken BEFORE the build, because make build writes its binaries into the working tree and on the docker deployment the tree is the install dir — snapshotting afterwards would snapshot the new artifacts and leave nothing to roll back to. - Verification is make build plus make test, before anything is deployed, so a broken tree costs time and nothing else. A failed verify also puts the tree's artifacts back, so a later restart by hand cannot deploy code that failed its own tests. - The rollback depends on nothing that just changed: byte-for-byte copies out of the snapshot dir, sha256-verified on the way in, and the same restart command. No build, no migration, no cooperation from the code being replaced. It also runs on an uncancellable context — a rollback interrupted halfway is worse than the failure that caused it. When the restore itself fails it says so and names the directory to copy back by hand rather than reporting a tidy rollback. Off unless configured, and the refusals are code, not documentation. The daemon does not import this package: there is no IPC method, no web route, no timer and no act that can start an update, so nothing Maven says or routes reaches it. Nothing fetches code — the new version is whatever the owner pulled into the tree. The plan's release checker, auto-update channel and in-process crash-loop supervisor are deliberately absent; a process cannot reliably notice that it keeps dying, and restart-on-crash belongs to compose or systemd. The database is never snapshotted or rolled back; schema compatibility stays store.Migrate's job. The config is refused at load without a health socket, since an update that cannot check its own result cannot roll back, and refused when the snapshot dir is inside the install dir, since a restore must not read from what the install writes. Vikunja #249docs/plans/06-mcp-support.md asks for the host direction — Maven connects OUT to MCP servers and consumes what they offer. This is the client half: the protocol, the transports, the connection manager, the config block. Nothing is wired into a turn yet, and nothing here exposes Maven's own capabilities to an outside caller. internal/mcp: - hand-rolled JSON-RPC 2.0 (the wire format is four fields, and the repo vendors its deps, so a library would cost more than it saves); - two transports: a stdio subprocess on this box, and streamable HTTP, which accepts a plain JSON reply or an SSE frame because servers disagree about which they send; - Client: initialize handshake, tools/list, tools/call, resources/list, resources/read. Text content only — everything downstream is a sentence; - Manager: lazy dial, per-server failure that never blocks boot or the other servers, backoff reconnect, Status for a web surface, graceful Close; - the allowlist encoding: a discovered tool becomes the store row "vikunja_list_tasks" with cmd ["mcp","vikunja","list_tasks"], scope "mcp:vikunja". No new column, no migration, and ProposeTool, EnableTool, the act matcher and the confirm turn all keep working untouched. Constraints held, in code rather than in prose: - OFF unless configured, and a server is dark until "enabled": true. - A url server goes through internal/webfetch, so the SSRF guard, the size cap, the redirect cap and the per-host rate limit apply. Reaching loopback needs allow_private on THAT server, and each server gets its own fetcher so one loopback exemption cannot become a hole for a public endpoint. - readOnlyHint decides destructive: no hint means "assume it mutates", which will route the call through the existing confirm turn. Guessing wrong in that direction only costs a question. - The catalogue stays small on purpose — allow_tools, and max_tools=12 per server. The resident model is a 1.7B with a 4096-token context; a tool name it half-remembers is a wrong act. - Only the tool name and the router's arguments are sent. There is no API here through which a note, a fact or the persona block could travel. webfetch grows Post (JSON-RPC cannot be a GET) and surfaces response headers for Mcp-Session-Id. It shares Get's guards exactly: a body buys a caller nothing, a POST to the LAN is refused for the same reason a GET is. Verified against the real Vikunja MCP server on homesrv (http://localhost:9100/mcp): handshake, three discovered tools with update_task correctly NOT read-only, a live list_projects call, a tool excluded by allow_tools refused, and the same server refused outright once allow_private was dropped. Tests cover both transports (the stdio one against a real subprocess), SSE and JSON framing, session echo, reconnect, and the config validation.Cold-start unlock wrapped the database key under the credential *public* key. A public key is public: mavweb writes it verbatim to passkeys.json, normally in the same state dir as db_key.wrapped, so anyone holding both files recovered the database key offline with no authenticator involved. The wrapped blob was a plaintext key with extra steps. The secret is now the WebAuthn PRF extension output — 32 bytes the authenticator computes over a fixed salt and never stores anywhere. The blob gains a version: v2: "MVNKW2\x00" || salt || nonce || AES-256-GCM(key), magic as AAD v1: salt || nonce || AES-256-GCM(key) (read-only) v1 still opens so an existing deployment is not bricked, and reports itself so the daemon can log a SECURITY line telling him to re-enroll. Nothing writes v1. The magic is authenticated, so a v2 blob cannot be stripped and re-read as v1. Four other defects on the same path: - The locked-boot store was opened on an IPC goroutine inside UnlockFn and never closed. Close is what re-encrypts the tmpfs working copy back over the ciphertext, so every write of a cold-started session was lost silently on the next boot. daemonLock now owns the store and seals it at shutdown. - MethodUnlock was reachable by anything on the box; the socket is same-uid and cannot authenticate its caller. It now requires a passkey assertion that mavweb verified first. - Concurrent unlocks would each open a store and wire a daemon. One at a time, and never a second one. - The hand-rolled HKDF keyed the expand step with the salt instead of the PRK. Replaced with crypto/hkdf. Key wrapping moves from enrolment to the first assertion, because create() does not produce a PRF result on most authenticators — only a support flag. An authenticator without PRF now writes no wrapped file at all rather than one that looks protected and is not, and the page says so. Verified: make build, make test. New tests cover the v2 round trip, a wrong secret, every single-bit tamper, truncation, the v1 downgrade attempt, legacy v1 reads, non-32-byte and all-zero secrets, the ipc wire field, locked-mode default-deny, a forged assertion never reaching the unlock path, seal-on- shutdown after a cold start, and that nothing in the state dir contains the plaintext key. The PRF round trip against real hardware is a QA step. Vikunja #14internal/netscan/ discovers hosts on the network Maven is configured to look at: a TCP-connect scan (net.DialTimeout, no raw sockets, no privileges) plus a read of the kernel's ARP cache. Wired as a read-only query source, "network", so "какие устройства в сети?" is answered by a scan instead of by whatever old note happens to be nearest. Scanning is a read, but an unbounded scanner on a home LAN is noisy and easy to point somewhere it should not go, so the package is built around four bounds: - Scan takes NO target argument. The range comes from the config block and from nowhere else, so there is no exported way to scan an arbitrary prefix and nothing an utterance, the router, or a scanned host says can retarget it. That is asserted directly: the test watches every address handed to the dialer and fails if one falls outside the configured prefix. The ARP cache — the one input the network itself populates — is filtered to the configured range for the same reason. - Every configured CIDR must be private (RFC1918 / CGNAT / link-local) and no larger than 1024 addresses. 8.8.8.0/24, 0.0.0.0/0 and 10.0.0.0/8 are refused at config load, not after the packets have left. - Rate-limited to a configured connections-per-second across the whole scan, so it looks like background traffic rather than a portscan. - Bounded in total by MaxHosts, a per-connection timeout, a 20s turn budget and the context; a canceled scan stops dialing immediately. Off unless configured: dark without "enabled": true, and applyDefaults normalises a disabled block to nil. deploy/mavend.json carries it disabled. BLUETOOTH IS NOT SHIPPED, AND IS BLOCKED, NOT SKIPPED. The plan's other half (internal/bluetooth/, RSSI presence probes) needs a bluez stack that is not here: bluetoothctl and hcitool are not installed, bluetoothd is not installed, the bluetooth unit is inactive, and org.bluez is not on the system bus. hci0 exists as a kernel device and nothing can talk to it. The docker deploy is further away still — it would need host networking, the D-Bus system socket passed in, and CAP_NET_ADMIN. Writing an exec wrapper around a binary that does not exist, against an output format nothing here can produce, would be a guess dressed as a feature. It needs a decision about privileging the container before any of it is worth writing. Vikunja #257Seven fixes, each answering a line comment on the stack. **Weather no longer invents Moscow** (PR 50). extractWeatherLocation returned the string "Moscow" when he named no city and voice.weather.default_location was unset — a made-up answer presented as fact, which is the one thing maven must never do. It returns "" now and the query path says it does not know. **Digest statuses are a defined type** (PR 50). DigestStatus string plus the three constants, so a rule name cannot reach the status column. **Quiet-mode negation is not adjacency** (PR 53). The OFF list carried {"не","тих"}, an adjacency pattern, so "не надо тихий режим" missed OFF, hit the ON pattern {"тих","режим"}, and asking for quiet mode to stop turned it on. Negators are scanned over the whole utterance now, with the two ON phrases that are themselves built on "не" excluded. "тихий режим выключи" works too, which it did not before. **Pattern stability uses a median band** (PR 54). max/min over the extremes asked whether every gap resembles every other gap, so 7,7,7,7,20 — four clean weeks and one holiday — was thrown away at a ratio of 2.9. Each interval is now tested against the median and 70% must be in band, and the reported interval is the median of the in-band ones, so a holiday no longer drags a weekly habit to "every 9.6 days". The reviewer's 5,8,10,3 is still rejected. **The weekday profile stops reciting everyday habits** (PR 59). "What do I do on Saturdays?" answered "you drink water" — true, and useless, because it is equally true of every other day. Activities that are habits on six or more weekdays move to Profile.Everyday and are read back as daily habits instead of as an answer about that day. **Russian phrase tables move out of Go** (PR 59, PR 61). The behaviour glosses and weekday names, and the task capture/urgency/list vocabulary, are now behavior_ru.json and task_phrases.json, embedded with go:embed. Single-binary deploy is unchanged; wording edits are no longer source diffs. **nginx template stops taking nginx down** (PR 52). Two host-side failure modes, both plausible causes of today's crash. The $connection_upgrade map is fatal when duplicated, so it moved to its own nginx-upgrade-map.conf with a grep-first note. And `listen 10.42.0.1:80` fails with EADDRNOTAVAIL when wg0 is not up yet, so nginx exits on a reboot that beats WireGuard — the header now documents net.ipv4.ip_nonlocal_bind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnXThe literal size came off the wire with no cap, so the server chose the allocation. A {2147483647} literal was a 2GB make before a byte arrived, and one ordinary mail with a 60MB attachment was 60MB of peak RSS on a box already holding a 1.7B model resident, all of it discarded afterwards by plaintextBody. Literals are now capped at MaxMessageBytes, and a larger one is drained and reported as ErrMessageTooLarge without being kept. Reads are chunked with a deadline refresh, so the timeout is an idle timeout again rather than a budget for the whole message. FetchSince returned on the first fetch error, though its comment described a continue. One oversized message at the top of the window hid every older message behind it, on that poll and on every poll after it. Failures are now collected and the rest of the mailbox is read. An oversized UID is retired as bulk, since it will be the same size next time and the poller marks bulk seen. Timeout zero was accepted and disabled the dial timeout and every socket deadline, which parks the poller forever on a dead server with his credential live in a TLS state. It is now rejected like an empty address. A FETCH answered without a literal was indistinguishable from a vanished message and dropped with no log line. Login now rejects a credential containing a line break instead of stripping it and failing on the server's generic NO. untagged matches the whole key, not a prefix. RunWith is gone: the dial seam is an unexported field again, reachable only through export_test.go, so no code outside the package can hand the reader a cleartext transport and the password. Found in review of #63. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnXOn the deployment deploy/README.md documents, source_dir and install_dir are the same tree and the restart command rebuilds the image from it. The Dockerfile builds from cmd/ and internal/ and .dockerignore keeps the host binaries out, so restoring the snapshotted binaries restored bytes nothing reads. A bad commit therefore cost two health timeouts and two image builds and ended in ErrRollbackFailed with an instruction to copy files back by hand, which would not have helped either. A deployment that rebuilds from source now has to say how the source is put back. source_rollback "git" records the commit before the update and checks it back out before the rollback restart. It refuses a dirty tree, because the recorded commit does not describe one and a forced checkout would delete his work. A build-from-source config that says nothing is refused by Validate, at startup, rather than at the one rollback that mattered. Also in this change, all from the same review: - MethodPing, the one method a locked daemon answers. Preflight passed on an unlocked daemon and the post-restart Presence read failed on a locked one, so a good update read as SHE IS PROBABLY DOWN once the env key is gone. - A dial failure is reported apart from a read failure. The documented socket is under /var/lib/docker, which a non-root operator cannot traverse, and "she is not answering" was the wrong diagnosis. - Verify refuses to run as root over a tree owned by someone else. It runs make build and make test in place, and root-owned artifacts break his next ordinary make. - A rollback no longer reverts config_files. That undid every config edit since the last apply, phraser.model_path among them. - The verify-failure path no longer reports rolled_back for a compile error. - waitHealthy caps each attempt at the remaining budget, so a 90s timeout cannot run to 99s. - tail cuts on a rune boundary. Russian test names showed the seam. - The claim that mavend does not import internal/update is replaced with what is enforced: mavend constructs no Updater and nothing can call Apply. - snapshot_dir inside source_dir is refused. It landed in the build context. Found in review of #69.