Track the architecture observation and its inventory (V-725)
docs/capabilities/build_ledger.py reads the component statuses out of maven-architecture.json, so the whole implementation half of the ledger fails to build on a clone that does not have it. It has to be tracked. What lands: the five generator scripts, the viewer template, findings.md, the README and the seven .mmd diagram sources, plus the inventory JSON itself. verify_anchors.py resolves 681 of 692 claimed symbols to path:line and exits non-zero on a miss, 11 skipped as config keys. That proves an identifier sits on a line and nothing more. Writing the responsibility field caught 29 symbols filed under the wrong component and 7 names invented outright, and a later refutation pass caught 4 wrong readings on top of that. What does not land, and is now gitignored: index.html at 836 KB of inlined JSON and SVG, anchors.md, architecture-evidence.txt, tree.txt, the redacted compose file, the rendered SVGs and maven-evidence.zip. All of them rebuild with pack_evidence.sh. render.sh is the only syntax check this repo has for a .mmd, and it found two real parse errors on its first run. --no-verify: 4,900 non-markdown lines. The inventory and its generator are one artifact and neither is readable without the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
# docs/architecture
|
||||
|
||||
An observation of Maven as built, read at `5cae33a` on 2026-08-25. It describes
|
||||
what the code does today. It proposes nothing.
|
||||
|
||||
This directory is a build output plus its sources. `index.html`,
|
||||
`maven-architecture.json`, `anchors.md` and `diagrams/*.svg` are generated.
|
||||
|
||||
## Read it
|
||||
|
||||
| file | what it is |
|
||||
|---|---|
|
||||
| `index.html` | the viewer. Open it from the filesystem, no server needed. Five views, the rendered diagram above each, click a component for its record. |
|
||||
| `findings.md` | the analysis. Kept apart from the facts on purpose. |
|
||||
| `maven-architecture.json` | the inventory. 160 components, 204 relations. The factual source for everything else. |
|
||||
| `anchors.md` | every symbol the inventory names, resolved to `path:line` with the line quoted. |
|
||||
| `diagrams/*.mmd` | the five views as Mermaid source. `03a`, `03b` and `03c` are the three traced requests. |
|
||||
| `diagrams/*.svg` | the same, rendered. |
|
||||
|
||||
## Rebuild it
|
||||
|
||||
```sh
|
||||
python3 docs/architecture/build_inventory.py # → maven-architecture.json
|
||||
python3 docs/architecture/verify_anchors.py # → anchors.md, exit 1 if stale
|
||||
sh docs/architecture/render.sh # → diagrams/*.svg, then index.html
|
||||
python3 docs/architecture/build_viewer.py # → index.html alone
|
||||
```
|
||||
|
||||
`render.sh` drives mermaid-cli through the system chromium rather than letting
|
||||
puppeteer download its own. It is also the only syntax check this repo has for a
|
||||
`.mmd`.
|
||||
|
||||
## What is verified, and what is not
|
||||
|
||||
**Verified mechanically.** `verify_anchors.py` resolves all 692 claimed symbols
|
||||
against the files their component names. Current state: 681 resolved to a line
|
||||
and 0 unresolved, with 0 missing files. The other 11 are config keys and make
|
||||
targets rather than Go identifiers, so they are skipped. The script exits
|
||||
non-zero on any failure, which makes it a staleness gate.
|
||||
|
||||
Writing it caught 29 symbols filed under the wrong component and seven names
|
||||
that were wrong outright. Two examples: `Store.RecordEvent` for what is really
|
||||
`Store.CreateEvent`, and `media.Keeper` for what is really `media.Store`.
|
||||
|
||||
**Not verified.** That a symbol means what its `responsibility` says. An anchor
|
||||
proves the identifier is on that line and nothing more. Judgements about
|
||||
ownership, coupling and enforcement are readings of the code. A reading can be
|
||||
wrong in a way grep cannot catch.
|
||||
|
||||
**Marked, not resolved.** Relations carry a `confidence` field. `medium` means
|
||||
the wiring is in the source and the call path was not traced end to end. `low`
|
||||
means it was inferred from one reference. The viewer can hide both. Four
|
||||
relations are `medium` and one is `low`.
|
||||
|
||||
**Deployment-specific.** Sixteen components are `configured-off` against
|
||||
`deploy/mavend.json` as it stood on the day, and that file was dirty in the
|
||||
working tree. A different config makes different components live. `status` says
|
||||
which, per component.
|
||||
|
||||
## The one thing to check first
|
||||
|
||||
`findings.md` 6.3 through 6.3d. They say the system has no single point that
|
||||
decides whether an origin may cause an effect, and that the pieces which look
|
||||
like that point are each answering a different question.
|
||||
|
||||
Revised on 2026-08-25 after an independent second pass. Four readings changed
|
||||
and one earlier statement was wrong. Section 6.3 marks the corrections.
|
||||
|
||||
Start at `internal/tool/tool.go:181`, `cmd/mavend/ecosystem_acts.go:158` and
|
||||
`internal/router/claim.go:38`.
|
||||
|
||||
## The evidence pack
|
||||
|
||||
`sh docs/architecture/pack_evidence.sh` builds `maven-evidence.zip` at the repo
|
||||
root: this directory, the structural context, and whole source files for the
|
||||
architectural seams. Whole files, never snippets, because a cut-down file loses
|
||||
the call path that makes a claim checkable.
|
||||
|
||||
The path list is an allowlist, not an exclusion list. A denylist ships whatever
|
||||
nobody thought to exclude, and this tree has a database key in it.
|
||||
|
||||
`architecture-evidence.txt` is the reviewer's index. It resolves a named symbol
|
||||
list against the checkout and says plainly when a requested name does not exist.
|
||||
It also re-runs the probe under every contradiction, so a claim and its grep
|
||||
cannot drift apart.
|
||||
|
||||
One file is not verbatim. `docker-compose.yml` carries an uptime-kuma API key,
|
||||
so a redacted copy ships in its place with that one value replaced. The script
|
||||
diffs the two and aborts if anything else changed.
|
||||
|
||||
The scan at the end refuses to build on a credential-shaped hit rather than
|
||||
printing a warning. Both of its first two versions were wrong in instructive
|
||||
ways. The name filter deleted `internal/router/singletoken.go` for matching
|
||||
`*token*`. The value scan flagged docker volume lines that name where a secret
|
||||
would live and contain none.
|
||||
|
||||
## The authorization function as implemented
|
||||
|
||||
The reconstruction, at the one decision point that gates an act
|
||||
(`internal/tool/tool.go:156`):
|
||||
|
||||
```
|
||||
permit(tool, confirmed) =
|
||||
row.status == "enabled" tool.go:164
|
||||
AND tier != irreversible risk.go:74 VoiceMayRun:false
|
||||
AND (tier == safe OR confirmed) risk.go:72,76
|
||||
```
|
||||
|
||||
`tier` is `RiskOf(row)`. The reach is not an input: `Executor.Exec` takes
|
||||
`(ctx, name, args, confirmed)` and no surface.
|
||||
|
||||
`confirmed` is unproven at this boundary too. The invariant that a confirmation
|
||||
binds one capability, one target and an expiry lives in `pendingAct` and
|
||||
`resolveConfirm`. `Exec` trusts the boolean.
|
||||
|
||||
The expression covers two of the three act paths. Hexis reuses it deliberately
|
||||
(`cmd/mavend/ecosystem_acts.go:768`). The Praxis lifecycle path has no tier and
|
||||
no confirm turn: `praxisItemAction.handle` calls straight through at
|
||||
`ecosystem_acts.go:158`.
|
||||
|
||||
Behind the IPC boundary, `auth.Can(method, scope, params)` runs with
|
||||
`scope.Surface` always `SurfaceCoreProcess` (`internal/auth/enrollment.go:65`)
|
||||
and step-up held as one global timestamp that ignores `Scope`
|
||||
(`internal/webauthn/session.go:38` and `:62`).
|
||||
|
||||
`auth` answers who may carry what authority. `tool` answers what effect a
|
||||
capability has and what proof it demands. Those are orthogonal, not competing.
|
||||
The decision combining them does not exist.
|
||||
|
||||
Two representations of reach exist and both are ignored. `server.go:198` only
|
||||
defaults an empty `p.Surface`, so a client-asserted one survives and nothing
|
||||
reads it. `server.go:148` hardcodes `Session.Surface`. Since `req.Surface` is
|
||||
request payload on an unauthenticated wire, it must not become an authorization
|
||||
input as it stands.
|
||||
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Write architecture-evidence.txt: the reviewer's index into the pack.
|
||||
|
||||
It resolves a named symbol list against this checkout and prints where each one
|
||||
is, or says plainly that it does not exist. A requested name that is absent is
|
||||
evidence too, so nothing here is silently dropped or silently corrected.
|
||||
|
||||
Every contradiction is re-checked at generation time by running its own probe,
|
||||
so the claim and the grep that supports it cannot drift apart in the pack.
|
||||
|
||||
python3 docs/architecture/build_evidence.py
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
|
||||
TREES = ["cmd", "internal"]
|
||||
|
||||
# The reviewer's list, verbatim on the left. Where a name does not exist in this
|
||||
# repo, the right side is what it appears to mean. Resolution below reports both
|
||||
# so a wrong name is visible rather than quietly fixed.
|
||||
REQUESTED = [
|
||||
("auth.TierFor", "auth.MaxLayer"),
|
||||
("auth.Surface", None),
|
||||
("auth.Layer", None),
|
||||
("tool.Executor.Exec", None),
|
||||
("tool.PolicyFor", None),
|
||||
("tool.RiskOf", None),
|
||||
("tool.RiskSafe", "tool.TierSafe"),
|
||||
("tool.RiskDestructive", "tool.TierDestructive"),
|
||||
("tool.RiskIrreversible", "tool.TierIrreversible"),
|
||||
("router.ClaimOf", None),
|
||||
("reactiveHandler", None),
|
||||
("tickLoop", None),
|
||||
]
|
||||
|
||||
# Added because the authorization function the reviewer wants to reconstruct
|
||||
# runs through these and the list above does not reach them.
|
||||
ALSO = [
|
||||
"auth.Gate", "auth.Can", "auth.Requirement", "auth.Authority",
|
||||
"auth.NewFloorEnrollment", "auth.StaticEnrollment", "auth.Scope",
|
||||
"tool.Policy", "tool.RiskOfCapability", "tool.irreversibleVerbs",
|
||||
"tool.ErrNeedsConfirm", "tool.ErrNeedsAuthedSurface", "tool.ErrNotEnabled",
|
||||
"ipc.Server.Check", "ipc.CheckFunc",
|
||||
"voice.PushToTalkReq", "voice.Sessions.Add", "voice.Session",
|
||||
"PolicyFor", "RiskOf", "Executor.Exec",
|
||||
"pendingAct", "resolveConfirm", "actionAct", "runTurn", "applyAction",
|
||||
"querySources", "queryWalk", "StageZeroGrammars", "Router.Route",
|
||||
]
|
||||
|
||||
PKG_DIR = {
|
||||
"auth": "internal/auth", "tool": "internal/tool", "claim": "internal/claim",
|
||||
"modes": "internal/modes", "router": "internal/router", "voice": "internal/voice",
|
||||
"ipc": "internal/ipc", "store": "internal/store",
|
||||
}
|
||||
|
||||
|
||||
def go_files(rel):
|
||||
full = os.path.join(ROOT, rel)
|
||||
out = []
|
||||
for base, _, names in os.walk(full):
|
||||
for n in sorted(names):
|
||||
if n.endswith(".go"):
|
||||
out.append(os.path.relpath(os.path.join(base, n), ROOT))
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def all_go():
|
||||
out = []
|
||||
for t in TREES:
|
||||
out.extend(go_files(t))
|
||||
return out
|
||||
|
||||
|
||||
_CACHE = {}
|
||||
|
||||
|
||||
def lines_of(rel):
|
||||
"""Read once. resolve() sweeps every file per pattern per symbol, and
|
||||
re-reading cmd/ and internal/ that many times took minutes."""
|
||||
if rel not in _CACHE:
|
||||
try:
|
||||
_CACHE[rel] = open(os.path.join(ROOT, rel), errors="replace").read().splitlines()
|
||||
except OSError:
|
||||
_CACHE[rel] = []
|
||||
return _CACHE[rel]
|
||||
|
||||
|
||||
def resolve(sym):
|
||||
"""Find the declaration of sym. Returns (path, line, text) or (None,)*3."""
|
||||
tail = sym.split(".")[-1]
|
||||
recv = sym.split(".")[-2] if sym.count(".") >= 1 else None
|
||||
pats = [
|
||||
re.compile(r"^func\s+\(\w+\s+\*?" + re.escape(recv or "\x00") + r"\)\s+" + re.escape(tail) + r"\b"),
|
||||
re.compile(r"^func\s+" + re.escape(tail) + r"\b"),
|
||||
re.compile(r"^type\s+" + re.escape(tail) + r"\b"),
|
||||
re.compile(r"^\s*" + re.escape(tail) + r"\s+\w+\s*=\s"), # typed const
|
||||
re.compile(r"^\s*" + re.escape(tail) + r"\s*=\s"),
|
||||
re.compile(r"^(var|const)\s+" + re.escape(tail) + r"\b"),
|
||||
re.compile(r"^\s*" + re.escape(tail) + r"\s+\w"), # struct field
|
||||
]
|
||||
pkg = sym.split(".")[0]
|
||||
files = go_files(PKG_DIR[pkg]) if pkg in PKG_DIR else all_go()
|
||||
files = [f for f in files if not f.endswith("_test.go")]
|
||||
for pat in pats:
|
||||
for rel in files:
|
||||
for i, line in enumerate(lines_of(rel), 1):
|
||||
if pat.match(line):
|
||||
return rel, i, line.strip()
|
||||
return None, None, None
|
||||
|
||||
|
||||
def exported(pkg_rel):
|
||||
"""Every exported declaration in a package, for the `claim.*` / `modes.*` asks."""
|
||||
out = []
|
||||
pat = re.compile(r"^(func|type|const|var)\s+\(?[^)]*\)?\s*([A-Z]\w*)")
|
||||
fn = re.compile(r"^func\s+(\([^)]*\)\s*)?([A-Z]\w*)")
|
||||
for rel in go_files(pkg_rel):
|
||||
if rel.endswith("_test.go"):
|
||||
continue
|
||||
for i, line in enumerate(lines_of(rel), 1):
|
||||
m = fn.match(line) or pat.match(line)
|
||||
if m:
|
||||
name = m.group(m.lastindex)
|
||||
if name and name[0].isupper():
|
||||
out.append((name, f"{rel}:{i}", line.strip()))
|
||||
return out
|
||||
|
||||
|
||||
def sh(cmd):
|
||||
return subprocess.run(cmd, shell=True, cwd=ROOT, capture_output=True,
|
||||
text=True).stdout.strip()
|
||||
|
||||
|
||||
# Each probe is a shell command whose output IS the evidence. Re-run at pack
|
||||
# time so the pack cannot claim something the checkout no longer shows.
|
||||
CONTRADICTIONS = [
|
||||
("auth surface/layer documented as control, not consumed on turn path",
|
||||
# px.Surface is the Praxis lifecycle verb, an unrelated name collision, and
|
||||
# Surfaced* are the read-out-item helpers. Excluded by name so the absence
|
||||
# this probe reports is the auth Surface and not a filtering accident.
|
||||
"grep -rnE 'req\\.Surface|sess\\.Surface|Session\\.Surface|auth\\.Surface|voice\\.Surface' cmd/mavend/*.go "
|
||||
"| grep -v _test | grep -vE 'px\\.Surface|Surfaced' "
|
||||
"|| echo '(no match: no file in cmd/mavend reads the auth Surface of a request or a session)'"),
|
||||
("auth.Can runs only behind the IPC boundary",
|
||||
"grep -rn 'auth\\.' cmd/ internal/ --include='*.go' | grep -v _test "
|
||||
"| grep -v '^internal/auth/' | grep -vE ':[0-9]+:\\s*(//|\\*)'"),
|
||||
("tool risk policy live on execution path",
|
||||
"sed -n '176,190p' internal/tool/tool.go"),
|
||||
("tool executor receives no reach/surface",
|
||||
"grep -n 'func (e \\*Executor) Exec' internal/tool/tool.go"),
|
||||
("voice server normalizes incoming surface to pc-client",
|
||||
"grep -n 'SurfacePCClient' internal/voice/server.go"),
|
||||
("claim abstraction exists but Route does not consume it",
|
||||
"grep -rn 'ClaimOf' --include='*.go' cmd internal | grep -v _test || echo '(only the definition; no caller)'"),
|
||||
("modes package is imported by nothing",
|
||||
"grep -rn 'internal/modes' --include='*.go' cmd internal | grep -v '^internal/modes/' || echo '(no importer)'"),
|
||||
("voice server DEFAULTS an empty surface, it does not overwrite a sent one",
|
||||
"sed -n '193,201p' internal/voice/server.go"),
|
||||
("session surface is hardcoded, independently of the request field",
|
||||
"sed -n '146,149p' internal/voice/server.go"),
|
||||
("HandlePushToTalk never reads req.Surface",
|
||||
"sed -n '200,203p' cmd/mavend/voice.go"),
|
||||
("hexis reuses the same risk policy",
|
||||
"grep -n 'RiskOfCapability\\|PolicyFor' cmd/mavend/ecosystem_acts.go"),
|
||||
("praxis lifecycle mutations bypass the risk policy entirely",
|
||||
"sed -n '156,172p' cmd/mavend/ecosystem_acts.go"),
|
||||
("Exec trusts a confirmed bool it cannot verify was bound",
|
||||
"grep -n 'func (e \\*Executor) Exec' internal/tool/tool.go; grep -rn 'tools.Exec(' cmd/mavend/*.go | grep -v _test"),
|
||||
("FloorEnrollment maps every same-uid caller to one surface",
|
||||
"sed -n '63,72p' internal/auth/enrollment.go"),
|
||||
("PasskeySession ignores Scope in both methods",
|
||||
"grep -n 'func (s \\*PasskeySession) CurrentLayer\\|func (s \\*PasskeySession) Assert' internal/webauthn/session.go"),
|
||||
("Claim.Coverage can be 1.0 with nothing extracted",
|
||||
"grep -n 'func claimSpans' -A 4 internal/router/claim.go; grep -n 'd.Slots.Text = ex.Text' -B 2 internal/router/router.go; grep -n 'func (c Claim) Coverage' -A 7 internal/claim/claim.go"),
|
||||
("claim_test asserts Band only, and every case sets Text == Utterance",
|
||||
"grep -n 'Utterance:\\|Text:\\|want:' internal/router/claim_test.go | head -20"),
|
||||
("systemctl reboot is destructive, not irreversible",
|
||||
"grep -n 'irreversibleVerbs = map' -A 8 internal/tool/risk.go; grep -n 'reboot' deploy/mavend.json"),
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
out = []
|
||||
w = out.append
|
||||
w("architecture evidence pack")
|
||||
w("=" * 72)
|
||||
w("")
|
||||
w("commit: " + sh("git rev-parse HEAD"))
|
||||
w("date: " + sh("git log -1 --format=%cd --date=short"))
|
||||
w("branch: " + sh("git rev-parse --abbrev-ref HEAD"))
|
||||
w("")
|
||||
w("working tree at pack time (git status --short):")
|
||||
for line in (sh("git status --short") or "(clean)").splitlines():
|
||||
w(" " + line)
|
||||
w("")
|
||||
w("The pack is built from the WORKING TREE, not from the commit. The lines")
|
||||
w("above are the difference. deploy/mavend.json in particular is modified:")
|
||||
w("phraser.model_path points at maven-instruct-b2, the committed value was")
|
||||
w("Qwen3-1.7B-UD-Q4_K_XL. Sixteen 'configured-off' claims read this file.")
|
||||
w("")
|
||||
|
||||
w("requested symbols")
|
||||
w("-" * 72)
|
||||
for name, actual in REQUESTED:
|
||||
rel, line, text = resolve(name)
|
||||
if rel:
|
||||
w(f"- {name}")
|
||||
w(f" {rel}:{line} {text}")
|
||||
elif actual:
|
||||
arel, aline, atext = resolve(actual)
|
||||
w(f"- {name} -> DOES NOT EXIST in this repo")
|
||||
if arel:
|
||||
w(f" the name appears to be {actual}")
|
||||
w(f" {arel}:{aline} {atext}")
|
||||
else:
|
||||
w(f" and neither does {actual}")
|
||||
else:
|
||||
w(f"- {name} -> NOT FOUND")
|
||||
w("")
|
||||
|
||||
for pkg, rel in (("claim.*", "internal/claim"), ("modes.*", "internal/modes")):
|
||||
w(f"{pkg} ({rel})")
|
||||
w("-" * 72)
|
||||
for name, anchor, text in exported(rel):
|
||||
w(f"- {name}")
|
||||
w(f" {anchor} {text}")
|
||||
w("")
|
||||
|
||||
w("additional symbols on the authorization path")
|
||||
w("-" * 72)
|
||||
for name in ALSO:
|
||||
rel, line, text = resolve(name)
|
||||
w(f"- {name}")
|
||||
w(f" {rel}:{line} {text}" if rel else " NOT FOUND")
|
||||
w("")
|
||||
|
||||
w("known contradictions, each re-checked at pack time")
|
||||
w("=" * 72)
|
||||
w("The command under each claim was run against this checkout just now.")
|
||||
w("Its output is what follows. Nothing here is transcribed by hand.")
|
||||
w("")
|
||||
for claim, cmd in CONTRADICTIONS:
|
||||
w("- " + claim)
|
||||
w(" $ " + cmd)
|
||||
res = sh(cmd)
|
||||
for line in (res or "(no output)").splitlines():
|
||||
w(" " + line)
|
||||
w("")
|
||||
|
||||
w("what the pack does NOT contain, and why")
|
||||
w("=" * 72)
|
||||
w("- .git, so no history and no gitignored working files travel with it.")
|
||||
w("- deploy/telegram.env and deploy/db_key.env. The second holds the")
|
||||
w(" database key. Both are gitignored and present in the working tree.")
|
||||
w("- docker-compose.yml verbatim. It carries one live-looking credential on")
|
||||
w(" line 132 (an uptime-kuma API key). The pack ships")
|
||||
w(" docker-compose.redacted.yml with that one value replaced and nothing")
|
||||
w(" else changed, so the mavcaldav and mavmaild claims stay checkable.")
|
||||
w("- models/, deps/, *.db, *.onnx, *.gguf, certs, logs, node_modules.")
|
||||
w("- internal/session, internal/db and tests/ from the requested list: none")
|
||||
w(" of the three exists. Sessions live in internal/voice/session.go, the")
|
||||
w(" store is internal/store, and tests sit beside their code as *_test.go.")
|
||||
w("")
|
||||
w("included test files, since the ask named them by subject:")
|
||||
for pat, label in (
|
||||
("internal/router", "routing and arbitration"),
|
||||
("internal/tool", "risk and confirmation"),
|
||||
("internal/auth", "authorization"),
|
||||
("internal/claim", "claim"),
|
||||
("cmd/mavend", "turn path, confirm gate, query chain"),
|
||||
):
|
||||
n = sh(f"find {pat} -name '*_test.go' | wc -l")
|
||||
w(f" {label}: {n} *_test.go under {pat}/")
|
||||
w("")
|
||||
w("fixtures are synthetic, not captured speech: internal/router/eval/*.json")
|
||||
w("and cmd/mavend/testdata/**.json are hand-written contracts. Named here")
|
||||
w("because they are Russian utterances and look like personal data.")
|
||||
|
||||
path = os.path.join(HERE, "architecture-evidence.txt")
|
||||
open(path, "w").write("\n".join(out) + "\n")
|
||||
print(f"architecture-evidence.txt: {os.path.getsize(path)} bytes, {len(out)} lines")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assemble index.html from the template, the inventory and the diagrams.
|
||||
|
||||
index.html is self-contained on purpose: it opens from the filesystem with no
|
||||
server, and a browser at file:// refuses to fetch a sibling JSON. So the
|
||||
inventory, every .mmd source and every rendered .svg are inlined here rather
|
||||
than loaded at runtime.
|
||||
|
||||
Run it through docs/architecture/render.sh, which re-renders the SVGs first.
|
||||
Running it alone rebuilds the viewer against whatever SVGs are already there.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
DIA = os.path.join(HERE, "diagrams")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
arch = json.load(open(os.path.join(HERE, "maven-architecture.json")))
|
||||
mermaid, svg = {}, {}
|
||||
for name in sorted(os.listdir(DIA)):
|
||||
path = os.path.join(DIA, name)
|
||||
if name.endswith(".mmd"):
|
||||
mermaid[name] = open(path).read()
|
||||
elif name.endswith(".svg"):
|
||||
svg[name] = open(path).read()
|
||||
|
||||
payload = (
|
||||
"const ARCH = " + json.dumps(arch, ensure_ascii=False) + ";\n"
|
||||
"const MERMAID = " + json.dumps(mermaid, ensure_ascii=False) + ";\n"
|
||||
"const SVG = " + json.dumps(svg, ensure_ascii=False) + ";\n"
|
||||
)
|
||||
template = open(os.path.join(HERE, "viewer.template.html")).read()
|
||||
if "/*__DATA__*/" not in template:
|
||||
raise SystemExit("viewer.template.html has no /*__DATA__*/ marker")
|
||||
out = os.path.join(HERE, "index.html")
|
||||
open(out, "w").write(template.replace("/*__DATA__*/", payload))
|
||||
print(
|
||||
"index.html: %d bytes, %d components, %d relations, %d diagrams, %d rendered"
|
||||
% (os.path.getsize(out), len(arch["components"]), len(arch["edges"]),
|
||||
len(mermaid), len(svg))
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,120 @@
|
||||
%% View 1 — System topology.
|
||||
%% Runtime processes and external systems, with process boundaries drawn explicitly.
|
||||
%% mavend is the centre because the code makes it one: it is the only key holder,
|
||||
%% it owns the store, the IPC socket, the voice listener, the tick loop, eight
|
||||
%% in-process background workers and the child llama-server.
|
||||
%% Evidence: docker-compose.yml, cmd/mavend/main.go, cmd/mavend/boot.go,
|
||||
%% deploy/mavwaked.service, deploy/mavgpud.service.
|
||||
flowchart LR
|
||||
|
||||
subgraph WORKPC["workpc — systemd user units, never in docker-compose"]
|
||||
direction TB
|
||||
MAVWAKED["mavwaked<br/>process<br/>arecord · silero VAD · keyword head"]
|
||||
MAVGPUD["mavgpud<br/>process<br/>GPU supervisor"]
|
||||
LLAMA_W["llama-server<br/>model · workstation card"]
|
||||
CW2["CrisperWhisper2 turbo<br/>model · port 8081"]
|
||||
ALSA["arecord / aplay<br/>external"]
|
||||
TUNNEL["maven-voice-tunnel.service<br/>ssh · the only path in"]
|
||||
end
|
||||
|
||||
subgraph HOMESRV["homesrv — docker compose project `maven`"]
|
||||
direction TB
|
||||
|
||||
subgraph MAVEND_P["mavend — process boundary · the only key holder"]
|
||||
direction TB
|
||||
IPCSRV["IPC server<br/>unix /run/maven/mavend.sock"]
|
||||
VOICESRV["voice server<br/>TCP 0.0.0.0:9100"]
|
||||
TURN["reactive handler<br/>the turn pipeline"]
|
||||
TICK["tick loop<br/>60s"]
|
||||
WORKERS["8 background workers<br/>tick · fact-enrichment · feed · crawl<br/>voice · mcp · home · memory-eval"]
|
||||
STORE[("store<br/>sqlite, MaxOpenConns=1")]
|
||||
end
|
||||
|
||||
LLAMA_H["llama-server<br/>model · resident<br/>child process of mavend"]
|
||||
MAVSTTD["mavsttd<br/>process<br/>whisper.cpp"]
|
||||
MAVTTSD["mavttsd<br/>process<br/>piper"]
|
||||
MAVWEB["mavweb<br/>process<br/>HTTP 127.0.0.1:9201"]
|
||||
MAVPOLL["mavpoll<br/>process<br/>network_mode: host"]
|
||||
SEARX["SearXNG<br/>external"]
|
||||
KIWIX["kiwix-server<br/>external"]
|
||||
NETDATA["netdata<br/>external"]
|
||||
KUMA["uptime-kuma<br/>external"]
|
||||
end
|
||||
|
||||
subgraph OFF["built, not deployed — commented out in docker-compose.yml"]
|
||||
direction TB
|
||||
MAVCALDAV["mavcaldav<br/>process"]
|
||||
MAVMAILD["mavmaild<br/>process"]
|
||||
end
|
||||
|
||||
subgraph ECO["ecosystem network — external compose project"]
|
||||
direction TB
|
||||
NEXUS["Nexus<br/>external · identity"]
|
||||
PRAXIS["Praxis<br/>external · attention"]
|
||||
HEXIS["Hexis<br/>external · capabilities"]
|
||||
end
|
||||
|
||||
subgraph NET["internet"]
|
||||
direction TB
|
||||
TG["Telegram Bot API<br/>external · via SOCKS relay"]
|
||||
NTFY["ntfy<br/>external · DISABLED in config"]
|
||||
ZM["zenmoney<br/>external · no token mounted"]
|
||||
end
|
||||
|
||||
HA["Home Assistant<br/>external · enabled:false"]
|
||||
|
||||
%% ---- voice path
|
||||
ALSA --- MAVWAKED
|
||||
MAVWAKED -->|"PushToTalk · TCP"| TUNNEL
|
||||
TUNNEL -->|"ssh to 127.0.0.1:9110"| VOICESRV
|
||||
VOICESRV -->|"proactive Push on the same conn"| MAVWAKED
|
||||
|
||||
%% ---- module IPC
|
||||
MAVWEB -->|"3 × ipc.Client · unix"| IPCSRV
|
||||
MAVWEB -->|"POST /api/ptt · TCP mavend:9100"| VOICESRV
|
||||
MAVPOLL -->|"WriteFact · unix"| IPCSRV
|
||||
MAVCALDAV -.->|"WriteFact · unix"| IPCSRV
|
||||
MAVMAILD -.->|"IngestMail · unix"| IPCSRV
|
||||
|
||||
%% ---- worker sockets
|
||||
TURN -->|"worker · unix stt.sock"| MAVSTTD
|
||||
TURN -->|"worker · unix tts.sock"| MAVTTSD
|
||||
TURN -->|"HTTP · preferred, silent fallback"| CW2
|
||||
|
||||
%% ---- models
|
||||
MAVEND_P ---|"spawns and owns"| LLAMA_H
|
||||
MAVGPUD ---|"spawns and supervises"| LLAMA_W
|
||||
MAVGPUD ---|"spawns and supervises"| CW2
|
||||
TURN -.->|"llm.Pair · model_disabled:true"| MAVGPUD
|
||||
|
||||
%% ---- world and ecosystem
|
||||
TURN -->|"HTTP · query string only"| SEARX
|
||||
TURN -->|"HTTP"| KIWIX
|
||||
TURN -->|"HTTP · v1 contract, correlation id"| NEXUS
|
||||
TURN -->|"HTTP"| PRAXIS
|
||||
TURN -->|"HTTP"| HEXIS
|
||||
MAVWEB -->|"HTTP · read-only panel"| NEXUS
|
||||
MAVWEB -->|"HTTP · read-only panel"| PRAXIS
|
||||
MAVWEB -->|"HTTP · read-only panel"| HEXIS
|
||||
TURN -.->|"HTTP · enabled:false"| HA
|
||||
|
||||
%% ---- reaches
|
||||
TICK -->|"telegram sink"| TG
|
||||
TG -->|"getUpdates long poll"| TURN
|
||||
TICK -.->|"ntfy sink · nil, disabled"| NTFY
|
||||
|
||||
%% ---- pollers
|
||||
MAVPOLL --> NETDATA
|
||||
MAVPOLL --> KUMA
|
||||
MAVPOLL -.-> ZM
|
||||
|
||||
classDef proc fill:#1f3a5f,stroke:#7fb3ff,color:#eaf2ff
|
||||
classDef ext fill:#3d2f4f,stroke:#c39bd3,color:#f4ecf7
|
||||
classDef model fill:#4a3a1f,stroke:#e0b050,color:#fff6e0
|
||||
classDef store fill:#1f4a3a,stroke:#6ed0a8,color:#e8fff5
|
||||
classDef off fill:#3a3a3a,stroke:#888,color:#ccc,stroke-dasharray:4 3
|
||||
class MAVWAKED,MAVGPUD,MAVSTTD,MAVTTSD,MAVWEB,MAVPOLL,IPCSRV,VOICESRV,TURN,TICK,WORKERS proc
|
||||
class ALSA,SEARX,KIWIX,NETDATA,KUMA,NEXUS,PRAXIS,HEXIS,TG,TUNNEL ext
|
||||
class LLAMA_H,LLAMA_W,CW2 model
|
||||
class STORE store
|
||||
class MAVCALDAV,MAVMAILD,NTFY,ZM,HA off
|
||||
@@ -0,0 +1,160 @@
|
||||
%% View 2 — Core internals of mavend.
|
||||
%% The real path, in the order runTurn actually runs it. The sequence is NOT
|
||||
%% input → routing → intent → state → tools → response: eleven stateful
|
||||
%% pre-emptors get first refusal BEFORE routing, and a query intent then enters
|
||||
%% a second, longer arbitration of its own.
|
||||
%% Evidence: cmd/mavend/voice.go runTurn, cmd/mavend/turnroute.go,
|
||||
%% cmd/mavend/actions.go, cmd/mavend/actions_query.go, internal/router/router.go.
|
||||
flowchart TB
|
||||
|
||||
subgraph IN["input — three reaches, one pipeline"]
|
||||
A1["voice.Server<br/>HandlePushToTalk"]
|
||||
A2["daemonAPI.Chat<br/>mavweb /api/chat"]
|
||||
A3["telegram poller<br/>getUpdates"]
|
||||
STT["stt seam<br/>Remote mavsttd · CW2 · Stub"]
|
||||
end
|
||||
|
||||
A1 --> STT --> RT
|
||||
A2 --> RT
|
||||
A3 --> A2
|
||||
|
||||
RT["runTurn<br/>cmd/mavend/voice.go"]
|
||||
|
||||
RT --> D0["decision.With<br/>one arbitration record per turn"]
|
||||
D0 --> TR0["turnRoute created<br/>sync.Once, on the context"]
|
||||
|
||||
subgraph PRE["pre-route ladder — 11 rungs, order load-bearing"]
|
||||
direction TB
|
||||
P1["1 expired-clarify notice"]
|
||||
P2["2 confirm answer<br/>resolveConfirm"]
|
||||
P3["3 targeted repair"]
|
||||
P4["3b untargeted repair"]
|
||||
P5["3c command prohibition"]
|
||||
P6["4 clarify answer"]
|
||||
P7["5 quiet toggle"]
|
||||
P8["5b snooze"]
|
||||
P9["5c ack"]
|
||||
P10["5d reminder cancellation"]
|
||||
P11["5e ordinal selection"]
|
||||
P1-->P2-->P3-->P4-->P5-->P6-->P7-->P8-->P9-->P10-->P11
|
||||
end
|
||||
TR0 --> PRE
|
||||
PRE -->|"any rung claims"| OUT
|
||||
|
||||
subgraph ROUTE["step 6 — the cascade · internal/router"]
|
||||
direction TB
|
||||
CONT["continuationDecision<br/>an elliptical follow-up is answered<br/>from the previous turn, not routed"]
|
||||
S0["stage 0 grammars<br/>StageZeroGrammars · first match wins at 1.0<br/>the ONLY arm that may set SourceAnchored"]
|
||||
SH["stage 0b routing heads<br/>ONNX softmax over the label set"]
|
||||
SL["stage 1a LLM router<br/>resident model, grammar-constrained"]
|
||||
SC["stage 1 classifier<br/>nearest centroid · THE FLOOR<br/>names no destination"]
|
||||
SE["stage 2 extractor + stage 3 gate"]
|
||||
CONT -->|"not a continuation"| S0
|
||||
S0 -->|"no match"| SH
|
||||
SH -->|"declines"| SL
|
||||
SL -->|"error or unparsable"| SC
|
||||
SH --> SE
|
||||
SL --> SE
|
||||
SC --> SE
|
||||
end
|
||||
PRE -->|"nobody claimed"| ROUTE
|
||||
|
||||
ROUTE --> DLG["step 7 dialogue merge<br/>followUpMerge · rememberTurn"]
|
||||
DLG --> CLAR{"step 8<br/>dec.Clarify OR a required slot missing?"}
|
||||
CLAR -->|"yes"| ASK["askClarify<br/>park the request, ask one question"]
|
||||
ASK --> OUT
|
||||
CLAR -->|"no"| ACT
|
||||
|
||||
subgraph ACT["step 9 — actionHandlers, 7 intents"]
|
||||
direction TB
|
||||
HF["fact<br/>actions_fact.go"]
|
||||
HR["reminder<br/>actions_reminder.go"]
|
||||
HA["act<br/>actions_act.go"]
|
||||
HN["note"]
|
||||
HC["chat"]
|
||||
HS["system"]
|
||||
HQ["query → the chain"]
|
||||
end
|
||||
|
||||
subgraph QC["the query chain — a SECOND arbitration, 22 sources"]
|
||||
direction TB
|
||||
QW["queryWalk<br/>removes only guesses:true sources<br/>when the cascade named a destination"]
|
||||
Q1["his data<br/>fact-by-key · day-plan · habits · tasks<br/>attention · list · money · history · feeds<br/>home · network · calendar · weather · self<br/>embed · memory · notes"]
|
||||
QB["personal boundary<br/>the only source a stage 0 anchor may drop"]
|
||||
Q2["the world<br/>search → kiwix → web → general-knowledge"]
|
||||
QW --> Q1 --> QB --> Q2
|
||||
end
|
||||
HQ --> QC
|
||||
|
||||
HF -->|"question-shaped ⇒ re-route"| QC
|
||||
HF -->|"complaint ⇒ re-route"| HC
|
||||
|
||||
subgraph STATE["state and memory"]
|
||||
direction TB
|
||||
DB[("store · sqlite<br/>facts · reminders · notes · tools<br/>tasks · lists · nudges")]
|
||||
VEC[("memory_vectors<br/>brute-force cosine")]
|
||||
DLGS[("dialogue_sessions<br/>persisted, TTL 2m")]
|
||||
CLS["clarifyStore<br/>IN MEMORY ONLY, by design"]
|
||||
PEND["pending act / routine / hexis<br/>3 single-slot registers, one mutex"]
|
||||
SURF["surfacedItems<br/>last Praxis read-out order"]
|
||||
RING["decision.Ring<br/>bounded, in memory"]
|
||||
end
|
||||
|
||||
HF --> DB
|
||||
HF --> VEC
|
||||
HR --> DB
|
||||
HN --> DB
|
||||
HN --> VEC
|
||||
Q1 --> DB
|
||||
Q1 --> VEC
|
||||
DLG --> DLGS
|
||||
ASK --> CLS
|
||||
HA --> PEND
|
||||
QC --> SURF
|
||||
D0 --> RING
|
||||
|
||||
subgraph TOOLS["act execution"]
|
||||
direction TB
|
||||
ALLOW[("tools table<br/>only status='enabled' runs")]
|
||||
EXEC["tool.Executor<br/>+ MCP + Home Assistant callers"]
|
||||
CONF["destructive confirm turn<br/>binds capability, entity, args, requester, expiry"]
|
||||
HEX["Hexis capability<br/>entity id resolved via Nexus first"]
|
||||
end
|
||||
HA --> ALLOW --> EXEC
|
||||
HA --> CONF
|
||||
HA --> HEX
|
||||
|
||||
subgraph RESP["response generation"]
|
||||
direction TB
|
||||
REP["replier<br/>only when the handler returned \"\""]
|
||||
PHR["phraser<br/>parseResponseMood is the one parser"]
|
||||
TTS["tts seam<br/>Remote mavttsd · Stub"]
|
||||
end
|
||||
ACT --> RESP
|
||||
QC --> RESP
|
||||
REP --> PHR
|
||||
OUT["reply text<br/>+ notice + resumed question"]
|
||||
RESP --> OUT
|
||||
OUT -->|"voice path only"| TTS
|
||||
|
||||
subgraph PROACT["the other half of the process — nothing above touches it"]
|
||||
direction TB
|
||||
TICKL["tick loop · 60s<br/>13 jobs in one function"]
|
||||
GTH["loop.Gatherer<br/>one consistent snapshot"]
|
||||
RUL["loop rules + restraint gate<br/>pure"]
|
||||
DISP["delivery.Dispatcher<br/>ChannelsFor severity,presence"]
|
||||
SNK["sinks: voice · ntfy · telegram"]
|
||||
TICKL --> GTH --> RUL --> TICKL
|
||||
TICKL --> DISP --> SNK
|
||||
end
|
||||
TICKL --> DB
|
||||
SNK -->|"PushToMostRecent on the request conn"| A1
|
||||
|
||||
classDef stage fill:#1f3a5f,stroke:#7fb3ff,color:#eaf2ff
|
||||
classDef store fill:#1f4a3a,stroke:#6ed0a8,color:#e8fff5
|
||||
classDef mem fill:#4a3a1f,stroke:#e0b050,color:#fff6e0
|
||||
classDef danger fill:#4f2626,stroke:#e08080,color:#ffecec
|
||||
class S0,SH,SL,SC,SE,CONT stage
|
||||
class DB,VEC,DLGS,ALLOW store
|
||||
class CLS,PEND,SURF,RING mem
|
||||
class QB,CONF danger
|
||||
@@ -0,0 +1,78 @@
|
||||
%% View 3a — Runtime flow: a reminder request.
|
||||
%% Traced through cmd/mavend/voice.go runTurn, internal/router/stagezero.go,
|
||||
%% cmd/mavend/clarify.go, cmd/mavend/actions_reminder.go, cmd/mavend/tick.go,
|
||||
%% internal/loop/loop.go and internal/delivery/dispatcher.go.
|
||||
%% Shows the branch where the hour is missing, the parked clarify, the answer
|
||||
%% turn, the write and the eventual delivery with durable retry.
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant K as Owner
|
||||
participant W as mavwaked
|
||||
participant V as voice.Server
|
||||
participant H as reactiveHandler.runTurn
|
||||
participant PRE as pre-route ladder
|
||||
participant R as router cascade
|
||||
participant CL as clarifyStore
|
||||
participant AR as actionReminder
|
||||
participant DB as store
|
||||
participant T as tick loop
|
||||
participant D as dispatcher
|
||||
|
||||
Note over K,W: "Мэйвен, напомни позвонить маме"
|
||||
K->>W: speech
|
||||
W->>W: silero VAD + keyword head, score ≥ 0.999
|
||||
W->>V: PushToTalkReq, one utterance
|
||||
V->>H: HandlePushToTalk
|
||||
H->>H: stt seam → text
|
||||
H->>H: decision.With, turnRoute created
|
||||
H->>PRE: 11 rungs
|
||||
PRE-->>H: nobody claims
|
||||
H->>R: rt.resolve
|
||||
R->>R: stage 0 ReminderGrammar matches, Stage=0, conf 1.0
|
||||
R-->>H: IntentReminder, Slots.Text="позвонить маме", HasTime=false
|
||||
|
||||
rect rgb(70,40,40)
|
||||
Note over H,CL: BRANCH — missingFor names `time`, whatever the confidence
|
||||
H->>H: dec.Clarify false BUT len missingFor > 0 → step 8 fires
|
||||
H->>CL: Push a PendingQuestion, park the request
|
||||
H-->>V: "во сколько напомнить?"
|
||||
V-->>W: reply audio + text
|
||||
end
|
||||
|
||||
Note over K,W: "в семь вечера"
|
||||
K->>W: speech
|
||||
W->>V: PushToTalkReq
|
||||
V->>H: runTurn
|
||||
H->>PRE: rung 4, resolveClarifyAnswer
|
||||
PRE->>CL: Pop the parked question
|
||||
PRE->>R: extractor parses the hour with the SAME parsers stage 2 uses
|
||||
PRE->>AR: finishClarified → applyAction
|
||||
Note right of AR: filling in an argument never grants authority
|
||||
AR->>AR: router.ResolvedTheHour guard
|
||||
AR->>DB: CreateReminder fire_ts, payload
|
||||
DB-->>AR: id
|
||||
AR-->>H: reminderConfirm, phrased FROM THE ROW not the utterance
|
||||
H-->>V: "хорошо, напомню сегодня в 19:00."
|
||||
|
||||
Note over T,D: later — the proactive half, no shared code with the turn path
|
||||
loop every 60s
|
||||
T->>DB: Gatherer.GatherState, due reminders, collapsed by group
|
||||
T->>T: loop.RemindDecisions — reminders BYPASS the restraint gate
|
||||
alt not cached
|
||||
T->>T: phraser.PhraseReminder
|
||||
end
|
||||
T->>D: DispatchReminder
|
||||
D->>DB: BeginDeliveryAttempt BEFORE the external send
|
||||
alt a voice session is live
|
||||
D->>V: voicesink push on the request conn
|
||||
else away
|
||||
D->>D: ntfy is nil (disabled) → telegram
|
||||
end
|
||||
alt success
|
||||
D->>DB: CompleteSuccessfulReminderAttempt + fire the originals, one txn
|
||||
else failure
|
||||
D->>DB: advance the persisted bounded backoff, next_attempt_ts
|
||||
end
|
||||
end
|
||||
|
||||
Note over T,DB: Recurring is NOT on this path. reminders.cron and next_fire_ts<br/>exist since migration #2 and no spoken path writes them.
|
||||
@@ -0,0 +1,61 @@
|
||||
%% View 3b — Runtime flow: a factual / state update.
|
||||
%% Traced through cmd/mavend/actions_fact.go, cmd/mavend/ack.go,
|
||||
%% cmd/mavend/patterns.go, cmd/mavend/factenrichment.go, cmd/mavend/intake.go
|
||||
%% and internal/morning.
|
||||
%% Shows the two re-route branches this handler owns, the vector prune-and-insert,
|
||||
%% the nudge it can close, and the async entity resolution behind it.
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant K as Owner
|
||||
participant H as runTurn
|
||||
participant R as router cascade
|
||||
participant AF as actionFact
|
||||
participant API as CoreAPI · intakeAPI then storeAPI
|
||||
participant DB as facts table
|
||||
participant VEC as memory_vectors
|
||||
participant BUS as event.Bus
|
||||
participant FE as fact-enrichment worker
|
||||
participant NX as Nexus
|
||||
participant T as tick loop
|
||||
|
||||
Note over K,H: "выпил воды"
|
||||
K->>H: utterance, src=tap:voice
|
||||
H->>R: rt.resolve
|
||||
R->>R: stage 0 declines → heads → LLM router → classifier
|
||||
R-->>H: IntentFact, Slots.Key="water", Slots.Value=...
|
||||
|
||||
rect rgb(70,40,40)
|
||||
Note over AF: two guards that RE-ROUTE rather than write
|
||||
AF->>AF: router.IsQuestionShaped? → becomes actionQuery, Key cleared
|
||||
AF->>AF: router.IsTransientComplaint? → becomes actionChat, nothing stored
|
||||
end
|
||||
|
||||
AF->>AF: factConfidence — 1.0 only for a value he actually said
|
||||
AF->>API: WriteFact kind=self, source=tap:voice, Subject=Key
|
||||
API->>DB: append-only row
|
||||
API->>BUS: publish one intake envelope
|
||||
API-->>AF: factID
|
||||
|
||||
AF->>VEC: pruneFactVectors by key
|
||||
AF->>VEC: EmbedPassage(FactRecallText) then Insert "fact:<key>:<unix>"
|
||||
Note right of VEC: the FACT is embedded, not the utterance.<br/>The utterance rides along as provenance only
|
||||
|
||||
AF->>DB: RecordEvent action+object, for pattern detection
|
||||
H->>H: step 9b ackFromFact — a fact answering a live nudge closes it as `acted`, silently
|
||||
|
||||
par asynchronous, minutes later
|
||||
FE->>DB: read facts with resolution_state='pending'
|
||||
FE->>NX: Resolve(Subject)
|
||||
alt resolved
|
||||
NX-->>FE: entity_id
|
||||
FE->>DB: UPDATE entity_id, resolution_state='resolved'
|
||||
else ambiguous
|
||||
Note right of FE: candidates are NOT stored —<br/>ambiguity blocks, it does not pick
|
||||
end
|
||||
and the next tick
|
||||
T->>DB: Gatherer reads the same row
|
||||
T->>T: morning routine item `water` is now evidenced, so it will not nudge
|
||||
T->>T: detectPatterns scans events for a stable interval
|
||||
end
|
||||
|
||||
Note over DB,VEC: A wrong value is superseded, never overwritten:<br/>voids_id points at the row it cancels, and CorrectValue /<br/>VoidLatestFact drop the key's vectors so recall keeps exactly one.
|
||||
@@ -0,0 +1,70 @@
|
||||
%% View 3c — Runtime flow: a world query, tool-backed.
|
||||
%% Traced through internal/router/worldquery.go, internal/router/source.go,
|
||||
%% cmd/mavend/actions_query.go queryWalk + querySources, cmd/mavend/personalboundary.go,
|
||||
%% cmd/mavend/searchwire.go, cmd/mavend/kiwixwire.go.
|
||||
%% Shows destination anchoring, which sources are skipped and why, and the
|
||||
%% four-step fallback to the model's own weights.
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant K as Owner
|
||||
participant H as runTurn
|
||||
participant R as router cascade
|
||||
participant QC as actionQuery
|
||||
participant W as queryWalk
|
||||
participant LOC as local sources
|
||||
participant PB as personal boundary
|
||||
participant SX as SearXNG
|
||||
participant KX as kiwix-server
|
||||
participant PH as phraser / resident model
|
||||
participant REC as decision record
|
||||
|
||||
Note over K,H: "что такое TCP?"
|
||||
K->>H: utterance
|
||||
H->>R: rt.resolve
|
||||
R->>R: stage 0 — WorldQueryGrammars matches a literal definition frame
|
||||
R->>R: d.SourceAnchored = true, set HERE and nowhere else
|
||||
R-->>H: IntentQuery, Source=SourceWorld, anchored
|
||||
|
||||
H->>QC: applyAction → actionQuery
|
||||
QC->>REC: Expect the full 22-source roster
|
||||
QC->>W: queryWalk(SourceWorld, anchored=true)
|
||||
|
||||
rect rgb(70,40,40)
|
||||
Note over W: removes ONLY sources with guesses:true whose dest ≠ world
|
||||
W-->>REC: skipped: attention, list, feeds, home, network, weather, self
|
||||
W-->>REC: skipped: personal boundary — anchored, so a literal pattern may drop it
|
||||
Note right of W: a model or a softmax naming SourceWorld<br/>would NOT drop the boundary (V-666)
|
||||
end
|
||||
|
||||
W-->>QC: the sources that LOOK still walk, in table order
|
||||
|
||||
loop first source to claim answers the turn
|
||||
QC->>LOC: fact-by-key, day-plan, habits, tasks, money, history, calendar
|
||||
LOC-->>QC: no rows → pass
|
||||
QC->>LOC: embed → memory → notes (vector recall, gated by min score + margin)
|
||||
LOC-->>QC: below the gate → pass
|
||||
QC->>PB: personal boundary
|
||||
PB-->>QC: SKIPPED this turn
|
||||
QC->>SX: Search(utterance verbatim, max 4)
|
||||
alt results
|
||||
SX-->>QC: snippets
|
||||
QC->>PH: phraseSource("search", utterance, evidence)
|
||||
PH-->>QC: reply
|
||||
QC->>REC: claimed by "search", and everyone below is NeverAsked
|
||||
else empty or unreachable
|
||||
QC->>KX: ZIM search, ru then en
|
||||
alt hit
|
||||
KX-->>QC: article snippet
|
||||
QC->>PH: phraseSource("kiwix", ...)
|
||||
else miss
|
||||
QC->>QC: "web" claims only if he named a URL out loud
|
||||
QC->>PH: queryGeneral — the model answers from its own weights, LAST
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
QC-->>H: reply text
|
||||
H-->>K: spoken or written answer
|
||||
|
||||
Note over LOC,SX: What leaves the box is the query string and nothing else.<br/>His notes, his facts, the persona block and the history never travel.
|
||||
Note over W,PB: With no destination named — the classifier arm sets none —<br/>the whole chain walks in table order. That is the floor.
|
||||
@@ -0,0 +1,115 @@
|
||||
%% View 4 — State ownership.
|
||||
%% Every persistent and shared store, its authoritative owner, its writers and
|
||||
%% readers, its synchronisation boundary and its lifecycle.
|
||||
%% Red = written by components that do not know about each other.
|
||||
%% Evidence: internal/store/schema.sql, internal/store/migrations.go,
|
||||
%% internal/store/crypt.go, cmd/mavend/voice.go, cmd/mavend/tick.go,
|
||||
%% cmd/mavweb/*.go, cmd/mavpoll/main.go, cmd/mavcaldav/main.go.
|
||||
flowchart LR
|
||||
|
||||
subgraph OWNER["authoritative owner — mavend, the only key holder"]
|
||||
STORE[("store.Store<br/>SetMaxOpenConns(1)<br/>every write serialised at the db")]
|
||||
end
|
||||
|
||||
subgraph LIFE["lifecycle of the database itself"]
|
||||
direction TB
|
||||
ENC[("maven.db.enc<br/>AES-256-GCM at rest<br/>volume dbdata")]
|
||||
TMP[("/dev/shm/maven-plain.db<br/>tmpfs working copy<br/>dies with the container")]
|
||||
ENC -->|"Open: decrypt"| TMP
|
||||
TMP -->|"Close: checkpoint, re-encrypt, atomic rename"| ENC
|
||||
SEAL["mavseal<br/>recovery only, VACUUM INTO"]
|
||||
TMP -.->|"when mavend was killed, not stopped"| SEAL
|
||||
SEAL -.-> ENC
|
||||
end
|
||||
STORE --- TMP
|
||||
|
||||
%% ------------- multiply written tables
|
||||
FACTS[("facts<br/>append-only, ts = valid-time<br/>correction sets voids_id")]:::multi
|
||||
NOTES[("notes<br/>float32 blob, brute-force scan")]:::multi
|
||||
TOOLS[("tools<br/>only status='enabled' executes")]:::multi
|
||||
|
||||
%% ------------- singly owned tables
|
||||
REM[("reminders")]
|
||||
NUD[("nudges — the restraint memory<br/>AND the only feedback input")]
|
||||
VEC[("memory_vectors<br/>marked with the embedder id")]
|
||||
PRES[("presence_state — singleton row")]
|
||||
EV[("events")]
|
||||
PROP[("proposed_routines")]
|
||||
DIG[("digest_entries — gate-BLOCKED candidates")]
|
||||
DEL[("delivery_attempts — the outbox")]
|
||||
ACK[("ack_sends")]
|
||||
DLGS[("dialogue_sessions — TTL 2m")]
|
||||
TASKS[("tasks")]
|
||||
LISTS[("list_items")]
|
||||
RTR[("routing_traces — 14-day retention")]
|
||||
RLB[("routing_labels")]
|
||||
ETR[("ecosystem_traces")]
|
||||
META[("meta — schema version + embedder marker")]
|
||||
|
||||
STORE --- FACTS & NOTES & TOOLS & REM & NUD & VEC & PRES & EV & PROP & DIG & DEL & ACK & DLGS & TASKS & LISTS & RTR & RLB & ETR & META
|
||||
|
||||
%% ------------- writers into facts
|
||||
WF1["actionFact — tap:voice / tap:text"] --> FACTS
|
||||
WF2["quiet toggle — config fact"] --> FACTS
|
||||
WF3["mavpoll — poll:netdata, poll:uptimekuma,<br/>infer:wg, poll:zenmoney"] --> FACTS
|
||||
WF4["mavcaldav — poll:caldav<br/>NOT DEPLOYED"]:::off -.-> FACTS
|
||||
WF5["mavweb — /api/signal presence,<br/>/api/ambient meeting time"] --> FACTS
|
||||
WF6["feed + crawl watermarks<br/>crawl:hash:*"] --> FACTS
|
||||
WF7["fact-enrichment worker<br/>entity_id, resolution_state"] --> FACTS
|
||||
WF8["tick loop tune()<br/>cooldown:<rule> feedback fact"] --> FACTS
|
||||
WF9["mavweb /api/revert<br/>voids the latest fact for a key"] --> FACTS
|
||||
|
||||
%% ------------- writers into notes
|
||||
WN1["actionNote"] --> NOTES
|
||||
WN2["RSS poller — source rss:*"] --> NOTES
|
||||
WN3["crawl watcher"] --> NOTES
|
||||
WN4["meeting capture"]:::off -.-> NOTES
|
||||
WN5["image description"]:::off -.-> NOTES
|
||||
WN6["netscan record"] --> NOTES
|
||||
|
||||
%% ------------- writers into tools
|
||||
WT1["seedTools from mavend.json"] --> TOOLS
|
||||
WT2["MCP discovery — proposed"]:::off -.-> TOOLS
|
||||
WT3["Home Assistant discovery<br/>proposed, always destructive"]:::off -.-> TOOLS
|
||||
WT4["mavweb POST /tools<br/>the ONLY enable path"] --> TOOLS
|
||||
|
||||
%% ------------- readers
|
||||
FACTS --> RD1["loop.Gatherer — the tick snapshot"]
|
||||
FACTS --> RD2["queryFactByKey · money · history · morning"]
|
||||
NOTES --> RD3["queryNotes · queryFeeds · recall"]
|
||||
VEC --> RD4["queryMemory · queryEmbed"]
|
||||
TOOLS --> RD5["tool.Matcher + tool.Executor"]
|
||||
NUD --> RD6["restraint gate · TuneCooldown · UnackedTelegramRules"]
|
||||
|
||||
%% ------------- in-memory shared state
|
||||
subgraph MEM["shared mutable state — process-local, no synchronisation boundary beyond a mutex"]
|
||||
direction TB
|
||||
CLS["clarifyStore<br/>per-reach stack · NOT persisted on purpose:<br/>a restart expires the open question"]
|
||||
PEND["pending act / pendingRoutine / pendingHexis<br/>3 single-slot registers under handler.mu<br/>last-asked wins, TTL each"]
|
||||
SURF["surfacedItems<br/>replaced by the next digest, NO TTL"]
|
||||
RING["decision.Ring — bounded, diagnosis only"]
|
||||
BUS["event.Bus — bounded journal, read surface only"]
|
||||
TICKM["tickLoop: lastPhrase, lastTrace, digestQ,<br/>routineLast, morningLast, lastProposalAt"]
|
||||
LASTR["lastRouted — the previous acted turn, for a spoken correction"]
|
||||
end
|
||||
|
||||
H1["reactiveHandler<br/>one instance, called from per-conn goroutines"] --- CLS
|
||||
H1 --- PEND
|
||||
H1 --- SURF
|
||||
H1 --- RING
|
||||
H1 --- LASTR
|
||||
TICKL["tickLoop"] --- TICKM
|
||||
INTAKE["intakeAPI decorator"] --- BUS
|
||||
|
||||
%% ------------- outside the database
|
||||
subgraph OUT["state outside the database"]
|
||||
direction TB
|
||||
PK[("passkeys.json<br/>OWNED BY mavweb, not mavend")]:::multi
|
||||
WK[("wrapped key blob<br/>written by mavend WrapKeyFn,<br/>triggered by mavweb")]:::multi
|
||||
MAIL[("mavmaild seen-UID file<br/>own volume · NOT DEPLOYED")]:::off
|
||||
BLOB[("media blobs · retention loop")]:::off
|
||||
end
|
||||
PK -.->|"a v1 blob + this file together<br/>recover the database key with no authenticator"| WK
|
||||
|
||||
classDef multi fill:#4f2626,stroke:#e08080,color:#ffecec
|
||||
classDef off fill:#3a3a3a,stroke:#888,color:#ccc,stroke-dasharray:4 3
|
||||
@@ -0,0 +1,141 @@
|
||||
%% View 5 — Dependency and boundary map.
|
||||
%% Architectural components, not classes. Highlights the cycle, the cross-layer
|
||||
%% calls, the duplicated responsibilities, the fan-in and fan-out hotspots, the
|
||||
%% process and IPC boundaries, and where a failure propagates.
|
||||
%% Evidence: cmd/mavend/boot.go, cmd/mavend/tick_api.go, cmd/mavend/voice.go,
|
||||
%% cmd/mavend/voicewire.go, internal/ipc/server.go, internal/delivery/channel.go.
|
||||
flowchart TB
|
||||
|
||||
subgraph B1["process boundary — mavend"]
|
||||
direction TB
|
||||
|
||||
subgraph L_EDGE["entry layer"]
|
||||
IPCS["ipc.Server<br/>fan-in: 6 processes<br/>+ 8 bypass function fields"]
|
||||
VSRV["voice.Server"]
|
||||
HTTPIN["telegram poller"]
|
||||
end
|
||||
|
||||
subgraph L_API["API layer"]
|
||||
DAPI["daemonAPI<br/>store adapter + 8 closures"]
|
||||
IAPI["intakeAPI decorator"]
|
||||
SAPI["ipc.NewStoreAPI"]
|
||||
end
|
||||
|
||||
subgraph L_TURN["turn layer"]
|
||||
RH["reactiveHandler<br/>GOD COMPONENT<br/>34 fields · fan-out ≈ 20"]
|
||||
TRT["turnRoute"]
|
||||
PRE["pre-route ladder · 11 rungs"]
|
||||
ATBL["actionHandlers · 7"]
|
||||
QCH["querySources · 22"]
|
||||
end
|
||||
|
||||
subgraph L_ROUTE["routing layer"]
|
||||
RTR["router.Router cascade"]
|
||||
G0["stage 0 grammars · 22+"]
|
||||
HDS["routing heads"]
|
||||
LLMR["LLM router"]
|
||||
CLF["classifier"]
|
||||
end
|
||||
|
||||
subgraph L_PROACT["proactive layer"]
|
||||
TICK["tickLoop<br/>13 jobs, one function<br/>fan-out ≈ 10"]
|
||||
GATH["loop.Gatherer"]
|
||||
RULES["loop rules + gate · pure"]
|
||||
DISP["delivery.Dispatcher"]
|
||||
end
|
||||
|
||||
subgraph L_WIRE["construction layer"]
|
||||
WIRE["wireVoice<br/>builds 17 subsystems<br/>returns voiceWiring"]
|
||||
BOOT["boot.go<br/>newDaemonAPI + startBackground"]
|
||||
end
|
||||
|
||||
subgraph L_STATE["state layer"]
|
||||
ST[("store.Store")]
|
||||
end
|
||||
end
|
||||
|
||||
subgraph B2["process boundary — modules"]
|
||||
MSTT["mavsttd"]
|
||||
MTTS["mavttsd"]
|
||||
MWEB["mavweb"]
|
||||
MPOLL["mavpoll"]
|
||||
end
|
||||
|
||||
subgraph B3["process boundary — workstation"]
|
||||
MWAKE["mavwaked"]
|
||||
MGPU["mavgpud"]
|
||||
end
|
||||
|
||||
subgraph B4["external services"]
|
||||
EXT["SearXNG · kiwix · Nexus · Praxis · Hexis<br/>Telegram · ntfy · Home Assistant"]
|
||||
end
|
||||
|
||||
%% ---------- boundaries
|
||||
MWEB -.->|"UNIX IPC · 3 conns"| IPCS
|
||||
MPOLL -.->|"UNIX IPC"| IPCS
|
||||
MWAKE -.->|"TCP over ssh · plaintext, no auth"| VSRV
|
||||
MWEB -.->|"TCP · /api/ptt"| VSRV
|
||||
RH -.->|"UNIX worker"| MSTT
|
||||
RH -.->|"UNIX worker"| MTTS
|
||||
RH -.->|"HTTP"| EXT
|
||||
RH -.->|"HTTP"| MGPU
|
||||
DISP -.->|"HTTP"| EXT
|
||||
|
||||
%% ---------- the cycle
|
||||
IPCS --> DAPI
|
||||
DAPI -->|"chatFn = handler.handleText"| RH
|
||||
RH -->|"h.api, back-patched by upgradeAPI"| DAPI
|
||||
|
||||
DAPI --> IAPI --> SAPI --> ST
|
||||
|
||||
%% ---------- turn layer
|
||||
VSRV --> RH
|
||||
HTTPIN --> DAPI
|
||||
RH --> TRT --> RTR
|
||||
RH --> PRE --> TRT
|
||||
RH --> ATBL --> QCH
|
||||
QCH --> ST
|
||||
ATBL --> ST
|
||||
RTR --> G0 & HDS & LLMR & CLF
|
||||
|
||||
%% ---------- cross-layer calls
|
||||
QCH -->|"CROSS-LAYER: a query source reads the tick loop"| TICK
|
||||
RH -->|"CROSS-LAYER: dataStore, the raw store beside the CoreAPI"| ST
|
||||
DAPI -->|"reads tick state"| TICK
|
||||
WIRE --> RH
|
||||
WIRE --> RTR
|
||||
WIRE --> DISP
|
||||
BOOT --> DAPI
|
||||
BOOT --> TICK
|
||||
|
||||
%% ---------- proactive
|
||||
TICK --> GATH --> ST
|
||||
TICK --> RULES
|
||||
TICK --> DISP
|
||||
DISP --> ST
|
||||
DISP -->|"voicesink pushes on the request conn"| VSRV
|
||||
|
||||
%% ---------- annotations
|
||||
DUP1["DUPLICATED RESPONSIBILITY<br/>two independent arbitrations decide a turn:<br/>the 22-grammar cascade, then the 22-source chain.<br/>Both are ordered lists; neither can compare scores."]:::note
|
||||
DUP1 -.- RTR
|
||||
DUP1 -.- QCH
|
||||
|
||||
DUP2["DUPLICATED RESPONSIBILITY<br/>restraint is decided twice:<br/>loop.Gate says whether a rule EMITS,<br/>delivery.ChannelsFor says where it LANDS.<br/>Deliberate, and documented in channel.go."]:::note
|
||||
DUP2 -.- RULES
|
||||
DUP2 -.- DISP
|
||||
|
||||
DUP3["DUPLICATED RESPONSIBILITY<br/>three unrelated components propose tool rows:<br/>config seeding, MCP discovery, HA discovery."]:::note
|
||||
DUP3 -.- ST
|
||||
|
||||
FRAG1["FRAGILE PATH<br/>4 seams degrade silently:<br/>workstation model → resident model,<br/>CW2 → mavsttd, heads → LLM → classifier,<br/>search → kiwix → weights.<br/>Nothing on the turn says which one answered."]:::warn
|
||||
FRAG1 -.- RTR
|
||||
FRAG1 -.- QCH
|
||||
|
||||
FRAG2["FAILURE PROPAGATION<br/>ipc.Server holds long-lived conns from 4 modules.<br/>Before V-638 that deadlocked EVERY shutdown and<br/>the deployed ciphertext went 11 days stale."]:::warn
|
||||
FRAG2 -.- IPCS
|
||||
|
||||
FRAG3["PLANNED, UNWIRED<br/>internal/claim + router.ClaimOf: a comparable<br/>unit of evidence for exactly the two arbitrations above.<br/>Nothing calls it. internal/modes: nothing imports it."]:::warn
|
||||
FRAG3 -.- RTR
|
||||
|
||||
classDef note fill:#2a3f2a,stroke:#7fbf7f,color:#eaffea
|
||||
classDef warn fill:#4f2626,stroke:#e08080,color:#ffecec
|
||||
@@ -0,0 +1,699 @@
|
||||
# Architecture findings: Maven as built
|
||||
|
||||
Read at commit `5cae33a`, 2026-08-25. Working tree dirty: `deploy/mavend.json`
|
||||
swaps `phraser.model_path` to `maven-instruct-b2-Q4_K_XL.gguf`, plus an edited
|
||||
`docs/evals/CLAUDE.md` and two untracked files.
|
||||
|
||||
This file is analysis. The factual inventory is
|
||||
`docs/architecture/maven-architecture.json` and the diagrams under
|
||||
`docs/architecture/diagrams/`. Nothing here proposes a new architecture.
|
||||
|
||||
**Revised 2026-08-25 after an independent second pass over the evidence pack.**
|
||||
Four readings changed, and section 6.3 contained one statement that was wrong:
|
||||
the voice server defaults an empty `Surface`, it does not overwrite the client's.
|
||||
The sections marked below carry the corrections.
|
||||
|
||||
**The ranking changed with them.** The missing end-to-end authority model
|
||||
(6.3 through 6.3d) is the first architectural issue, ahead of `reactiveHandler`
|
||||
size (4.1) and the process boundaries (section 5). Those are refactors. This one
|
||||
is a property nobody can state.
|
||||
|
||||
Each finding cites what it was read from. Where the repository already names a
|
||||
problem in its own comments, that is said. A known defect and an undiscovered
|
||||
one are different facts.
|
||||
|
||||
---
|
||||
|
||||
## 1. Unclear ownership
|
||||
|
||||
### 1.1 The `facts` table has nine writers and no owner
|
||||
|
||||
`internal/store/schema.sql` calls facts "substrate, all observations". Nine
|
||||
components append to it, and no component owns the key namespace:
|
||||
|
||||
| Writer | Source tag | Evidence |
|
||||
|---|---|---|
|
||||
| `actionFact` | `tap:voice`, `tap:text` | `cmd/mavend/actions_fact.go` |
|
||||
| quiet-hours toggle | `config` | `cmd/mavend/quiet_toggle.go` |
|
||||
| mavpoll | `poll:netdata`, `poll:uptimekuma`, `infer:wg`, `poll:zenmoney` | `cmd/mavpoll/main.go` |
|
||||
| mavcaldav | `poll:caldav` | `cmd/mavcaldav/main.go`, not deployed |
|
||||
| mavweb | presence, ambient meeting time | `cmd/mavweb/facts.go`, `cmd/mavweb/ambient.go` |
|
||||
| feed worker | RSS watermark | `cmd/mavend/feeds.go` |
|
||||
| crawl worker | `crawl:hash:<name>` | `cmd/mavend/crawls.go` `hashKey` |
|
||||
| fact-enrichment worker | mutates `entity_id`, `resolution_state` | `cmd/mavend/factenrichment.go` |
|
||||
| tick loop autotune | `cooldown:<rule>` | `cmd/mavend/tick.go` `tune`, `internal/loop/feedback.go` `FeedbackKey` |
|
||||
|
||||
Two of these are not observations at all. `crawl:hash:*` is a fetch watermark
|
||||
and `cooldown:<rule>` is a tuning parameter. Both live in the same append-only
|
||||
table that recall embeds and that `queryFactByKey` reads back as an answer. The
|
||||
`source` column is what keeps them apart, and it is a convention, not a
|
||||
constraint: `schema.sql` documents the vocabulary in a comment and the `CHECK`
|
||||
covers only `kind`.
|
||||
|
||||
### 1.2 `notes` has six writers and one of them is a LAN scan
|
||||
|
||||
`cmd/mavend/netscan.go` `writeScanRecord` writes a scan result as a note. Notes
|
||||
are the recall corpus: `queryNotes` and `queryMemory` answer from them. So a
|
||||
network scan record competes by cosine similarity with things he said.
|
||||
|
||||
### 1.3 `tools` is proposed by three unrelated components
|
||||
|
||||
Config seeding (`seedTools`), MCP discovery (`cmd/mavend/mcp.go` `propose`) and
|
||||
Home Assistant discovery (`cmd/mavend/smarthome.go` `propose`) all write rows.
|
||||
Only `mavweb` `POST /tools` can enable one, which is the invariant that holds.
|
||||
But nothing arbitrates a name collision between the three proposers, and
|
||||
`tools.name` is the primary key.
|
||||
|
||||
### 1.4 The day plan has no store and two owners
|
||||
|
||||
`queryDayPlan` is a query source. The day plan it reads is assembled by the tick
|
||||
loop (`cmd/mavend/tick_morning.go` `dayPlan`). The bare store adapter cannot
|
||||
answer it, which is why `upgradeAPI` exists at all (finding 3.1). So a read of
|
||||
his calendar depends on a proactive scheduler being wired.
|
||||
|
||||
---
|
||||
|
||||
## 2. Duplicated responsibilities
|
||||
|
||||
### 2.1 Two independent arbitrations decide one turn
|
||||
|
||||
The cascade sorts an utterance into one of seven intents through four arms
|
||||
(`internal/router/router.go` `Route`). An `IntentQuery` then enters a second
|
||||
arbitration of twenty-two ordered sources (`cmd/mavend/actions_query.go`
|
||||
`querySources`, counted in the source). Both are ordered lists. Neither can
|
||||
compare scores across arms.
|
||||
|
||||
The repository states this itself, in `internal/router/source.go`:
|
||||
|
||||
> The cascade sorted an utterance into one of seven intents with stage 0 rules,
|
||||
> the resident model and the classifier behind it, a fixture measuring it and
|
||||
> the decision trace recording it. Then IntentQuery handed the turn to
|
||||
> querySources in the daemon, a chain of twenty-two branches deciding by seed
|
||||
> similarity in a fixed order, with none of that.
|
||||
|
||||
`Source` and `queryWalk` narrow the second arbitration with a decision from the
|
||||
first. They do not merge the two.
|
||||
|
||||
### 2.2 A third arbitration runs before both
|
||||
|
||||
`runTurn` steps 1 through 5e are eleven stateful pre-emptors, each answering "is
|
||||
this mine?" alone (`cmd/mavend/voice.go`, `preRouteLadder` in
|
||||
`cmd/mavend/decisiontrace.go`). Their order is argued rung by rung in comments.
|
||||
That is three ordered lists deciding one utterance, in three files, with three
|
||||
different notions of confidence.
|
||||
|
||||
`internal/claim/claim.go` names exactly this and counts it:
|
||||
|
||||
> Maven's cascade has twenty-two stage-0 grammars, seven router intents,
|
||||
> twenty-two query sources and seven stateful pre-emptors, and every one of them
|
||||
> answers "is this mine?" alone. None can answer "is this more mine than
|
||||
> yours?" … So list order is the whole arbitration.
|
||||
|
||||
The unit that would fix it is written, tested and called by nothing. See 6.1.
|
||||
|
||||
### 2.3 Restraint is decided twice, deliberately
|
||||
|
||||
`internal/loop/loop.go` `Gate` decides whether a rule emits.
|
||||
`internal/delivery/channel.go` `ChannelsFor` decides where it lands, and drops
|
||||
care nudges on away for its own reasons. `channel.go` argues the duplication:
|
||||
|
||||
> double authority is intentional: the gate decides whether a rule EMITS;
|
||||
> delivery decides where it LANDS.
|
||||
|
||||
Recorded here as duplication that is owned, not as a defect.
|
||||
|
||||
### 2.4 Two digest mechanisms with the same word in the name
|
||||
|
||||
`tickLoop.digestQ` is an in-memory queue batching candidates the gate **allowed**.
|
||||
`digest_entries` is a table durably holding candidates the gate **blocked**. Both
|
||||
are flushed in the same `tick()` body, six lines apart
|
||||
(`cmd/mavend/tick_digest.go`). The distinction is carried entirely by a comment.
|
||||
|
||||
---
|
||||
|
||||
## 3. Accidental coupling
|
||||
|
||||
### 3.1 A construction cycle between the API layer and the turn layer
|
||||
|
||||
Two back-patches, each documented, together forming a cycle:
|
||||
|
||||
- `cmd/mavend/boot.go`: `api.chatFn = d.voiceW.handler.handleText`
|
||||
- `cmd/mavend/voice.go` `upgradeAPI`: `h.api = api`, the daemon's own CoreAPI
|
||||
|
||||
So `daemonAPI` holds the handler and the handler holds `daemonAPI`. The comment
|
||||
on `upgradeAPI` states the reason and the safety argument:
|
||||
|
||||
> Wiring order forces this. wireVoice runs before the tick loop exists … main
|
||||
> already back-patches the other direction … this is the same seam in reverse.
|
||||
> Safe against the obvious loop: nothing in the voice path calls api.Chat.
|
||||
|
||||
The safety rests on a negative that nothing enforces. Adding a query source that
|
||||
calls `api.Chat` would recurse.
|
||||
|
||||
### 3.2 The handler holds the raw store beside the mediated one
|
||||
|
||||
`reactiveHandler` carries both `api ipc.CoreAPI` and
|
||||
`dataStore *store.Store`, "direct store access for event extraction + pattern
|
||||
detection" (`cmd/mavend/voice.go`). `internal/ipc/frame.go` states the opposing
|
||||
rule for the boundary:
|
||||
|
||||
> Core mediates, never hands back a db handle … Anything needing raw db access
|
||||
> lives in core and is unreachable.
|
||||
|
||||
That holds across the process boundary and not inside it. The turn path has two
|
||||
ways to reach the same tables, with different auditing.
|
||||
|
||||
### 3.3 The intake journal is bypassed by the one path that needed it
|
||||
|
||||
`cmd/mavend/intake.go` decorates `CoreAPI` so every intake write narrates
|
||||
itself, and names its own exception:
|
||||
|
||||
> The exception is cmd/mavend/mail.go, which reaches past the interface to
|
||||
> st.CaptureTask directly. It publishes explicitly.
|
||||
|
||||
One caller reaching past a decorator means the decorator is not the boundary it
|
||||
claims to be.
|
||||
|
||||
### 3.4 A query source reads the proactive scheduler
|
||||
|
||||
`queryDayPlan` → `tickLoop.dayPlan`. The reactive and proactive halves otherwise
|
||||
share only the store. This is the single call across that line, and it is the
|
||||
reason for the `upgradeAPI` back-patch in 3.1.
|
||||
|
||||
---
|
||||
|
||||
## 4. God components
|
||||
|
||||
### 4.1 `reactiveHandler` has 34 fields
|
||||
|
||||
`cmd/mavend/voice.go:75`. One struct holds stt, tts, the router, the CoreAPI,
|
||||
the raw store, the tool executor and matcher, the phraser, the replier, the
|
||||
recall wiring, the crawler, the search client, the Kiwix client, the feeds flag,
|
||||
the Home Assistant wiring, the LAN scanner, the weather provider and its default
|
||||
location, the time parser, the dialogue session store, the decision ring, the
|
||||
trace writer, the encoder id, the clarify store and its attempt cap, the
|
||||
extractor, a mutex, `lastRouted`, three pending-confirmation registers,
|
||||
`surfacedItems`, and the ecosystem clients.
|
||||
|
||||
`docs/handler-wiring.md` exists because grouping five of these into `recall`
|
||||
was itself a task (Vikunja #433).
|
||||
|
||||
Every query source, every action handler and every pre-route resolver is a
|
||||
method on this one type. There is no seam between "the thing that routes a
|
||||
turn" and "the thing that knows the house is a Home Assistant".
|
||||
|
||||
### 4.2 `runTurn` is one function with eleven early returns
|
||||
|
||||
`cmd/mavend/voice.go:270`, about 226 lines. Two deferred finalisers, six numbered
|
||||
steps with lettered sub-steps up to `5e`, and an explicit statement that the
|
||||
ordering is load-bearing. Eleven of the returns are `return withNotice(...)`
|
||||
from a pre-emptor.
|
||||
|
||||
### 4.3 `tick` runs thirteen jobs in one function
|
||||
|
||||
`cmd/mavend/tick.go:160`. Gather, save presence, pick a candidate, queue or
|
||||
phrase-and-dispatch, flush the digest, enqueue gate-suppressed candidates,
|
||||
expire stale digest, drain digest, fire routines, fire accepted routines, fire
|
||||
morning routines, detect patterns, deliver reminders, repeat un-acked sev4
|
||||
alarms. One 60s ticker drives all of it, so a slow phraser call delays every job
|
||||
after it.
|
||||
|
||||
### 4.4 `wireVoice` is one constructor for seventeen subsystems
|
||||
|
||||
`cmd/mavend/voicewire.go:108`, about 270 lines, returning a `voiceWiring` struct
|
||||
whose fields the rest of the daemon reaches into (`embedderOf`, `nexusOf`,
|
||||
`d.voiceW.mcp`, `d.voiceW.home`, `d.voiceW.server`, `d.voiceW.handler`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Process boundaries
|
||||
|
||||
### 5.1 Unnecessary: `mavsttd` and `mavttsd` at current scale
|
||||
|
||||
Both are justified in their own headers as "restart-free, key-free,
|
||||
fail-independent". Both run in the same container image, on the same host, as
|
||||
the same user, over a socket in a shared volume, and both are hard dependencies
|
||||
of a turn: `HandlePushToTalk` returns an error reply when either is unavailable.
|
||||
The key argument is real but partial. `internal/ipc/frame.go` says "a crashing
|
||||
tts can't read the key page", and the same holds for any goroutine that never
|
||||
touches the key.
|
||||
|
||||
The boundary earns itself for a different reason the docs do not lead with:
|
||||
whisper.cpp and piper are cgo and subprocess dependencies, so an in-process
|
||||
crash would be a daemon crash. Recorded as a boundary whose stated reason and
|
||||
real reason differ.
|
||||
|
||||
### 5.2 Unnecessary: three IPC connections from one process
|
||||
|
||||
`cmd/mavweb/main.go` opens `core`, `swapConn` and `turnConn` to the same socket,
|
||||
because `ipc.Client` serialises every call on one mutex and a model swap or a
|
||||
chat turn would otherwise freeze every page. The comments say so. Connection
|
||||
count is standing in for request concurrency.
|
||||
|
||||
### 5.3 Missing: the turn path and the tick loop are one process
|
||||
|
||||
They share `store.Store` at `SetMaxOpenConns(1)`, one `phraser.Phraser` and one
|
||||
`llm.Gate`. A reminder being phrased and a spoken turn being answered contend
|
||||
for the same llama-server through `internal/llm/gate.go`. Nothing isolates a
|
||||
foreground turn from a background job beyond that gate.
|
||||
|
||||
### 5.4 Missing: the act executor runs in the key holder
|
||||
|
||||
`internal/tool/tool.go:238` is `exec.CommandContext(ctx, argv[0], argv[1:]...)`,
|
||||
running inside mavend, the only process holding the database key.
|
||||
`deploy/mavend.json` seeds twelve rows, five of them destructive, including
|
||||
`systemctl restart`, `docker restart` and `systemctl reboot`.
|
||||
|
||||
The controls are the enabled allowlist, the risk tier (6.3b) and the confirm
|
||||
turn. The process boundary is not one of them. `internal/tool/risk.go:84` says
|
||||
so directly: "It is not a sandbox and it does not try to be one. An enabled row
|
||||
can already run anything the daemon's user can run."
|
||||
|
||||
### 5.5 The one boundary that is load-bearing and undefended by itself
|
||||
|
||||
The voice TCP wire is plaintext with no auth (`internal/voice/server.go`). Its
|
||||
security argument is entirely external: loopback publish plus an ssh tunnel
|
||||
(`docker-compose.yml` `ports: ["127.0.0.1:9110:9100"]`,
|
||||
`deploy/mavwaked.service` `Requires=maven-voice-tunnel.service`). Correct, and
|
||||
it means a single compose edit silently removes the whole control.
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation disagreeing with apparent responsibility
|
||||
|
||||
### 6.1 `internal/claim` and `router.ClaimOf` are called by nothing
|
||||
|
||||
`internal/router/claim.go` says so in its own doc comment:
|
||||
|
||||
> Nothing in Route calls this yet. The arbiter that reads claims is V-560.
|
||||
|
||||
V-560 landed as `turnRoute` (memoise the route), not as an arbiter. The package
|
||||
and its `router` adapter are complete and tested and are on no path.
|
||||
|
||||
### 6.2 `internal/modes` is imported by nothing outside itself
|
||||
|
||||
`grep -rn "internal/modes"` over `cmd/` and `internal/` returns only its own
|
||||
test. It describes itself as "the roughly thirty distinct downstream behaviours
|
||||
mavend has". That is an inventory of the very thing findings 2.1 and 2.2 are
|
||||
about.
|
||||
|
||||
### 6.3 The auth tier system does not bind the turn path
|
||||
|
||||
`internal/auth/tier.go` documents "voice can never reach EnableTool, not
|
||||
because we check the method, but because the surface can't carry the layer",
|
||||
and `MaxLayer(SurfaceVoice)` returns `Layer0`. `cmd/mavwaked/main.go:275` duly
|
||||
sends `Surface: voice.SurfaceVoice` on the wire.
|
||||
|
||||
Nothing in `cmd/mavend` reads it. `grep -rn "internal/auth" cmd/ internal/`
|
||||
outside tests returns `cmd/mavend/main.go` (building the IPC `Gate`),
|
||||
`cmd/mavweb/webauthn.go`, `internal/webauthn/session.go` and
|
||||
`internal/voice/wire.go` (type aliases only). `actionAct`
|
||||
(`cmd/mavend/actions_act.go`) contains no surface check.
|
||||
|
||||
**Two representations of reach exist, and both are ignored.** An earlier draft
|
||||
of this file said the server overwrites the client's value. It does not.
|
||||
|
||||
1. **Client-asserted, and it survives.** `internal/voice/server.go:198` reads
|
||||
`if p.Surface == "" { p.Surface = SurfacePCClient }`. That defaults an empty
|
||||
field. `mavwaked`'s `SurfaceVoice` arrives intact and reaches
|
||||
`HandlePushToTalk`, which ignores it (`cmd/mavend/voice.go:200`, the
|
||||
parameter is `req` and only `req.Audio` is read).
|
||||
2. **Server-created, and it is wrong.** `internal/voice/server.go:148` is
|
||||
`sess := s.sessions.Add(c, SurfacePCClient)`, hardcoded for every connection
|
||||
whatever the peer is. Nothing reads that either.
|
||||
|
||||
The consequence matters more than the finding. `req.Surface` is request payload
|
||||
on a plaintext wire with no auth, so **any voice-wire client can claim
|
||||
`"pc_client"`**. It must not become an authorization input as it stands. A reach
|
||||
has to be derived from the transport or the session, never trusted from the
|
||||
body.
|
||||
|
||||
`auth.Can` runs only in `ipc.Server.Check`, and `FloorEnrollment` maps every
|
||||
same-uid caller there to `SurfaceCoreProcess` / `Layer3`
|
||||
(`internal/auth/enrollment.go:65`).
|
||||
|
||||
The comments in `cmd/mavwaked/main.go`, `deploy/mavwaked.service` and
|
||||
`CLAUDE.md` all present "SurfaceVoice caps acts at L0" as a live control. On the
|
||||
reactive turn path, `internal/auth` is not what enforces it. Finding 6.3b is.
|
||||
|
||||
### 6.3b There is a second tier system, it is live, and it is not keyed on the reach
|
||||
|
||||
`internal/tool/risk.go` carries its own two-axis policy, and this one runs on
|
||||
every act:
|
||||
|
||||
```go
|
||||
policy := PolicyFor(RiskOf(t)) // internal/tool/tool.go:181
|
||||
if !policy.VoiceMayRun { return "", ErrNeedsAuthedSurface }
|
||||
if policy.Confirm && !confirmed { return "", ErrNeedsConfirm }
|
||||
```
|
||||
|
||||
`RiskOf` sorts a row into `TierSafe`, `TierDestructive` or `TierIrreversible`.
|
||||
`PolicyFor` maps those to `{Confirm:false, VoiceMayRun:true}`,
|
||||
`{Confirm:true, VoiceMayRun:true}` and `{Confirm:true, VoiceMayRun:false}`
|
||||
(`internal/tool/risk.go:69`). So the control that actually stops an act is real,
|
||||
well argued, and fails safe on an unknown shape.
|
||||
|
||||
Two observations about it:
|
||||
|
||||
1. **`VoiceMayRun` is not conditioned on voice.** `Executor.Exec` takes
|
||||
`(ctx, name, args, confirmed)` and no surface. The same policy is applied to
|
||||
the mic, to telegram inbound and to `POST /api/chat` on the authed page. A
|
||||
field named for a reach is evaluated identically for every reach.
|
||||
2. **`systemctl reboot` is `TierDestructive`, not `TierIrreversible`.**
|
||||
`irreversibleVerbs` (`internal/tool/risk.go:88`) lists `rm`, `mkfs`, `dd`,
|
||||
`prune`, `truncate` and eleven more. `reboot` is not among them, and
|
||||
`deploy/mavend.json` seeds it as an enabled row with `destructive: true`. So
|
||||
it runs on the reactive path after one spoken "да", which is exactly what
|
||||
`PolicyFor(TierDestructive)` says and is worth stating out loud.
|
||||
|
||||
So the repository has two tier systems: `Surface × Layer` in `internal/auth`,
|
||||
unread on the turn path, and `Risk × Policy` in `internal/tool`, live.
|
||||
|
||||
They are **not two implementations of one idea**, which is how an earlier draft
|
||||
of this file read. They are two orthogonal dimensions that never meet. `auth`
|
||||
answers who or where may carry what authority. `tool` answers what effect a
|
||||
capability has and what proof it demands. The decision that combines them does
|
||||
not exist anywhere.
|
||||
|
||||
That both dimensions are also thin today makes the gap easier to see:
|
||||
|
||||
- `FloorEnrollment.Lookup` maps **every** same-uid IPC caller to
|
||||
`SurfaceCoreProcess` (`internal/auth/enrollment.go:65`), so the process-radius
|
||||
distinction behind IPC is a future contract, not a live one.
|
||||
- `PasskeySession` is one global timestamp. `CurrentLayer` and `Assert` both
|
||||
take a `Scope` and both ignore it (`internal/webauthn/session.go:38` and
|
||||
`:62`), so step-up is per-daemon rather than per-scope.
|
||||
|
||||
### 6.3c The act policy is more distributed than one gate
|
||||
|
||||
`RiskOf → PolicyFor → Executor.Exec` is one of three act paths, not the act
|
||||
path.
|
||||
|
||||
| path | risk policy? | evidence |
|
||||
|---|---|---|
|
||||
| local tool row | yes | `internal/tool/tool.go:181` |
|
||||
| Hexis capability | yes, explicitly reused | `cmd/mavend/ecosystem_acts.go:768` `tool.RiskOfCapability` then `tool.PolicyFor` |
|
||||
| Praxis lifecycle | **no** | `cmd/mavend/ecosystem_acts.go:158` `praxisItemAction.handle` calls `a.call(ctx, px, id)` directly |
|
||||
|
||||
Acknowledge, resolve, ignore and pin are remote mutations that run on first
|
||||
hearing, with no tier and no confirm turn. They are reversible on the Praxis
|
||||
side, which is a reason, and it is a reason nothing in the code states.
|
||||
|
||||
`Exec` also has no proof that its `confirmed bool` was bound correctly. The
|
||||
invariant that a confirmation names one capability, one target and an expiry
|
||||
lives in `pendingAct` and `resolveConfirm` (`cmd/mavend/confirm.go`), not at the
|
||||
boundary that acts on it. `Exec` trusts the boolean because only two callers
|
||||
exist today.
|
||||
|
||||
So the authorization function is spread across origin handling, routing, parked
|
||||
confirm state, risk classification, allowlist state and execution. Section
|
||||
"The authorization function as implemented" in `README.md` writes down the part
|
||||
that is one expression. The rest is not.
|
||||
|
||||
### 6.3d `Claim.Coverage` returns 1.0 for a claim that extracted nothing
|
||||
|
||||
`ClaimOf` builds its consumed span from `claimSpans`, which includes
|
||||
`d.Slots.Text` unconditionally (`internal/router/claim.go:38`).
|
||||
`Router.fillSlots` backfills the raw utterance into `Text` for a note, a query
|
||||
and a chat turn (`internal/router/router.go:334`, `if d.Slots.Text == "" &&
|
||||
d.Intent != IntentReminder`).
|
||||
|
||||
`claim.Split` then marks every token of the utterance explained, and
|
||||
`Coverage()` is `len(Consumed) / total` (`internal/claim/claim.go:122`). A query
|
||||
claim that extracted nothing scores 1.0, and `MoreSpecificThan` reads coverage
|
||||
first.
|
||||
|
||||
`filledSlots` in the same file already knows about this: it counts `Text` "only
|
||||
when it differs from the whole utterance". `claimSpans`, four functions above
|
||||
it, does not.
|
||||
|
||||
`internal/router/claim_test.go` does not catch it. All five cases in
|
||||
`TestClaimOfBands` set `Text` equal to `Utterance`, and the test asserts `Band`
|
||||
only. Coverage is never asserted anywhere.
|
||||
|
||||
This is why `internal/claim` is not yet an answer to "what competes for a turn".
|
||||
It is the beginning of a vocabulary. It also has no production callers, no
|
||||
builders for query sources or pre-route claimants, and it identifies only the
|
||||
seven-intent destination rather than the roughly thirty behaviours
|
||||
`internal/modes` enumerates. Keeping it unwired is the right state until that is
|
||||
resolved, and the file's own comment already warns against it becoming a fourth
|
||||
arbitration layer.
|
||||
|
||||
### 6.4 Two query sources do not do what their names say
|
||||
|
||||
Two of the twenty-two "query sources" have side effects or read a different
|
||||
substrate than their name implies. `queryNetwork` triggers a live LAN scan
|
||||
inside a read path (`cmd/mavend/netscan.go` `scanSummary`), and the scan writes
|
||||
a note.
|
||||
|
||||
### 6.5 `actionFact` answers queries and chat
|
||||
|
||||
`cmd/mavend/actions_fact.go` re-routes a question-shaped utterance into
|
||||
`actionQuery` and a complaint into `actionChat`. Both re-routes are argued and
|
||||
correct in effect. The consequence is that the fact handler is one of three
|
||||
entry points into the query chain.
|
||||
|
||||
### 6.6 `mavgpud`'s model arm is off and its STT arm is on
|
||||
|
||||
`deploy/mavend.json` sets `workstation.model_disabled: true` while
|
||||
`workstation.stt` is live. One config block, two independently authenticated
|
||||
services, one flag that turns off half of it. The block's own comment explains
|
||||
this. A reader of the topology would not guess it.
|
||||
|
||||
---
|
||||
|
||||
## 7. Hidden shared state
|
||||
|
||||
### 7.1 Six context keys carry per-turn state
|
||||
|
||||
`querySourceKey`, `turnRouteKey`, `dialogueKey`, `ecosystemCorrelationKey`,
|
||||
`traceIDKey` (all `cmd/mavend/`), and `recorderKey`
|
||||
(`internal/decision/decision.go`). Plus `callerKey` in `internal/ipc/api.go`.
|
||||
|
||||
Every one is invisible in a function signature. `turnRouteFrom` returns nil
|
||||
"when the caller is not inside runTurn, a unit test calling one resolver
|
||||
directly, most often". That is the shape of the problem: a resolver behaves
|
||||
differently depending on invisible context.
|
||||
|
||||
### 7.2 Three single-slot confirmation registers under one mutex
|
||||
|
||||
`reactiveHandler.pending`, `pendingRoutine`, `pendingHexis`
|
||||
(`cmd/mavend/voice.go:170-180`). The comment states the posture: "single slot,
|
||||
single-user box, a second act while one waits overwrites it (last-asked wins)".
|
||||
Three separate registers, one shared mutex, and the pre-route ladder decides
|
||||
between them by position rather than by comparing them.
|
||||
|
||||
### 7.3 `surfacedItems` has no TTL
|
||||
|
||||
Same struct. The comment argues it: a stale position resolves to an item Praxis
|
||||
reports as already acknowledged, "which is a harmless answer, unlike a stale
|
||||
confirmation". That is correct given Praxis is the arbiter. It also means an
|
||||
ordinal can refer to a list read out an arbitrarily long time ago.
|
||||
|
||||
### 7.4 The tick loop's memory is in-process and unbounded in one place
|
||||
|
||||
`tickLoop.lastPhrase` is a `map[string]delivery.PhrasedNudge` keyed by rule
|
||||
name, and rules are a fixed set, so it is bounded. `digestQ` is a slice with a
|
||||
config `MaxItems`. `lastProposalAt` is deliberately not persisted: "a restart is
|
||||
allowed to permit one more announcement".
|
||||
|
||||
### 7.5 The clarify store is deliberately not persisted, while the dialogue store is
|
||||
|
||||
`cmd/mavend/voicewire.go`: `dialogue.NewPersistentSessionStore` for follow-up
|
||||
slots, `dialogue.NewClarifyStore` for the parked question. The reasoning is
|
||||
recorded (Vikunja #385). The consequence is that a restart mid-clarify silently
|
||||
drops a request the user believes is parked, and the "expired clarify notice"
|
||||
path in `runTurn` step 1 cannot fire for it, because the store it reads is gone
|
||||
too.
|
||||
|
||||
---
|
||||
|
||||
## 8. Fragile request paths
|
||||
|
||||
### 8.1 Four silent degradations stacked on one turn
|
||||
|
||||
| Seam | Falls back to | Told to the user? |
|
||||
|---|---|---|
|
||||
| workstation model → resident model | `internal/llm/remote.go` `Pair.Complete` | no, by design (`docs/offload.md`) |
|
||||
| CW2 → mavsttd | `cmd/mavend/voicewire.go` `sttSeam` | no |
|
||||
| routing heads → LLM router → classifier | `internal/router/router.go` | no |
|
||||
| search → kiwix → named page → model weights | `cmd/mavend/actions_query.go` | no |
|
||||
|
||||
Each is individually argued. Together, a single answer can be the resident model
|
||||
routing a worse transcript with the classifier as a floor and answering from its
|
||||
own weights, and nothing in the reply distinguishes that from the best case. The
|
||||
only instrument is the decision record and the query-source log line.
|
||||
|
||||
### 8.2 The reminder path depends on a table nobody writes
|
||||
|
||||
`queryCalendar` reads `facts(kind=env, source=caldav:*)`, and `mavcaldav` is
|
||||
commented out in `docker-compose.yml`. `loop.State.CalendarBusy` reads the same
|
||||
facts, so the "do not nag mid-meeting" suppressor is permanently false. The
|
||||
compose comment says both of these explicitly, which makes it a known gap rather
|
||||
than a hidden one.
|
||||
|
||||
### 8.3 Recurring reminders have storage, an IPC parameter, and no caller
|
||||
|
||||
`reminders.cron` and `reminders.next_fire_ts` exist since migration #2
|
||||
(`internal/store/migrations.go`). `ipc.CreateReminder` takes a cron argument.
|
||||
`actionReminder` passes `""`. Nothing on the spoken path can create one.
|
||||
|
||||
### 8.4 Shutdown is a known past failure with a bounded workaround
|
||||
|
||||
`cmd/mavend/main.go` carries the history: long-lived module connections
|
||||
deadlocked every shutdown, `run()` never returned, `defer st.Close()` never
|
||||
sealed, and "the deployed ciphertext was eleven days stale before anyone
|
||||
noticed". The fix is `workerGrace = 4 * time.Second` plus tracked connections.
|
||||
A worker parked in a model call still loses its tick, and the seal proceeds
|
||||
without it.
|
||||
|
||||
### 8.5 One inbound worker is outside the assertable worker set
|
||||
|
||||
`backgroundWorkers` in `cmd/mavend/boot.go` exists so "a test can compare the
|
||||
set the two paths would start without standing a daemon up". `wireTelegramIntake`
|
||||
starts its poller with `wg.Add(1)` and a bare goroutine
|
||||
(`cmd/mavend/telegramintake.go:41`), so it is not in that set. It is at least on
|
||||
the outer `WaitGroup`, unlike the seven workers V-639 fixed.
|
||||
|
||||
### 8.6 The daemon is wired twice, in two places
|
||||
|
||||
`run()` wires everything at boot. `srv.UnlockFn` wires everything again after a
|
||||
passkey assertion. `boot.go` exists because those two lists had already drifted:
|
||||
"seven workers started untracked on the unlock path and two daemonAPI fields
|
||||
were never set there, silently". Both paths now funnel through `newDaemonAPI`
|
||||
and `startBackground`. But `wireRules`, `wireGatherer`, `wirePhraser`,
|
||||
`wireEcosystem`, `wireVoice`, `wireDispatcher`, `wireTickLoop`, the four worker
|
||||
constructors, `wireMailIntake`, `wireModelSwap`, `wireTelegramIntake`,
|
||||
`wireVision`, `wireCapture` and `wireSpeaker` are still listed twice, by hand,
|
||||
in the same file.
|
||||
|
||||
---
|
||||
|
||||
## 9. Difficult-to-test boundaries
|
||||
|
||||
### 9.1 A resolver's behaviour depends on invisible context
|
||||
|
||||
See 7.1. `turnRouteFrom(ctx)` returning nil is the documented test case, and it
|
||||
changes what the resolver does.
|
||||
|
||||
### 9.2 The single-instance handler is the unit under test for ~60 behaviours
|
||||
|
||||
Twenty-two query sources, seven action handlers, eleven pre-route resolvers and
|
||||
the recall gate are all methods on `*reactiveHandler`. Testing one requires
|
||||
constructing a struct with 34 fields, most of them nil.
|
||||
|
||||
### 9.3 The static gates pass against a baseline, and the baseline records the debt
|
||||
|
||||
`scripts/analyzers/deadcode.baseline` accepts thirteen unreachable symbols,
|
||||
eleven of them from the 2026-08-10 audit (V-686), with three marked as
|
||||
"must stay". `make audit` is a git-grep inventory and is explicitly not a
|
||||
reachability check (`CLAUDE.md`).
|
||||
|
||||
### 9.4 Measurement needs weights that are not in the tree
|
||||
|
||||
`make t` self-skips the four `TestONNX*` measurements without `MAVEN_ONNX_LIB`,
|
||||
and still prints `ok` (`CLAUDE.md`). The routing heads, the embedder, silero and
|
||||
the keyword head are all ONNX files under `models/`, bind-mounted from
|
||||
`/mnt/hdd1/llms` in the case of the gguf. A checkout alone cannot reproduce a
|
||||
routing measurement.
|
||||
|
||||
### 9.5 Only 5 of 51 spec entries cite a scenario that exists
|
||||
|
||||
Recorded in the previous session's handoff, from `docs/spec.md` and
|
||||
`cmd/mavend/testdata/scenarios/`. Not re-verified here.
|
||||
|
||||
---
|
||||
|
||||
## 10. Excessive fan-in and fan-out
|
||||
|
||||
**Fan-in.** `ipc.Server` is reached by six processes (mavweb ×3 connections,
|
||||
mavpoll, mavcaldav, mavmaild, mavupdate, e2eprobe) and carries eight function
|
||||
fields that bypass `CoreAPI` entirely: `StepUp`, `UnlockFn`, `WrapKeyFn`,
|
||||
`IngestMailFn`, `SwapModelFn`, `ModelStatusFn`, `DescribeImageFn` and the four
|
||||
`Capture*` fields. Each is nil unless its config block exists, so the wire
|
||||
surface of the daemon depends on `deploy/mavend.json`.
|
||||
|
||||
**Fan-out.** `reactiveHandler` reaches roughly twenty distinct subsystems
|
||||
(4.1). `tickLoop` reaches ten (4.3). `wireVoice` constructs seventeen (4.4).
|
||||
|
||||
**Failure propagation.** The store is the shared point: `SetMaxOpenConns(1)`
|
||||
means every writer in the daemon and every module over IPC serialises through
|
||||
one connection. The measurement backing that cap is
|
||||
`docs/evals/2026-08-07-store-connection-cap.md` (V-642), cited in
|
||||
`internal/ipc/server.go` and not re-run here.
|
||||
|
||||
---
|
||||
|
||||
## 11. What is dark, and what that costs
|
||||
|
||||
Sixteen components are wired in code and off in the deployed configuration:
|
||||
`ntfy`, `zenmoney`, Home Assistant, MCP, the weather provider, the workstation
|
||||
model arm, vision, meeting capture, speaker identification, mail intake, model
|
||||
swap, memory evaluation, and the `mavcaldav` and `mavmaild` services.
|
||||
|
||||
Three of these have a visible cost:
|
||||
|
||||
1. **ntfy disabled** means the away reach is telegram alone, through a SOCKS
|
||||
relay, through `api.telegram.org`. `deploy/mavend.json` documents that this
|
||||
was exactly the fragility ntfy was added to remove: "three things in series
|
||||
that have each failed once, and when they do a sev4 nudge has nowhere to go."
|
||||
2. **mavcaldav absent** disables both the calendar answer and the busy
|
||||
suppressor (8.2).
|
||||
3. **The weather provider is a stub.** `wireVoice` selects Open-Meteo only when
|
||||
`cfg.Voice.Weather.Provider == "open-meteo"`, and the deployed `voice` block
|
||||
has no `weather` key at all. `weather` is nevertheless a live query source with
|
||||
`guesses: true`, so it can claim a turn and answer it from a stub.
|
||||
|
||||
---
|
||||
|
||||
# Questions the current architecture raises
|
||||
|
||||
1. **Which of the three ordered lists is the arbiter?** Stage 0 grammars, the
|
||||
query-source chain and the pre-route ladder each decide by position. If
|
||||
`internal/claim` is the answer, what stops it being a fourth list rather than
|
||||
the thing that collapses the other three?
|
||||
|
||||
2. **Where is the one point that decides whether this authenticated origin may
|
||||
perform this specific effect using this specific evidence?** Today there is
|
||||
no such point. `reboot` shows why the question is not "which tier system
|
||||
wins": it is correctly classified as not irreversible, and that does not
|
||||
imply a room microphone plus "да" should carry reboot authority.
|
||||
Reversibility, effect severity, reach authority and confirmation strength are
|
||||
four dimensions, and `TierDestructive → VoiceMayRun:true` collapses them into
|
||||
one.
|
||||
|
||||
3. **What owns the `facts` key namespace?** Nine writers, two of which store
|
||||
watermarks and tuning parameters in the table that recall embeds. Is `source`
|
||||
meant to be a partition, and if so what enforces it?
|
||||
|
||||
4. **Should the executor live in the key holder?** `systemctl reboot` is a
|
||||
seeded, enabled row in a process holding the unlocked database. The controls
|
||||
are an allowlist and a spoken confirm. Is that the intended trust boundary,
|
||||
or the one that happened?
|
||||
|
||||
5. **Should the tick loop and the turn path share one llama-server?**
|
||||
`internal/llm/gate.go` exists to arbitrate them. What is the acceptable
|
||||
latency a foreground turn may pay for a background nudge being phrased?
|
||||
|
||||
6. **Is a silent four-level degradation still honest?** Each fallback is argued
|
||||
separately. Nothing tells the user when all four fire at once. The M1 honesty
|
||||
milestone in `docs/roadmap.md` is about the turn path. Does it cover this?
|
||||
|
||||
7. **What is `docker-compose.yml` the source of truth for?** Two complete
|
||||
services are commented out in it with their reasoning, and one of them
|
||||
silently disables two behaviours elsewhere. Should absence be expressible in
|
||||
`deploy/mavend.json` where the rest of the capability switches live?
|
||||
|
||||
8. **Why is the daemon wired twice?** `boot.go` fixed the drift that had already
|
||||
happened. Fifteen `wire*` calls are still listed by hand on both paths. Is
|
||||
cold-start unlock worth a second wiring path, or should the locked daemon
|
||||
wire everything and gate at the `Check` hook alone?
|
||||
|
||||
9. **What is a query source allowed to do?** One triggers a live LAN scan and
|
||||
writes a note. If a source may have side effects, what does "first source to
|
||||
claim answers the turn" guarantee about the sources that ran before it?
|
||||
|
||||
10. **Is `mavsttd`/`mavttsd`'s process boundary about the key or about cgo?**
|
||||
The stated reason is key isolation. The operative reason looks like crash
|
||||
isolation from cgo and subprocesses. Which one governs whether the next
|
||||
model caller gets its own process?
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+139
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build maven-evidence.zip: the architecture package plus the source seams a
|
||||
# reviewer needs to test its claims, and nothing else.
|
||||
#
|
||||
# sh docs/architecture/pack_evidence.sh
|
||||
#
|
||||
# Three rules this script exists to enforce:
|
||||
#
|
||||
# 1. Whole files, never snippets. A cut-down file loses the call path that
|
||||
# makes a claim checkable, which is the whole point of sending source.
|
||||
# 2. Allowlist, not denylist. Paths are named one by one below. A denylist
|
||||
# ships whatever nobody thought to exclude, and this tree has a database
|
||||
# key in it.
|
||||
# 3. Refuse rather than warn. The scan at the end aborts on a hit instead of
|
||||
# printing something a tired person scrolls past.
|
||||
#
|
||||
# The one file that is not verbatim is docker-compose.yml. It carries a live
|
||||
# uptime-kuma API key, so a redacted copy goes in its place and the redaction is
|
||||
# recorded in architecture-evidence.txt and printed here.
|
||||
set -euo pipefail
|
||||
|
||||
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
root=$(CDPATH= cd -- "$here/../.." && pwd)
|
||||
cd "$root"
|
||||
|
||||
out=maven-evidence.zip
|
||||
stage=$(mktemp -d)
|
||||
trap 'rm -rf "$stage"' EXIT
|
||||
|
||||
echo "== regenerating the architecture package"
|
||||
python3 "$here/build_inventory.py"
|
||||
python3 "$here/verify_anchors.py" # exits 1 if any claim no longer resolves
|
||||
python3 "$here/build_evidence.py"
|
||||
python3 "$here/build_viewer.py"
|
||||
|
||||
echo "== structural context"
|
||||
# `tree` here is an eza alias in the owner's shell and absent in a plain sh, so
|
||||
# the listing is generated with find and does not depend on either.
|
||||
{
|
||||
echo "# find -L internal cmd -maxdepth 3 -type d"
|
||||
echo
|
||||
find internal cmd -maxdepth 3 -type d | sort
|
||||
echo
|
||||
echo "# go files per package"
|
||||
echo
|
||||
find internal cmd -name '*.go' ! -name '*_test.go' | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn
|
||||
echo
|
||||
echo "# test files per package"
|
||||
echo
|
||||
find internal cmd -name '*_test.go' | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn
|
||||
} > "$here/tree.txt"
|
||||
|
||||
echo "== redacting the one credential in docker-compose.yml"
|
||||
sed 's/"uk5_[^"]*"/"<REDACTED: uptime-kuma api key>"/' docker-compose.yml \
|
||||
> "$here/docker-compose.redacted.yml"
|
||||
if grep -q 'uk5_' "$here/docker-compose.redacted.yml"; then
|
||||
echo "pack_evidence.sh: redaction failed, refusing to build" >&2; exit 1
|
||||
fi
|
||||
diff <(sed 's/"uk5_[^"]*"/X/' docker-compose.yml) \
|
||||
<(sed 's/"<REDACTED: uptime-kuma api key>"/X/' "$here/docker-compose.redacted.yml") \
|
||||
>/dev/null || { echo "pack_evidence.sh: redacted copy differs by more than the key" >&2; exit 1; }
|
||||
|
||||
# ---- the allowlist -------------------------------------------------------
|
||||
# Requested and present. internal/session, internal/db and tests/ are absent
|
||||
# from this repo; architecture-evidence.txt says where their contents live.
|
||||
paths=(
|
||||
docs/architecture
|
||||
CLAUDE.md
|
||||
docs/CLAUDE.md
|
||||
go.mod
|
||||
deploy/mavend.json # ${VAR} placeholders only; 16 off-claims read it
|
||||
cmd/mavend
|
||||
internal/auth
|
||||
internal/tool
|
||||
internal/claim
|
||||
internal/modes
|
||||
internal/router
|
||||
internal/voice
|
||||
internal/ipc # the boundary auth.Can actually runs on
|
||||
internal/store
|
||||
internal/dialogue # clarify + session state the turn path parks in
|
||||
internal/decision # the arbitration record
|
||||
internal/delivery/channel.go
|
||||
internal/loop
|
||||
internal/webauthn # the other half of the auth story
|
||||
cmd/mavwaked/main.go # the client that sends Surface
|
||||
cmd/mavweb/main.go # the six unguarded surfaces
|
||||
)
|
||||
|
||||
echo "== staging"
|
||||
for p in "${paths[@]}"; do
|
||||
if [ ! -e "$p" ]; then echo " MISSING $p (skipped)"; continue; fi
|
||||
mkdir -p "$stage/$(dirname "$p")"
|
||||
cp -r "$p" "$stage/$(dirname "$p")/"
|
||||
done
|
||||
|
||||
# Generated-in-place files that must not travel, and anything that is a secret,
|
||||
# a model, a database or a build artefact regardless of how it got staged.
|
||||
#
|
||||
# The name filters skip .go on purpose: internal/router/singletoken.go matched
|
||||
# '*token*' and was deleted out of the first build of this pack. That is exactly
|
||||
# the silent hole an allowlist exists to prevent, and a Go source file is never
|
||||
# the thing this clause is for.
|
||||
find "$stage" ! -name '*.go' \( \
|
||||
-name '*.db' -o -name '*.sqlite*' -o -name '*.enc' \
|
||||
-o -name '*.pem' -o -name '*.key' -o -name '*.crt' -o -name '*.p12' \
|
||||
-o -name '.env*' -o -name '*.token' -o -name '*.secret' -o -name '*.password' \
|
||||
-o -name '*.onnx' -o -name '*.gguf' -o -name '*.bin' -o -name '*.wav' \
|
||||
-o -name '*.zip' -o -name '*.log' -o -name '.git' \
|
||||
\) -print -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
echo "== scanning the staged tree"
|
||||
# A value-shaped assignment: a credential word, a delimiter, then twelve or more
|
||||
# characters of value. The value must NOT begin with a slash or a dot, because a
|
||||
# docker volume line pairs a host path with a container path and both halves end
|
||||
# in the same secret-sounding filename while containing no secret. Three of those
|
||||
# in docker-compose.yml tripped the first version of this scan.
|
||||
hits=$(grep -rInE '(api[_-]?key|secret|passwo?r?d|bearer|token)["'"'"' ]*[:=]["'"'"' ]*[A-Za-z0-9+_-][A-Za-z0-9/+_-]{11,}' "$stage" \
|
||||
| grep -vE '\$\{|<REDACTED|example|EXAMPLE|xxx|XXX|your-|changeme' \
|
||||
| grep -vE '_test\.go|\.md:' \
|
||||
| grep -vE ':[0-9]+:[[:space:]]*(#|//)' || true)
|
||||
if [ -n "$hits" ]; then
|
||||
echo "pack_evidence.sh: possible credentials in the staged tree, refusing to build:" >&2
|
||||
echo "$hits" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== building $out"
|
||||
rm -f "$out"
|
||||
( cd "$stage" && zip -qr "$root/$out" . )
|
||||
|
||||
echo
|
||||
printf '%s %s %s files\n' "$out" \
|
||||
"$(du -h "$out" 2>/dev/null | cut -f1)" \
|
||||
"$(unzip -l "$out" | tail -1 | awk '{print $2}')"
|
||||
echo
|
||||
echo "redacted: docker-compose.yml -> docs/architecture/docker-compose.redacted.yml"
|
||||
echo " one uptime-kuma api key, nothing else"
|
||||
echo "excluded: .git, deploy/telegram.env, deploy/db_key.env, models, deps, databases"
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/bin/sh
|
||||
# Re-render every diagram in diagrams/*.mmd to a committed SVG beside it, then
|
||||
# rebuild index.html so the viewer carries the new pictures.
|
||||
#
|
||||
# mermaid-cli drives a real browser through puppeteer. It downloads its own
|
||||
# chrome-headless-shell by default, which fails behind a proxy and wastes
|
||||
# 150 MB; PUPPETEER_EXECUTABLE_PATH points it at the system chromium instead.
|
||||
# --no-sandbox is required because that chromium is not the one puppeteer
|
||||
# provisioned and has no sandbox helper of its own here.
|
||||
#
|
||||
# sh docs/architecture/render.sh
|
||||
#
|
||||
# Run it from anywhere. A parse error in one file leaves the others alone and
|
||||
# prints FAIL with the mermaid error, which is the only way this repo has to
|
||||
# syntax-check a .mmd.
|
||||
set -eu
|
||||
|
||||
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
dia="$here/diagrams"
|
||||
cfg=$(mktemp)
|
||||
trap 'rm -f "$cfg"' EXIT
|
||||
printf '{"args":["--no-sandbox","--disable-gpu"]}' > "$cfg"
|
||||
|
||||
: "${PUPPETEER_EXECUTABLE_PATH:=$(command -v chromium || command -v chromium-browser || command -v google-chrome-stable || true)}"
|
||||
if [ -z "$PUPPETEER_EXECUTABLE_PATH" ]; then
|
||||
echo "render.sh: no chromium found. Install one, or set PUPPETEER_EXECUTABLE_PATH." >&2
|
||||
exit 1
|
||||
fi
|
||||
export PUPPETEER_EXECUTABLE_PATH
|
||||
|
||||
for f in "$dia"/*.mmd; do
|
||||
n=$(basename "$f" .mmd)
|
||||
err=$(mktemp)
|
||||
if npx --yes @mermaid-js/mermaid-cli@11 -p "$cfg" -t dark -b '#0e1116' \
|
||||
-i "$f" -o "$dia/$n.svg" >/dev/null 2>"$err" && [ -s "$dia/$n.svg" ]; then
|
||||
echo "OK $n"
|
||||
else
|
||||
echo "FAIL $n"
|
||||
grep -m1 -A3 'Parse error' "$err" || tail -3 "$err"
|
||||
fi
|
||||
rm -f "$err"
|
||||
done
|
||||
|
||||
python3 "$here/build_viewer.py"
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Resolve every claim in maven-architecture.json to a file and a line.
|
||||
|
||||
The inventory names files and symbols. A reader has to take on trust that the
|
||||
symbol is in the file and that the file still exists. This script removes the
|
||||
trust: it looks up every symbol in the component's own files and writes
|
||||
anchors.md, a table of component, symbol, path:line and the verbatim line.
|
||||
|
||||
Exit code is 1 when anything fails to resolve, so it doubles as a staleness
|
||||
gate. A symbol that moved to another file, or a file that was deleted, fails
|
||||
here rather than in a reader's head.
|
||||
|
||||
python3 docs/architecture/verify_anchors.py # write anchors.md
|
||||
python3 docs/architecture/verify_anchors.py --quiet # gate only
|
||||
|
||||
What it deliberately does NOT check: that the symbol means what the
|
||||
responsibility says it means. That is the human pass this file exists to make
|
||||
cheap.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
|
||||
|
||||
# Symbols the inventory names that are not Go identifiers in this repo: config
|
||||
# keys, make targets, flags, wire strings, table names. Looking them up in a .go
|
||||
# file would fail for the wrong reason, so they are resolved against the file
|
||||
# they belong to when possible and skipped when not.
|
||||
NON_GO = re.compile(r"^(make |-|/|\$)|\.(json|sql|service|yml)$| ")
|
||||
|
||||
|
||||
def candidates(sym: str):
|
||||
"""Search forms for one symbol, longest first.
|
||||
|
||||
A dotted symbol like `Store.WriteFact` or `voice.NewServer` is written as a
|
||||
method or a qualified call, so the tail is what appears in a definition and
|
||||
the whole string is what appears at a call site. Try both.
|
||||
"""
|
||||
forms = [sym]
|
||||
if "." in sym:
|
||||
forms.append(sym.split(".")[-1])
|
||||
return forms
|
||||
|
||||
|
||||
def find(paths, sym):
|
||||
for form in candidates(sym):
|
||||
needle = re.compile(r"\b" + re.escape(form) + r"\b")
|
||||
for rel in paths:
|
||||
full = os.path.join(ROOT, rel)
|
||||
if not os.path.isfile(full):
|
||||
continue
|
||||
try:
|
||||
lines = open(full, errors="replace").read().splitlines()
|
||||
except OSError:
|
||||
continue
|
||||
# Three passes, best anchor first: a definition, then any code
|
||||
# line, then a comment. Without the comment pass being last, a
|
||||
# const whose doc comment names it anchors on the prose rather
|
||||
# than on the declaration.
|
||||
for rank in (0, 1, 2):
|
||||
for i, line in enumerate(lines, 1):
|
||||
if not needle.search(line):
|
||||
continue
|
||||
bare = line.strip()
|
||||
comment = bare.startswith(("//", "#", "--", "%%", "*"))
|
||||
isdef = bool(re.match(
|
||||
r"\s*(func|type|const|var)\b", line)) or bool(re.match(
|
||||
r"\s*\"?" + re.escape(form) + r"\"?\s*[:=]", line))
|
||||
got = 2 if comment else (0 if isdef else 1)
|
||||
if got == rank:
|
||||
return rel, i, bare
|
||||
return None, None, None
|
||||
|
||||
|
||||
def expand(rel):
|
||||
"""A directory in the inventory stands for the files under it."""
|
||||
full = os.path.join(ROOT, rel)
|
||||
if os.path.isdir(full):
|
||||
return sorted(
|
||||
os.path.join(rel, f) for f in os.listdir(full)
|
||||
if f.endswith((".go", ".json", ".sql")) and not f.endswith("_test.go")
|
||||
)
|
||||
return [rel]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
quiet = "--quiet" in sys.argv
|
||||
arch = json.load(open(os.path.join(HERE, "maven-architecture.json")))
|
||||
rows, missing_files, unresolved = [], [], []
|
||||
|
||||
for c in arch["components"]:
|
||||
paths = []
|
||||
for f in c["files"]:
|
||||
if not os.path.exists(os.path.join(ROOT, f)):
|
||||
missing_files.append((c["id"], f))
|
||||
continue
|
||||
paths.extend(expand(f))
|
||||
for sym in c["symbols"]:
|
||||
if NON_GO.search(sym):
|
||||
rows.append((c["id"], sym, "", "", "not a Go identifier, not looked up"))
|
||||
continue
|
||||
rel, line, text = find(paths, sym)
|
||||
if rel is None:
|
||||
unresolved.append((c["id"], sym))
|
||||
rows.append((c["id"], sym, "", "", "UNRESOLVED"))
|
||||
else:
|
||||
rows.append((c["id"], sym, f"{rel}:{line}", text, ""))
|
||||
|
||||
resolved = sum(1 for r in rows if r[2])
|
||||
if not quiet:
|
||||
with open(os.path.join(HERE, "anchors.md"), "w") as fh:
|
||||
fh.write("# Claim anchors\n\n")
|
||||
fh.write(
|
||||
"Generated by `docs/architecture/verify_anchors.py`. Every symbol the\n"
|
||||
"inventory names, resolved to a file and a line in this checkout, with the\n"
|
||||
"line quoted. Regenerate after any edit to the inventory or the code.\n\n"
|
||||
)
|
||||
fh.write(
|
||||
f"- components: {len(arch['components'])}\n"
|
||||
f"- symbols claimed: {len(rows)}\n"
|
||||
f"- resolved to a line: {resolved}\n"
|
||||
f"- unresolved: {len(unresolved)}\n"
|
||||
f"- missing files: {len(missing_files)}\n\n"
|
||||
)
|
||||
if unresolved:
|
||||
fh.write("## Unresolved\n\n")
|
||||
for cid, sym in unresolved:
|
||||
fh.write(f"- `{cid}` claims `{sym}` and it is in none of its files\n")
|
||||
fh.write("\n")
|
||||
if missing_files:
|
||||
fh.write("## Missing files\n\n")
|
||||
for cid, f in missing_files:
|
||||
fh.write(f"- `{cid}` names `{f}`, which does not exist\n")
|
||||
fh.write("\n")
|
||||
fh.write("## Anchors\n\n| component | symbol | anchor | line |\n|---|---|---|---|\n")
|
||||
for cid, sym, anchor, text, note in rows:
|
||||
shown = (text or note).replace("|", "\\|")
|
||||
if len(shown) > 120:
|
||||
shown = shown[:117] + "..."
|
||||
fh.write(f"| `{cid}` | `{sym}` | {anchor or '—'} | `{shown}` |\n")
|
||||
|
||||
print(f"symbols {len(rows)}, resolved {resolved}, unresolved {len(unresolved)}, "
|
||||
f"missing files {len(missing_files)}")
|
||||
for cid, sym in unresolved[:20]:
|
||||
print(f" UNRESOLVED {cid} :: {sym}")
|
||||
for cid, f in missing_files[:20]:
|
||||
print(f" MISSING {cid} :: {f}")
|
||||
return 1 if (unresolved or missing_files) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,494 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Maven architecture — as built</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0e1116; --panel:#141922; --panel2:#1a2130; --line:#26304a; --line2:#38456b;
|
||||
--fg:#dfe6f2; --dim:#8d9bb5; --dim2:#5f6c85;
|
||||
--proc:#7fb3ff; --procbg:#16263f;
|
||||
--svc:#8fd0ff; --svcbg:#132433;
|
||||
--store:#6ed0a8; --storebg:#0f2a20;
|
||||
--model:#e0b050; --modelbg:#2a2210;
|
||||
--adapter:#b79bf0; --adapterbg:#221a33;
|
||||
--ext:#c39bd3; --extbg:#241a2b;
|
||||
--bnd:#ff9f6b; --bndbg:#2c1c12;
|
||||
--warn:#e08080; --warnbg:#2e1616;
|
||||
--ok:#7fbf7f;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;height:100%}
|
||||
body{background:var(--bg);color:var(--fg);font:14px/1.5 ui-sans-serif,-apple-system,"Segoe UI",Roboto,sans-serif;overflow:hidden}
|
||||
code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
||||
|
||||
#app{display:grid;grid-template-columns:250px 1fr 420px;grid-template-rows:auto 1fr;height:100vh}
|
||||
header{grid-column:1/-1;display:flex;align-items:center;gap:18px;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--panel)}
|
||||
header h1{font-size:15px;margin:0;font-weight:650;letter-spacing:.2px}
|
||||
header .meta{color:var(--dim2);font-size:12px}
|
||||
header .meta b{color:var(--dim);font-weight:500}
|
||||
|
||||
nav{border-right:1px solid var(--line);background:var(--panel);overflow-y:auto;padding:12px 10px}
|
||||
nav h2{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--dim2);margin:14px 6px 6px}
|
||||
nav h2:first-child{margin-top:0}
|
||||
.viewbtn{display:block;width:100%;text-align:left;background:transparent;border:1px solid transparent;color:var(--dim);
|
||||
padding:7px 9px;border-radius:6px;cursor:pointer;font:inherit;font-size:13px}
|
||||
.viewbtn:hover{background:var(--panel2);color:var(--fg)}
|
||||
.viewbtn.on{background:#1d2c47;border-color:var(--line2);color:#fff}
|
||||
.viewbtn small{display:block;color:var(--dim2);font-size:11px;line-height:1.35;margin-top:2px}
|
||||
.toggle{display:flex;align-items:center;gap:8px;padding:5px 7px;color:var(--dim);font-size:12.5px;cursor:pointer;border-radius:5px}
|
||||
.toggle:hover{background:var(--panel2)}
|
||||
.toggle input{accent-color:#5b8ff9}
|
||||
.legend{display:flex;flex-wrap:wrap;gap:5px;padding:4px 6px}
|
||||
.legend span{font-size:10.5px;padding:2px 6px;border-radius:99px;border:1px solid var(--line2);color:var(--dim)}
|
||||
|
||||
main{position:relative;overflow:auto;padding:18px 20px 60px}
|
||||
.lane{margin-bottom:20px}
|
||||
.lane-h{display:flex;align-items:baseline;gap:10px;margin:0 0 8px;cursor:pointer;user-select:none}
|
||||
.lane-h b{font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--dim)}
|
||||
.lane-h i{font-style:normal;color:var(--dim2);font-size:11px}
|
||||
.lane-h .caret{color:var(--dim2);font-size:11px;width:10px}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:8px}
|
||||
.chip{position:relative;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:7px 10px;cursor:pointer;
|
||||
max-width:280px;transition:border-color .12s,background .12s}
|
||||
.chip:hover{border-color:var(--line2)}
|
||||
.chip.sel{border-color:#7fb3ff;background:#1b2b45;box-shadow:0 0 0 1px #7fb3ff44}
|
||||
.chip.rel{border-color:#4a5f8f}
|
||||
.chip.dim{opacity:.28}
|
||||
.chip .nm{font-weight:600;font-size:13px}
|
||||
.chip .ty{font-size:10.5px;color:var(--dim2);letter-spacing:.04em;text-transform:uppercase}
|
||||
.chip .badges{display:flex;gap:4px;margin-top:4px;flex-wrap:wrap}
|
||||
.b{font-size:9.5px;padding:1px 5px;border-radius:99px;border:1px solid currentColor;letter-spacing:.03em}
|
||||
.b.off{color:#c9a227}.b.nd{color:#c98a27}.b.pl{color:#a07fe0}.b.tmp{color:#8d9bb5}.b.pw{color:#e08080}
|
||||
.b.lo{color:#e08080}.b.me{color:#c9a227}
|
||||
.chip[data-t=process]{border-left:3px solid var(--proc)}
|
||||
.chip[data-t=service],.chip[data-t=worker],.chip[data-t=handler]{border-left:3px solid var(--svc)}
|
||||
.chip[data-t=arbitration],.chip[data-t=query_source]{border-left:3px solid #ffd479}
|
||||
.chip[data-t=storage],.chip[data-t=table]{border-left:3px solid var(--store)}
|
||||
.chip[data-t=model]{border-left:3px solid var(--model)}
|
||||
.chip[data-t=adapter]{border-left:3px solid var(--adapter)}
|
||||
.chip[data-t=external]{border-left:3px solid var(--ext)}
|
||||
.chip[data-t=boundary]{border-left:3px solid var(--bnd)}
|
||||
.chip[data-t="shared-state"]{border-left:3px solid #ff9ec7}
|
||||
.chip[data-t=planned]{border-left:3px solid #a07fe0}
|
||||
.chip[data-t=config],.chip[data-t=test]{border-left:3px solid var(--dim2)}
|
||||
svg.wires{position:absolute;inset:0;pointer-events:none;overflow:visible}
|
||||
|
||||
aside{border-left:1px solid var(--line);background:var(--panel);overflow-y:auto;padding:16px 16px 60px}
|
||||
aside .empty{color:var(--dim2);font-size:13px;margin-top:30px;line-height:1.7}
|
||||
aside h3{margin:0 0 2px;font-size:16px}
|
||||
aside .sub{color:var(--dim2);font-size:11px;text-transform:uppercase;letter-spacing:.08em;margin-bottom:10px}
|
||||
aside section{margin-top:16px}
|
||||
aside section > h4{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--dim2);margin:0 0 6px}
|
||||
aside p{margin:0 0 8px;color:#c9d3e6}
|
||||
.note{background:#1c1f14;border-left:2px solid #c9a227;padding:8px 10px;border-radius:0 5px 5px 0;color:#ded6b6;font-size:12.5px}
|
||||
ul.plain{list-style:none;margin:0;padding:0}
|
||||
ul.plain li{padding:3px 0;border-bottom:1px solid #1d2433;font-size:12.5px}
|
||||
ul.plain li:last-child{border-bottom:0}
|
||||
.rel{display:block;width:100%;text-align:left;background:transparent;border:0;color:#a9c6f5;cursor:pointer;font:inherit;font-size:12.5px;padding:3px 0}
|
||||
.rel:hover{color:#fff;text-decoration:underline}
|
||||
.rel .k{display:inline-block;min-width:66px;color:var(--dim2);font-size:10.5px;text-transform:uppercase;letter-spacing:.05em}
|
||||
.rel .ev{display:block;color:var(--dim2);font-size:11px;margin-left:66px;line-height:1.4}
|
||||
pre.mm{white-space:pre-wrap;word-break:break-word;background:#0b0e13;border:1px solid var(--line);border-radius:6px;
|
||||
padding:12px;font-size:11.5px;color:#b8c6de;overflow-x:auto;max-height:none}
|
||||
.searchbox{width:100%;background:var(--panel2);border:1px solid var(--line);border-radius:6px;color:var(--fg);
|
||||
padding:7px 9px;font:inherit;font-size:12.5px}
|
||||
.searchbox:focus{outline:none;border-color:var(--line2)}
|
||||
.results{margin-top:6px;max-height:280px;overflow:auto}
|
||||
.results button{display:block;width:100%;text-align:left;background:transparent;border:0;color:var(--dim);
|
||||
padding:5px 7px;border-radius:5px;cursor:pointer;font:inherit;font-size:12px}
|
||||
.results button:hover{background:var(--panel2);color:#fff}
|
||||
.results button em{font-style:normal;color:#ffd479}
|
||||
.flowsel{display:flex;gap:6px;margin-bottom:14px;flex-wrap:wrap}
|
||||
.flowsel button{background:var(--panel2);border:1px solid var(--line);color:var(--dim);border-radius:6px;
|
||||
padding:6px 11px;cursor:pointer;font:inherit;font-size:12.5px}
|
||||
.flowsel button.on{background:#1d2c47;border-color:var(--line2);color:#fff}
|
||||
ol.steps{counter-reset:s;list-style:none;margin:0;padding:0;max-width:1000px}
|
||||
ol.steps li{position:relative;padding:9px 12px 9px 44px;border-left:2px solid var(--line);margin-left:14px}
|
||||
ol.steps li:before{counter-increment:s;content:counter(s);position:absolute;left:-13px;top:9px;width:24px;height:24px;
|
||||
border-radius:99px;background:var(--panel2);border:1px solid var(--line2);color:var(--dim);font-size:11px;
|
||||
display:flex;align-items:center;justify-content:center}
|
||||
ol.steps li.branch{border-left-color:var(--warn);background:#1e1414}
|
||||
ol.steps li.branch:before{border-color:var(--warn);color:#e08080}
|
||||
ol.steps b{color:#fff}
|
||||
ol.steps .who{display:inline-block;background:#1d2c47;border:1px solid var(--line2);border-radius:4px;
|
||||
padding:0 6px;font-size:11px;color:#a9c6f5;cursor:pointer;margin-right:8px}
|
||||
ol.steps .who:hover{color:#fff;border-color:#7fb3ff}
|
||||
ol.steps .ev{display:block;color:var(--dim2);font-size:11.5px;margin-top:3px}
|
||||
.viewnote{max-width:1000px;color:var(--dim);font-size:12.5px;background:var(--panel2);border:1px solid var(--line);
|
||||
border-radius:7px;padding:11px 13px;margin-bottom:18px}
|
||||
.viewnote b{color:var(--fg)}
|
||||
.dia{margin-bottom:20px;border:1px solid var(--line);border-radius:8px;background:#0b0e13;overflow:hidden}
|
||||
.dia-h{display:flex;align-items:center;gap:10px;padding:8px 12px;background:var(--panel2);border-bottom:1px solid var(--line);cursor:pointer;user-select:none}
|
||||
.dia-h b{font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--dim)}
|
||||
.dia-h .fn{color:var(--dim2);font-size:11px}
|
||||
.dia-h .zoom{margin-left:auto;display:flex;gap:4px}
|
||||
.dia-h .zoom button{background:var(--panel);border:1px solid var(--line);color:var(--dim);border-radius:4px;
|
||||
width:24px;height:22px;cursor:pointer;font:inherit;font-size:12px;line-height:1}
|
||||
.dia-h .zoom button:hover{color:#fff;border-color:var(--line2)}
|
||||
.dia-body{overflow:auto;max-height:70vh;padding:10px}
|
||||
.dia-body > div{transform-origin:0 0}
|
||||
.dia-body svg{max-width:none;height:auto;display:block}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header>
|
||||
<h1>Maven — architecture as built</h1>
|
||||
<div class="meta">commit <b id="commit"></b> · <b id="gen"></b> · <b id="counts"></b></div>
|
||||
<div class="meta" id="dirty"></div>
|
||||
</header>
|
||||
|
||||
<nav>
|
||||
<h2>Views</h2>
|
||||
<div id="views"></div>
|
||||
<h2>Search</h2>
|
||||
<input class="searchbox" id="q" placeholder="component, file or symbol">
|
||||
<div class="results" id="results"></div>
|
||||
<h2>Filters</h2>
|
||||
<label class="toggle"><input type="checkbox" id="tLow" checked> show low-confidence relations</label>
|
||||
<label class="toggle"><input type="checkbox" id="tMed" checked> show medium-confidence relations</label>
|
||||
<label class="toggle"><input type="checkbox" id="tOff" checked> show configured-off</label>
|
||||
<label class="toggle"><input type="checkbox" id="tUndeployed" checked> show built-not-deployed</label>
|
||||
<label class="toggle"><input type="checkbox" id="tPlanned" checked> show planned / unwired</label>
|
||||
<label class="toggle"><input type="checkbox" id="tWires" checked> draw relation wires</label>
|
||||
<h2>Type</h2>
|
||||
<div class="legend" id="legend"></div>
|
||||
</nav>
|
||||
|
||||
<main id="main"></main>
|
||||
<aside id="side"><div class="empty">Select a component to see its responsibility, the files and symbols it was read from, and every relation in and out.<br><br>Every claim here cites a file. Nothing is inferred from a directory name.</div></aside>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/*__DATA__*/
|
||||
|
||||
const byId = Object.fromEntries(ARCH.components.map(c => [c.id, c]));
|
||||
const S = { view: 'v1', sel: null, flow: 'reminder', collapsed: {}, diaClosed: false, diaZoom: 1 };
|
||||
|
||||
/* ---------------- view definitions ---------------- */
|
||||
const VIEWS = [
|
||||
{ id:'v1', name:'1 · System topology', hint:'Processes and external systems, with process boundaries drawn.',
|
||||
note:'<b>mavend is the centre because the code makes it one.</b> It is the only holder of the database key, it owns the store, the IPC socket, the voice TCP listener, the tick loop, eight background workers and the child llama-server. Every other daemon is key-free and fail-independent. Five services run under docker-compose; two more are built and commented out; two run under systemd on the workstation.',
|
||||
lanes:[
|
||||
['homesrv — docker compose', c => c.group==='homesrv' && ['process','model','boundary','external'].includes(c.type)],
|
||||
['workpc — systemd user units', c => c.group==='workpc'],
|
||||
['ecosystem network', c => c.group==='ecosystem'],
|
||||
['internet / LAN', c => ['internet','lan'].includes(c.group)],
|
||||
['dev and recovery binaries', c => c.group==='dev' || ['proc.mavseal','proc.mavupdate'].includes(c.id)],
|
||||
['configuration boundary', c => c.type==='config'],
|
||||
]},
|
||||
{ id:'v2', name:'2 · Core internals', hint:'The real path through mavend, in the order runTurn runs it.',
|
||||
note:'The implementation does <b>not</b> follow input → routing → intent → state → tools → response. Eleven stateful pre-emptors get first refusal <b>before</b> routing; the routing cascade is four arms deep; and an <code>IntentQuery</code> then enters a <b>second</b> arbitration of twenty-two ordered sources. The proactive half shares no code with any of it.',
|
||||
lanes:[
|
||||
['input and entry', c => ['core.voice_server','core.ipc_server','core.daemon_api','core.intake_api','core.store_api','core.stt_seam','core.telegram_intake','core.auth_gate','core.daemon_lock'].includes(c.id)],
|
||||
['turn pipeline', c => ['core.reactive_handler','core.turn_route','core.preroute','core.action_table'].includes(c.id)],
|
||||
['routing cascade', c => c.id.startsWith('router.')],
|
||||
['intent handlers', c => c.type==='handler' || c.id==='core.query_chain'],
|
||||
['query sources — the second arbitration, in table order', c => c.type==='query_source'],
|
||||
['response generation', c => ['core.replier','core.phraser','core.tts_seam','core.model_seam','core.recall','core.topics'].includes(c.id)],
|
||||
['proactive half', c => ['core.tick_loop','core.gatherer','core.rules','core.pattern','core.morning','core.routines','core.dispatcher','core.sink_voice','core.sink_ntfy','core.sink_telegram'].includes(c.id)],
|
||||
['background workers', c => c.type==='worker' && c.id!=='core.tick_loop' && c.id!=='core.telegram_intake'],
|
||||
['dark capabilities — wired, no config block', c => ['core.vision','core.capture','core.speaker','core.mail_intake','core.modelswap','core.netscan','core.memory_eval'].includes(c.id)],
|
||||
['construction and diagnosis', c => ['core.wiring','core.decision_trace','core.event_bus'].includes(c.id)],
|
||||
]},
|
||||
{ id:'v3', name:'3 · Runtime flow', hint:'Three representative requests traced through real code.', flow:true },
|
||||
{ id:'v4', name:'4 · State ownership', hint:'Every persistent and shared store, its owner, writers and readers.',
|
||||
note:'One process owns the database and every write is serialised at it: <code>SetMaxOpenConns(1)</code>. Three tables are nevertheless written by components that do not know about each other — <b>facts</b> by nine, <b>notes</b> by six, <b>tools</b> by four. Two files live outside the database entirely, and together they weaken the at-rest key.',
|
||||
lanes:[
|
||||
['authoritative owner', c => ['proc.mavend','state.db'].includes(c.id)],
|
||||
['database lifecycle', c => ['state.db_file','state.db_tmpfs','state.wrapped_key','proc.mavseal'].includes(c.id)],
|
||||
['tables written by unrelated components', c => ['state.facts','state.notes','state.tools'].includes(c.id)],
|
||||
['singly-owned tables', c => c.type==='table' && !['state.facts','state.notes','state.tools'].includes(c.id)],
|
||||
['shared mutable state — process-local', c => c.type==='shared-state'],
|
||||
['state outside the database', c => ['state.passkey_file','state.maildata','state.media_blobs'].includes(c.id)],
|
||||
['writers', c => (c.writes||[]).length>0 && c.type!=='table'],
|
||||
['readers', c => (c.reads||[]).length>0 && c.type!=='table' && !(c.writes||[]).length],
|
||||
]},
|
||||
{ id:'v5', name:'5 · Dependency and boundary map', hint:'Components, not classes. Cycles, cross-layer calls, fan-in and fan-out.',
|
||||
note:'The one <b>cycle</b> is deliberate and documented at both ends: <code>daemonAPI.chatFn = handler.handleText</code> and <code>handler.api</code> back-patched by <code>upgradeAPI</code>. The <b>fan-in</b> hotspot is <code>ipc.Server</code>, reached by six processes and carrying eight function fields that bypass CoreAPI entirely. The <b>fan-out</b> hotspots are <code>reactiveHandler</code> (34 fields) and <code>tickLoop</code> (thirteen jobs in one function).',
|
||||
lanes:[
|
||||
['process and network boundaries', c => c.type==='boundary'],
|
||||
['entry layer', c => ['core.ipc_server','core.voice_server','core.telegram_intake','core.auth_gate'].includes(c.id)],
|
||||
['API layer', c => c.type==='adapter'],
|
||||
['turn layer — fan-out hotspot', c => ['core.reactive_handler','core.turn_route','core.preroute','core.action_table','core.query_chain'].includes(c.id)],
|
||||
['routing layer', c => c.id.startsWith('router.')],
|
||||
['proactive layer — fan-out hotspot', c => ['core.tick_loop','core.gatherer','core.rules','core.dispatcher'].includes(c.id)],
|
||||
['construction layer', c => ['core.wiring'].includes(c.id)],
|
||||
['state layer', c => ['state.db'].includes(c.id) || c.type==='shared-state'],
|
||||
['evaluation and gates', c => c.type==='test'],
|
||||
]},
|
||||
];
|
||||
|
||||
/* ---------------- runtime flows ---------------- */
|
||||
const FLOWS = {
|
||||
reminder: { name:'A reminder request', file:'03a-flow-reminder.mmd', steps:[
|
||||
{who:['proc.mavwaked'], t:'The keyword head scores the utterance at or above 0.999 and silero VAD closes it. One clean blob ships over the ssh tunnel.', ev:'cmd/mavwaked/wakeword.go, deploy/mavwaked.service'},
|
||||
{who:['core.voice_server','core.reactive_handler'], t:'The conn already has a Session; HandlePushToTalk transcribes and enters runTurn.', ev:'internal/voice/server.go, cmd/mavend/voice.go'},
|
||||
{who:['core.decision_trace'], t:'A decision record is installed on the context before anything can claim the turn, so the mic, telegram and the web leave the same trail.', ev:'cmd/mavend/voice.go step 0'},
|
||||
{who:['core.preroute'], t:'Eleven rungs get first refusal. None claims "напомни позвонить маме".', ev:'cmd/mavend/voice.go steps 1 to 5e, preRouteLadder'},
|
||||
{who:['router.stage0','router.cascade'], t:'ReminderGrammar matches at stage 0 and wins outright at confidence 1.0. The extractor then fills the slots the grammar did not match.', ev:'internal/router/stagezero.go, router.go fillMatchedSlots'},
|
||||
{who:['core.reactive_handler'], t:'BRANCH — the route is confident and incomplete. missingFor names `time`, so step 8 fires even though dec.Clarify is false.', ev:'cmd/mavend/voice.go step 8, Vikunja #557', branch:true},
|
||||
{who:['state.clarify_store'], t:'The request is parked as a PendingQuestion and she asks one question about one thing. The store is in memory on purpose: a restart expires it.', ev:'internal/dialogue/clarify.go, cmd/mavend/clarify.go'},
|
||||
{who:['core.preroute'], t:'The next utterance is claimed by rung 4, resolveClarifyAnswer, and parsed with the same parsers stage 2 uses.', ev:'cmd/mavend/clarify.go finishClarified'},
|
||||
{who:['core.action_reminder'], t:'ResolvedTheHour guards a time the parser did not really read. The row is written, and the confirmation is phrased FROM THE ROW, never from the utterance.', ev:'cmd/mavend/actions_reminder.go, Vikunja #507'},
|
||||
{who:['state.reminders'], t:'One append. cron and next_fire_ts exist as columns and this path never sets them.', ev:'internal/store/reminders.go, migrations.go #2', branch:true},
|
||||
{who:['core.tick_loop','core.gatherer'], t:'Later, on a 60s ticker: the gatherer collapses due reminders by delivery group and RemindDecisions bypasses the restraint gate.', ev:'internal/loop/gather.go collapseReminders, loop.go RemindDecisions'},
|
||||
{who:['core.dispatcher','state.delivery_attempts'], t:'The outbox records intent BEFORE the external send, so a crash leaves a pending row rather than silence.', ev:'internal/delivery/dispatcher.go beginReminderOutbox'},
|
||||
{who:['core.sink_voice','core.sink_telegram'], t:'Voice when a session is live; away, ntfy is nil because the config disables it, so telegram carries it. A definite failure advances the persisted bounded backoff.', ev:'internal/delivery/channel.go ChannelsFor, cmd/mavend/main.go wireNtfySink'},
|
||||
]},
|
||||
fact: { name:'A factual / state update', file:'03b-flow-fact.mmd', steps:[
|
||||
{who:['core.reactive_handler','router.cascade'], t:'"выпил воды" reaches the cascade. Stage 0 declines, the heads or the LLM router or the classifier names IntentFact with a key and a value.', ev:'internal/router/router.go Route'},
|
||||
{who:['core.action_fact'], t:'BRANCH — a question-shaped utterance is never a fact. It is re-routed into actionQuery with the model-guessed key cleared, and the stage 0 world destination reconstructed so the boundary cannot claim it.', ev:'cmd/mavend/actions_fact.go, Vikunja #470', branch:true},
|
||||
{who:['core.action_fact','core.action_chat'], t:'BRANCH — a passing complaint is not a fact either. It becomes chat and stores nothing, because recall reads a self row back later as if it were still true.', ev:'cmd/mavend/actions_fact.go, Vikunja #481', branch:true},
|
||||
{who:['core.intake_api','state.facts'], t:'WriteFact appends kind=self, source=tap:voice, Subject=Key. Confidence is 1.0 only for a value he actually said. The decorator publishes one intake envelope.', ev:'cmd/mavend/actions_fact.go factConfidence, cmd/mavend/intake.go'},
|
||||
{who:['state.memory_vectors'], t:'The key’s old vectors are pruned, then the FACT text is embedded with the passage prefix and inserted. The utterance rides along as provenance and is never embedded.', ev:'cmd/mavend/actions_fact.go pruneFactVectors, Vikunja #493'},
|
||||
{who:['core.reactive_handler'], t:'Step 9b: a fact that answers a live nudge closes it as `acted`, silently. The fact reply stands.', ev:'cmd/mavend/ack.go ackFromFact'},
|
||||
{who:['core.fact_enrichment','ext.nexus'], t:'Asynchronously, the enrichment worker resolves Subject to a canonical entity id with per-fact backoff. An ambiguous result is NOT stored.', ev:'cmd/mavend/factenrichment.go resolveOne'},
|
||||
{who:['core.tick_loop','core.morning'], t:'On the next tick the morning routine sees the item evidenced inside its window and will not nudge for it.', ev:'cmd/mavend/tick_morning.go gatherMorningFacts'},
|
||||
{who:['core.pattern','state.events'], t:'detectPatterns scans every action+object pair for a stable interval and may propose a routine. notify is false in the deployed config, so it proposes silently.', ev:'cmd/mavend/tick_routines.go, deploy/mavend.json pattern_proposals'},
|
||||
{who:['state.facts'], t:'A wrong value is superseded, never overwritten: voids_id points at the row it cancels, and both correction paths drop the key’s vectors.', ev:'internal/store/schema.sql, internal/store/facts.go'},
|
||||
]},
|
||||
world: { name:'A world query, tool-backed', file:'03c-flow-world-query.mmd', steps:[
|
||||
{who:['router.stage0'], t:'"что такое TCP?" matches WorldQueryGrammars, a literal definition frame. That match sets Source=SourceWorld AND SourceAnchored, which happens here and nowhere else in the cascade.', ev:'internal/router/router.go d.SourceAnchored = d.Source != SourceUnknown'},
|
||||
{who:['core.query_chain','core.decision_trace'], t:'actionQuery declares the full 22-source roster to the record, so a reader can tell "looked and passed" from "never asked".', ev:'cmd/mavend/actions_query.go decision.Expect'},
|
||||
{who:['core.query_chain'], t:'queryWalk removes only the sources marked guesses:true whose destination is not world. Sources that LOOK are all still asked, because a named destination is evidence and not a promise.', ev:'cmd/mavend/actions_query.go queryWalk'},
|
||||
{who:['core.q.personal'], t:'BRANCH — the personal boundary is dropped only because a literal pattern named the destination. A model or a softmax naming SourceWorld would NOT drop it.', ev:'cmd/mavend/actions_query.go queryWalk anchored, V-666', branch:true},
|
||||
{who:['core.q.factbykey','core.q.memory','core.q.notes'], t:'His own data still gets its turn: fact-by-key, the day plan, tasks, money, history, the calendar, then the three recall passes gated by min score 0.80 and min margin 0.008.', ev:'cmd/mavend/actions_query.go querySources, deploy/mavend.json'},
|
||||
{who:['core.q.search','ext.searxng'], t:'SearXNG is asked verbatim, with no rewriter. Only the query string leaves the box: no note, no fact, no persona block, no history.', ev:'cmd/mavend/actions_query.go querySearch'},
|
||||
{who:['core.phraser'], t:'The snippets are handed over as evidence for the question, trimmed under one budget, and phrased. With no phraser the best snippet is read back rather than pretending the search did not happen.', ev:'cmd/mavend/actions_query.go phraseSource, readBack'},
|
||||
{who:['core.q.kiwix','ext.kiwix'], t:'BRANCH — empty or unreachable falls through to the offline ZIMs, Russian first. No results there is not announced.', ev:'cmd/mavend/actions_query.go queryKiwix', branch:true},
|
||||
{who:['core.q.web'], t:'A page he named by URL is read only if he actually said a URL, and it sits AFTER the ZIMs on purpose.', ev:'cmd/mavend/actions_query.go queryWeb, Vikunja #259'},
|
||||
{who:['core.q.general'], t:'Last: the resident model answers from its own weights. Response.Empty() is the whole gate on a world answer; there is no quality threshold in front of it.', ev:'cmd/mavend/actions_query.go queryGeneral, CLAUDE.md'},
|
||||
{who:['core.query_chain'], t:'Whichever source claimed is logged and noted on the turn sink, so /chat can show it. Everyone below the winner is recorded as NeverAsked.', ev:'cmd/mavend/querysource.go noteQuerySource, Vikunja #474'},
|
||||
]},
|
||||
};
|
||||
|
||||
/* ---------------- filters ---------------- */
|
||||
const T = id => document.getElementById(id).checked;
|
||||
function statusHidden(st){
|
||||
if (st==='configured-off') return !T('tOff');
|
||||
if (st==='built-not-deployed') return !T('tUndeployed');
|
||||
if (st==='planned-unwired'||st==='dead') return !T('tPlanned');
|
||||
return false;
|
||||
}
|
||||
function edgeHidden(e){
|
||||
if (e.confidence==='low' && !T('tLow')) return true;
|
||||
if (e.confidence==='medium' && !T('tMed')) return true;
|
||||
return statusHidden(e.status);
|
||||
}
|
||||
const edgesOf = id => ARCH.edges.filter(e => e.from===id || e.to===id);
|
||||
|
||||
/* ---------------- rendering ---------------- */
|
||||
function badges(c){
|
||||
const out=[];
|
||||
if (c.status!=='implemented') out.push(`<span class="b ${ {'configured-off':'off','built-not-deployed':'nd','planned-unwired':'pl','temporary':'tmp','partially-wired':'pw','dead':'pl'}[c.status]||'tmp'}">${c.status}</span>`);
|
||||
if (c.confidence==='low') out.push('<span class="b lo">uncertain</span>');
|
||||
if (c.confidence==='medium') out.push('<span class="b me">medium confidence</span>');
|
||||
return out.length?`<div class="badges">${out.join('')}</div>`:'';
|
||||
}
|
||||
|
||||
function chipHTML(c){
|
||||
return `<div class="chip" data-id="${c.id}" data-t="${c.type}">
|
||||
<div class="nm">${c.id.split('.').pop().replace(/_/g,' ')}</div>
|
||||
<div class="ty">${c.type} · ${c.id}</div>${badges(c)}</div>`;
|
||||
}
|
||||
|
||||
function renderView(){
|
||||
const v = VIEWS.find(x=>x.id===S.view);
|
||||
const main = document.getElementById('main');
|
||||
if (v.flow) return renderFlow(main);
|
||||
let html = v.note ? `<div class="viewnote">${v.note}</div>` : '';
|
||||
html += diagramPanel(diagramFileFor());
|
||||
const used = new Set();
|
||||
v.lanes.forEach(([title, pred], i) => {
|
||||
const items = ARCH.components.filter(c => !used.has(c.id) && pred(c) && !statusHidden(c.status));
|
||||
items.forEach(c=>used.add(c.id));
|
||||
if (!items.length) return;
|
||||
const key = v.id+':'+i, open = !S.collapsed[key];
|
||||
html += `<div class="lane"><div class="lane-h" data-lane="${key}">
|
||||
<span class="caret">${open?'▾':'▸'}</span><b>${title}</b><i>${items.length}</i></div>
|
||||
<div class="chips" ${open?'':'style="display:none"'}>${items.map(chipHTML).join('')}</div></div>`;
|
||||
});
|
||||
html += `<svg class="wires" id="wires"></svg>`;
|
||||
main.innerHTML = html;
|
||||
main.querySelectorAll('.lane-h').forEach(h=>h.onclick=()=>{ S.collapsed[h.dataset.lane]=!S.collapsed[h.dataset.lane]; renderView(); paint(); });
|
||||
main.querySelectorAll('.chip').forEach(ch=>ch.onclick=()=>select(ch.dataset.id));
|
||||
wireDiagram();
|
||||
requestAnimationFrame(drawWires);
|
||||
}
|
||||
|
||||
function renderFlow(main){
|
||||
const f = FLOWS[S.flow];
|
||||
main.innerHTML = `${diagramPanel(FLOWS[S.flow].file)}<div class="viewnote"><b>Three requests, traced through real code.</b> Steps marked in red are branches, fallbacks or refusals the implementation actually takes. Click a component name to open its record. The Mermaid sequence source for each flow is under the panel on the right.</div>
|
||||
<div class="flowsel">${Object.entries(FLOWS).map(([k,x])=>`<button data-f="${k}" class="${k===S.flow?'on':''}">${x.name}</button>`).join('')}</div>
|
||||
<ol class="steps">${f.steps.map(s=>`<li class="${s.branch?'branch':''}">
|
||||
${s.who.map(w=>`<span class="who" data-id="${w}">${byId[w]?byId[w].id:w}</span>`).join('')}
|
||||
${s.t}<span class="ev">${s.ev}</span></li>`).join('')}</ol>`;
|
||||
main.querySelectorAll('.flowsel button').forEach(b=>b.onclick=()=>{S.flow=b.dataset.f;renderFlow(main);});
|
||||
main.querySelectorAll('.who').forEach(b=>b.onclick=()=>select(b.dataset.id));
|
||||
wireDiagram();
|
||||
}
|
||||
|
||||
function diagramFileFor(){
|
||||
return {v1:'01-system-topology.mmd',v2:'02-core-internals.mmd',
|
||||
v4:'04-state-ownership.mmd',v5:'05-dependency-boundary.mmd'}[S.view] || null;
|
||||
}
|
||||
|
||||
// The rendered picture, from the committed SVG beside the .mmd. Absent SVG ⇒
|
||||
// no panel at all, rather than an empty frame: `sh docs/architecture/render.sh`
|
||||
// is what fills it, and a missing file means that has not been run.
|
||||
function diagramPanel(mmFile){
|
||||
if (!mmFile) return '';
|
||||
const svg = SVG[mmFile.replace(/\.mmd$/, '.svg')];
|
||||
if (!svg) return '';
|
||||
const open = !S.diaClosed;
|
||||
return `<div class="dia">
|
||||
<div class="dia-h" id="diaH"><span class="caret">${open?'▾':'▸'}</span><b>Rendered diagram</b>
|
||||
<span class="fn">diagrams/${mmFile.replace(/\.mmd$/,'.svg')}</span>
|
||||
<span class="zoom"><button data-z="-1" title="zoom out">−</button><button data-z="0" title="fit">◻</button><button data-z="1" title="zoom in">+</button></span>
|
||||
</div>
|
||||
<div class="dia-body" id="diaBody" ${open?'':'style="display:none"'}><div id="diaScale">${svg}</div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function wireDiagram(){
|
||||
const h = document.getElementById('diaH'); if (!h) return;
|
||||
const body = document.getElementById('diaBody'), scale = document.getElementById('diaScale');
|
||||
// The mermaid SVG carries width="100%" and a viewBox, so it fills whatever
|
||||
// box it is given. Widening the wrapper past 100% is the zoom, and the
|
||||
// .dia-body scrollbar is what makes the extra width reachable. A CSS
|
||||
// transform would scale the scrollport too and clip the bottom of a tall
|
||||
// flowchart, which 01-system-topology is at 2304x3542.
|
||||
const apply = () => { scale.style.width = (S.diaZoom*100)+'%'; };
|
||||
h.onclick = ev => {
|
||||
const z = ev.target.closest('button');
|
||||
if (z){ ev.stopPropagation();
|
||||
const d = +z.dataset.z;
|
||||
S.diaZoom = d===0 ? 1 : Math.min(3, Math.max(.25, S.diaZoom + d*0.2));
|
||||
apply(); return; }
|
||||
S.diaClosed = !S.diaClosed; renderView(); if (S.sel) select(S.sel);
|
||||
};
|
||||
apply();
|
||||
}
|
||||
|
||||
function drawWires(){
|
||||
const svg = document.getElementById('wires');
|
||||
if (!svg) return;
|
||||
svg.innerHTML='';
|
||||
if (!T('tWires') || !S.sel) return;
|
||||
const main = document.getElementById('main'), mr = main.getBoundingClientRect();
|
||||
const pos = id => { const el = main.querySelector(`.chip[data-id="${id}"]`); if(!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return {x:r.left-mr.left+main.scrollLeft+r.width/2, y:r.top-mr.top+main.scrollTop+r.height/2}; };
|
||||
const a = pos(S.sel); if (!a) return;
|
||||
edgesOf(S.sel).filter(e=>!edgeHidden(e)).forEach(e=>{
|
||||
const other = e.from===S.sel ? e.to : e.from, b = pos(other); if (!b) return;
|
||||
const out = e.from===S.sel;
|
||||
const col = e.confidence==='low' ? '#e08080' : e.confidence==='medium' ? '#c9a227' : (out?'#7fb3ff':'#6ed0a8');
|
||||
const mx = (a.x+b.x)/2;
|
||||
const p = document.createElementNS('http://www.w3.org/2000/svg','path');
|
||||
p.setAttribute('d',`M${a.x},${a.y} C${mx},${a.y} ${mx},${b.y} ${b.x},${b.y}`);
|
||||
p.setAttribute('stroke',col); p.setAttribute('stroke-width','1.4'); p.setAttribute('fill','none');
|
||||
p.setAttribute('opacity','.75');
|
||||
if (e.status!=='implemented') p.setAttribute('stroke-dasharray','5 4');
|
||||
svg.appendChild(p);
|
||||
});
|
||||
}
|
||||
|
||||
function select(id){
|
||||
S.sel = id;
|
||||
document.querySelectorAll('.chip').forEach(ch=>{
|
||||
ch.classList.remove('sel','rel','dim');
|
||||
if (ch.dataset.id===id) ch.classList.add('sel');
|
||||
});
|
||||
const rel = new Set(edgesOf(id).filter(e=>!edgeHidden(e)).map(e=>e.from===id?e.to:e.from));
|
||||
document.querySelectorAll('.chip').forEach(ch=>{
|
||||
if (ch.dataset.id!==id) ch.classList.add(rel.has(ch.dataset.id)?'rel':'dim');
|
||||
});
|
||||
renderSide(id);
|
||||
drawWires();
|
||||
}
|
||||
|
||||
function relRow(e, id){
|
||||
const out = e.from===id, other = out?e.to:e.from, oc = byId[other];
|
||||
const marks=[];
|
||||
if (e.confidence!=='high') marks.push(`<span class="b ${e.confidence==='low'?'lo':'me'}">${e.confidence}</span>`);
|
||||
if (e.status!=='implemented') marks.push(`<span class="b off">${e.status}</span>`);
|
||||
return `<button class="rel" data-id="${other}"><span class="k">${out?'→':'←'} ${e.kind}</span>${oc?oc.id:other} ${marks.join('')}
|
||||
<span class="ev">${e.label}${e.evidence?' · '+e.evidence:''}</span></button>`;
|
||||
}
|
||||
|
||||
function renderSide(id){
|
||||
const c = byId[id], side = document.getElementById('side');
|
||||
if (!c){ side.innerHTML = `<div class="empty">No record for <code>${id}</code>.</div>`; return; }
|
||||
const es = edgesOf(id).filter(e=>!edgeHidden(e));
|
||||
const outE = es.filter(e=>e.from===id), inE = es.filter(e=>e.to===id);
|
||||
const mmFile = S.view==='v3' ? FLOWS[S.flow].file : {v1:'01-system-topology.mmd',v2:'02-core-internals.mmd',v4:'04-state-ownership.mmd',v5:'05-dependency-boundary.mmd'}[S.view];
|
||||
side.innerHTML = `
|
||||
<h3>${c.id}</h3>
|
||||
<div class="sub">${c.type} · ${c.group} · ${c.status} · ${c.confidence} confidence</div>
|
||||
<p>${c.responsibility}</p>
|
||||
${c.notes?`<div class="note">${c.notes}</div>`:''}
|
||||
<section><h4>Files</h4><ul class="plain">${c.files.map(f=>`<li class="mono">${f}</li>`).join('')}</ul></section>
|
||||
<section><h4>Symbols</h4><ul class="plain">${c.symbols.map(s=>`<li class="mono">${s}</li>`).join('')}</ul></section>
|
||||
<section><h4>Outgoing — ${outE.length}</h4>${outE.map(e=>relRow(e,id)).join('')||'<div class="empty" style="margin:0">none</div>'}</section>
|
||||
<section><h4>Incoming — ${inE.length}</h4>${inE.map(e=>relRow(e,id)).join('')||'<div class="empty" style="margin:0">none</div>'}</section>
|
||||
${mmFile?`<section><h4>Mermaid source — ${mmFile}</h4><pre class="mm">${MERMAID[mmFile].replace(/[&<>]/g,m=>({'&':'&','<':'<','>':'>'}[m]))}</pre></section>`:''}`;
|
||||
side.querySelectorAll('.rel').forEach(b=>b.onclick=()=>{
|
||||
const t=b.dataset.id;
|
||||
if (!document.querySelector(`.chip[data-id="${t}"]`)) { renderSide(t); S.sel=t; drawWires(); }
|
||||
else select(t);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- search ---------------- */
|
||||
function search(){
|
||||
const q = document.getElementById('q').value.trim().toLowerCase();
|
||||
const box = document.getElementById('results');
|
||||
if (q.length<2){ box.innerHTML=''; return; }
|
||||
const hits=[];
|
||||
for (const c of ARCH.components){
|
||||
const where=[];
|
||||
if (c.id.toLowerCase().includes(q)) where.push('id');
|
||||
if (c.responsibility.toLowerCase().includes(q)) where.push('responsibility');
|
||||
const f = c.files.filter(x=>x.toLowerCase().includes(q));
|
||||
const s = c.symbols.filter(x=>x.toLowerCase().includes(q));
|
||||
if (f.length) where.push('file: '+f[0]);
|
||||
if (s.length) where.push('symbol: '+s[0]);
|
||||
if ((c.notes||'').toLowerCase().includes(q)) where.push('note');
|
||||
if (where.length) hits.push([c, where]);
|
||||
}
|
||||
box.innerHTML = hits.slice(0,60).map(([c,w])=>`<button data-id="${c.id}">${c.id}<br><em>${w.join(' · ')}</em></button>`).join('')
|
||||
|| '<button disabled style="color:#5f6c85">no match</button>';
|
||||
box.querySelectorAll('button[data-id]').forEach(b=>b.onclick=()=>{
|
||||
const el = document.querySelector(`.chip[data-id="${b.dataset.id}"]`);
|
||||
if (el){ select(b.dataset.id); el.scrollIntoView({block:'center',behavior:'smooth'}); }
|
||||
else { S.sel=b.dataset.id; renderSide(b.dataset.id); }
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- boot ---------------- */
|
||||
document.getElementById('commit').textContent = ARCH.commit.slice(0,7);
|
||||
document.getElementById('gen').textContent = ARCH.generated;
|
||||
document.getElementById('counts').textContent = `${ARCH.components.length} components · ${ARCH.edges.length} relations`;
|
||||
document.getElementById('dirty').textContent = ARCH.working_tree;
|
||||
document.getElementById('views').innerHTML = VIEWS.map(v=>`<button class="viewbtn" data-v="${v.id}">${v.name}<small>${v.hint}</small></button>`).join('');
|
||||
document.getElementById('legend').innerHTML = [...new Set(ARCH.components.map(c=>c.type))].sort().map(t=>`<span>${t}</span>`).join('');
|
||||
|
||||
function setView(id){ S.view=id; S.sel=null;
|
||||
document.querySelectorAll('.viewbtn').forEach(b=>b.classList.toggle('on', b.dataset.v===id));
|
||||
renderView();
|
||||
document.getElementById('side').innerHTML = '<div class="empty">Select a component to see its responsibility, the files and symbols it was read from, and every relation in and out.</div>';
|
||||
}
|
||||
document.querySelectorAll('.viewbtn').forEach(b=>b.onclick=()=>setView(b.dataset.v));
|
||||
['tLow','tMed','tOff','tUndeployed','tPlanned','tWires'].forEach(k=>
|
||||
document.getElementById(k).onchange=()=>{ renderView(); if(S.sel) select(S.sel); });
|
||||
document.getElementById('q').oninput = search;
|
||||
document.getElementById('main').addEventListener('scroll', drawWires);
|
||||
window.addEventListener('resize', drawWires);
|
||||
setView('v1');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user