Merge the arbitration kernel study (#229)

docs/plans/20-two-artifacts-and-neither-is-spring.md. No Go changed.

The thesis holds for the routing cascade, querySources and the pre-route
ladder, and is refuted for reach selection: ChannelsFor is a total pure
function that returns several winners, so nothing claims and nothing loses.
The digestion tick is not the odd seam out but the one already done right, and
the proposal is that the other three come to look like it.

The strongest finding is not the shape. The three structural holes that make a
route untrustworthy are written three times for three consumers with three
return types. internal/claim is built and tested and has no callers.

(V-585)
This commit is contained in:
2026-08-06 02:19:59 +04:00
@@ -0,0 +1,408 @@
# Two artifacts, and neither one is Spring
Proposal. V-585. Related umbrella V-558, and the design collected in
`docs/plans/19-dialogue-arbitration.md`.
## Verdict
**Thesis one holds for four seams and fails for one.** Four are one shape: the routing
cascade, the query source chain, the pre-route resolver ladder, the digestion tick. Reach
selection is not. It maps severity and presence to a set of channels. It has no claimants and
no losers.
**Thesis two holds.** An arbitration kernel is a package and a convention inside one program.
It is not a framework. A framework whose only client is the codebase it came from is that
codebase with more ceremony.
**The answer is two artifacts of different sizes.** One package inside Maven, built from
`internal/claim` and `internal/decision`. Both already exist and neither is wired. One small
library across the four services, holding the correlation id, the headers, the timeout policy
and the named gap. Neither is Spring. The daemons must not get a third.
**The strongest finding is the duplication, not the shape.** Three structural holes make a
route untrustworthy. They are written out by hand in three files, for three consumers, with
three return types. `gateLLMDecision` flattens them to a float. `vetoOf` re-derives them as a
sentence. `thinReason` re-derives them again as a trace string. That is what having no common
unit costs, and it is countable in lines rather than in taste.
## The five seams, tested
### 1. The routing cascade. The shape, ordered by hand.
The claimant is `router.Grammar` (`internal/router/stage0.go:19`). It holds a name, a regex
and a `Build` that may still decline. A claim is a regex match plus `ok` from `Build`.
Ordering is the append order in `buildRouter` (`cmd/mavend/voicewire.go:384`). Twelve appends.
Each one carries a comment arguing its position against its neighbours. First match wins at
confidence 1.0.
Below stage 0 the two engines are alternatives, not rivals. The classifier runs only when the
router is nil or errored. Inside the classifier the order is cosine score, and the top three
are recorded.
Losers are recorded and change nothing. `noteGrammarOutcomes`
(`internal/router/decisiontrace.go:56`) separates a grammar that did not match from one whose
`Build` declined. Everything past the winner is marked `NeverAsked`.
### 2. The query source chain. The shape, ordered by hand, with a boundary in it.
The claimant is `querySource` (`cmd/mavend/actions_query.go:45`), a name and one function
returning `(string, bool)`. Twenty-four of them sit in one slice literal, walked in order. The
comment on the slice says the order is load-bearing. It is right.
This seam carries something the others do not. The personal boundary at line 139 is a stop,
not an answer. Everything above it reads the owner's data. Everything below reads the world. A
question about him that reaches the boundary ends there.
### 3. The pre-route resolver ladder. The shape, and the one that hurts.
Seven rungs, each returning `(reply string, handled bool)`. Ordering is the order of the `if`
statements in `runTurn` (`cmd/mavend/voice.go:258`). The roster in `preRouteLadder`
(`cmd/mavend/decisiontrace.go:33`) is kept by hand, and its own comment admits nothing
enforces the correspondence.
The recurring bug lives here. A rung claims before the utterance is routed. So the claimant
with the earliest and strongest trigger is the one that knows least about what was said. V-560
fixed half of it. It computes the route once, before the ladder, and lets the clarify resolver
read it. The other rungs still decide without reading it.
### 4. The digestion tick. The shape, and the only one already done right.
This corrects the brief. `loop.Tick` (`internal/loop/loop.go:85`) is not a first-to-claim
walk. It is an arbitration with a declared comparator.
- The claimant is `loop.Rule`. Its `Predicate` says whether it wants the turn.
- The gate is separate from the claim. `Gate` (`loop.go:21`) checks snooze, cooldown, quiet
hours, calendar busy, presence and missing data. `ExplainGate` names which one blocked.
- The comparator is data, not position. Max severity wins, and ties break on name.
`DefaultRules` states outright that slice order is not load-bearing.
- Losers are recorded with what they lost to. `ExplainTick` (`internal/loop/explain.go:86`)
fills `LostTo`, and rewrites the previous best when a higher severity displaces it.
- Losers get a second life. `DigestEligible` (`loop.go:129`) decides which suppressed
candidates are bundled for later. It refuses cooldown and snooze, because neither is
restraint.
Every property the kernel wants already exists here, on five rules. The kernel argues that the
other three seams should look like this one. It does not need a new idea.
### 5. Reach selection. Not the shape.
`ChannelsFor` (`internal/delivery/channel.go:73`) takes severity and presence and returns a
slice of channels. Nothing claims. Nothing passes. Nothing loses. Every channel in the
returned slice sends, so there is not even one winner.
Naming the sinks claimants would be the forced abstraction. It would also hide the property
this table has and the ladders lack. It is total, it is pure, and every cell is covered by
`TestChannelsForEveryTableCell`.
One thing in the dispatcher does re-decide. `ErrVoiceNoSession` means the presence guess was
wrong, so the remaining channel list is replaced with the away table
(`internal/delivery/dispatcher.go:188`). That is a retry on new evidence, not a contest. Leave
it alone.
### What else has the shape
`fillMatchedSlots` (`internal/router/router.go:212`) arbitrates per slot. A matched value
always wins, and the extractor fills only what was left empty. That is the coverage-first rule
the kernel proposes, written once for four slots.
`bestRecall` and the topic veto pick between a fact and a note by score with a margin. That
one is a real score comparison, and it should stay one.
## The abstractions
Three, not four. The straw man had `Claimant`, `Claim`, `Arbiter` and `Record`. Drop
`Claimant`.
**`claim.Claim`, evidence rather than a verdict.** It exists at `internal/claim/claim.go`,
built and tested, imported by one function that nothing calls. It carries `Consumed` and
`Unexplained` for coverage, an ordinal `Band`, and a `Veto` string that keeps the reason a
float threw away. Coverage is compared before band. That is the fix for the Rome failure,
where a parked reminder ate the whole utterance while explaining none of it.
**`decision.Record`, the trace.** It exists at `internal/decision/decision.go` and it is wired
everywhere. It separates won, declined, lost on score, thinned, merged and never asked. The
last one is the valuable one. A claimant that never looked reads identically to one that looked and
passed. That is what hardcoded order hides.
**`Arbiter`, the thing that does not exist.** One function. It takes a set of claims and a
comparator, returns a winner, and notes the rest. `loop.Tick` is that function, specialised to
rules. Generalising it is the proposal.
**Against a `Claimant` interface.** Every seam already rejected one, for the same reason.
`querySource` is a struct of one function because the sources are methods on one handler with
no state. An interface would mean one empty type per source. `confirmResolver` is the same
shape, and `loop.Rule` is a struct with a closure. An interface would buy a shared name and
cost twenty-four empty types. The claimants stay what they are. Each seam builds `claim.Claim`
values at its own edge, which is what `router.ClaimOf` already does.
## What ordering becomes
Ordering becomes a comparator plus a rank, and the rank is data.
Today ordering is position in a slice, and position is invisible in the record. Add a rung to
`runTurn`, forget `preRouteLadder`, and the rung vanishes from the trace. The roster's own
comment admits nothing enforces it.
The proposal is smaller than a dependency graph. A claimant declares a rank. The arbiter sorts
by coverage, then band, then rank. Rank breaks the tie that evidence cannot break.
**A dependency graph is the wrong tool.** The real constraints are pairwise and local. Day
plan before calendar. Praxis before the capture marker. Narrative last. A graph turns those
into edges and then needs a topological sort whose output nobody can read. The twelve comments
in `buildRouter` would become twelve edges with the arguments deleted. Keep the arguments.
**Two claimants at the same rank must be an error, caught at wiring time.** Not at turn time.
The registry is built once at boot, so a duplicate rank is a boot failure naming both
claimants. Falling back to slice order on a tie would restore the invisible ordering the
kernel exists to remove.
**The roster stops being hand-kept.** A claimant registered with the arbiter is on the roster
by construction. That deletes the `preRouteLadder` failure mode outright.
## What it buys
**It deletes three copies of one test.** The three structural holes appear in
`gateLLMDecision` (`internal/router/router.go:271`), in `vetoOf`
(`internal/router/claim.go:101`), and in `thinReason` (`internal/router/decisiontrace.go:28`).
Three files, three return types, one rule. A fourth consumer would write it a fourth time.
With a claim carrying `Veto`, the rule is written once. The float, the sentence and the trace
string all derive from it. This one is worth the work on its own.
**It makes the recurring bug expressible.** Rome, V-567 and V-577 are one defect. The claimant
that knows least holds the earliest trigger. Coverage-first arbitration states the fix once,
in `MoreSpecificThan`. A parked clarify explaining zero tokens of "какая сейчас погода в
Риме?" loses to a weather claim explaining all of them. Nobody has to encode that a parked
clarify is less trustworthy than a grammar.
**The limit.** The kernel prevents the class only where the losing claimant
computes low coverage. Rome, V-567 and V-577 all qualify. Each is a stateful claimant
swallowing an utterance it explains none of. A claimant that matches a substring does explain
those tokens, and coverage does not catch it. V-567's substring match is that case from the
other side. Coverage there has to be measured against the whole utterance rather than the
matched span. `claimSpans` already does that, in the safe direction. So the kernel
prevents most of the class and describes the rest. Claiming more would be dishonest.
**It makes contention countable.** Today it is not. The 91-case fixture draws two stage-0
grammars exactly once, at `ru-query-019`, and both route the same intent. Nobody knows whether
contention is rare or whether the fixture omits it. An arbiter that sees every claim can
count.
**What it does not buy.** No accuracy point comes from this alone. Every number in
`docs/evals/2026-08-05-routing-resident-model.md` is reachable without it. The kernel is a
place to put the fix, not the fix.
## What it costs
Every seam rewritten is a chance to break a measured number.
Re-measure the 91-case routing fixture. The baseline is 75.8% full and 80.2% intent-only at
p50 1.19s, in `docs/evals/2026-08-05-routing-resident-model.md`. Judge against the classifier
and the resident model, because those are what always answer.
Re-measure Praxis reach. The baseline is 27/30 overall, 11/12 Praxis and 5/5 lifecycle, in
`docs/evals/2026-08-05-praxis-reach.md`. The Praxis grammars are the only path to Praxis, so a
reordering that demotes them costs every point.
Re-run the nine-scenario interleave probe in `docs/evals/2026-08-06-claimant-interleave.md`.
Six of nine pass today. That probe measures exactly what this proposal is for.
Re-run the dialogue contract tests from V-563. They are whole multi-turn traces, and the only
tests that cover the ladder as a ladder.
Latency is the cheap part. A stage-0 query costs 3.7µs. One claim per claimant adds two slices
and a token split, on a path whose p50 is over a second.
The real cost is the arguments. Twenty-three comments across `buildRouter` and `querySources`
explain why each entry sits where it does. A migration that turns them into rank integers and
drops the prose destroys the only documentation the ordering has.
## Migration order
**Step one, no behaviour change. Delete the duplication.** Make `vetoOf` the single definition
of the three structural holes. Have `gateLLMDecision` and `thinReason` read it. One rule,
three consumers, no new abstraction. Re-measure the routing fixture and nothing else. This is
worth landing whether or not the rest does.
**Step two, the proof. Arbitrate the pre-route ladder.** Smallest seam, seven rungs, and the
one with the measured defect. Each resolver returns a claim instead of a bool. The arbiter
compares coverage, then band, then rank. The roster comes from the registry. The proof is the
interleave probe reaching nine of nine with the routing fixture unmoved.
**Step three, the query source chain.** Twenty-four sources, most of which already compute a
match span. The personal boundary does not become a ranked claimant. It stays a hard stop, and
the arbiter runs above it and below it separately. Re-measure Praxis reach and the search and
Kiwix fallback.
**Step four, stage 0, or not at all.** Twelve grammar groups whose order encodes twelve
arguments, scoring 20/20 on the fixture. Most to lose, least to gain. Defer it until steps two
and three have sat in the deploy long enough to break something.
**`loop.Tick` moves last or never.** It already has the comparator, the gate with reasons, the
loser trace and the loser rescue. Rewriting it to call a generic arbiter risks the digest path
to gain a shared name.
## What must not be in the kernel
**Authorization.** This is the hard line. CLAUDE.md is explicit that LLM output is not
authorization, and that a confirmation binds capability id, target entity, arguments,
requester and expiry (`cmd/mavend/confirm.go`). A generic arbiter turns many opinions into
one winner. That is the wrong shape for a binding. Make confirm a claimant with a rank and a band, and a claim that scored higher could take the
turn from it. The binding would be softened into a comparison. Confirm may report to the record. It must not compete in the
arbiter.
The same rule covers the Hexis path. Free text never reaches a mutating call, and resolution
happens against Nexus. Neither is a contest, so neither is arbitration.
**The personal boundary.** Same reason, different currency. The boundary is not the most
specific claimant. It is a stop. A boundary that can lose to a higher-coverage claim is not a
boundary. The failure is the owner's notes reaching a search engine.
**Confidence as a float.** The band exists because the measurement said a calibrated float is
not available. The classifier scores 62% correct below its median and 62% above, over a spread
0.083 wide. Its top-two margin has a p50 of 0.009
(`docs/plans/19-dialogue-arbitration.md`). A kernel with a `Score float64` on the claim invites
every claimant to invent one. When V-546 lands a softmax head with a calibrated probability,
that number is read beside the bands, not squeezed inside them.
**Slot extraction and validation.** `fillMatchedSlots` runs after a winner exists, and slot
validation against the action schema is V-562. Both ask whether a claim is well formed. Neither
asks which claim wins.
## The second artifact: the ecosystem client
The kernel is Maven's alone. The one thing here with plural clients by construction is the
contract between the four services. Maven implements its side of it twice and a half.
### Is the contract uniform today? No.
Nexus and Praxis share one implementation. `ecosystemHTTP` (`cmd/mavend/ecosystem.go:62`) is
embedded in both, so both get the same 10 second timeout, the same `setHeaders`, the same
typed `ecosystemError`, and the same correlation key. Hexis is a separate client in another
repository, vendored at `vendor/github.com/kami/hexis/pkg/client`, and it agrees on some of
that and not the rest.
Eleven divergences. Four of them are defects rather than style.
**A Hexis 401 is spoken as an outage.** Nexus and Praxis return `*ecosystemError` with
`Unauthorized()`, `ContractMismatch()` and `Unreachable()` classifiers
(`cmd/mavend/ecosystem.go:144`). Hexis returns `fmt.Errorf` strings
(`hexis/pkg/client/client.go:157`). So `unauthorizedEcosystemError`
(`cmd/mavend/ecosystem_acts.go:549`) does `errors.As` and always gets false for Hexis. The
owner hears "Hexis is down" when the truth is that Hexis refused the credential. The comment
at `ecosystem_acts.go:32` says that conflation must not happen.
**The Hexis discovery hop is uncorrelated.** Hexis carries its own context key
(`client.go:81`), invisible to Maven's. The bridge is a manual second stamp at
`ecosystem.go:546`. `discoverCapabilities` (`ecosystem.go:527`) does not do it, and discovery
runs before execute, so that call goes out with no correlation id. The doc comment above it
claims the opposite.
**Causation is computed and never sent.** `causationID` is derived at
`ecosystem_acts.go:771`. Hexis supports `X-Causation-ID` (`client.go:147`). Nothing passes it.
**A Praxis failure names no service.** `ecosystemGap` (`ecosystem_acts.go:36`) is the shared
named-gap helper, and Nexus and Hexis call it. Praxis returns per-verb Russian strings instead
(`ecosystem_acts.go:62`, `:73`, `:84`, `:95`). There is no `servicePraxis` constant. A Praxis
outage and a Praxis 403 both say "не получилось", with the service unnamed.
The rest are real but smaller. Hexis sends no `X-Requested-By: maven` and no `Accept` header,
so Hexis cannot attribute a read call to Maven at all. Its timeout is 30 seconds against
Maven's 10, which Maven cannot change from here. Its success predicate is `>= 400` where the
shared client uses `!= 200`. `withToken` is duplicated verbatim per client because the
embedded struct cannot return the concrete type. The version constant `"v1"` is defined twice,
in two repositories, with nothing keeping the two equal.
### Would a shared library have plural clients?
**Partly verifiable, and the honest answer is that two of the three cannot be checked from
here.**
Hexis is verified. It is a Go module consumed through a `replace` directive, and it already
publishes a Go client library that Maven imports. That is an existence proof that the pattern
works for one of them.
Praxis is suggested and not proven. Two comments reference Go paths in its repository
(`cmd/mavend/factenrichment.go:4`, `cmd/mavend/ecosystem.go:365`). Nothing here compiles
against it.
Nexus is unverifiable from this repository. There is an HTTP base URL and some JSON shapes,
and no language signal at all.
So the claim that a shared library would have plural clients rests on one confirmed adopter
and two assumptions. Do not present it as settled. The cheaper test is to fix Maven's side first,
in Maven. Offer the package outward once it has proven itself on one caller.
### What belongs in it
The straw man is right, with one addition.
- The correlation id, minted once per action, with one context key that all clients read. The
two-key split is the cause of the uncorrelated discovery hop.
- Causation, since one of the three already supports it and the value is already computed.
- The version header and `X-Requested-By`.
- The timeout policy, as one number rather than 10 in one repository and 30 in another.
- A typed error with the three classifiers, so a refused credential never speaks as an outage.
- The named gap shape, so no client invents its own vocabulary the way Praxis did.
### What must stay out
**Authorization, for the second time and the same reason.** The confirmation binding is
Maven's. It binds capability id, target entity, arguments, requester and expiry, and it lives
in `cmd/mavend/confirm.go`. A shared client that offered a policy hook would invite each
service to supply its own, and the binding would become configuration. Free text never reaches
a mutating Hexis call, and entity resolution stays in Nexus. Neither belongs in a transport
library.
Retry stays out too, or nearly. The only retry Maven has is Nexus enrichment at the worker
layer, with backoff from one minute to one hour (`cmd/mavend/factenrichment.go:49`). It is
there because enrichment is a background job with no listener. A turn cannot retry, because
the owner is standing there. A transport-level retry in a shared library would hide a
second budget behind a turn that already has a name for failing.
## The daemons must not get a framework
The instinct is right, and half the work is already done in a way that shows why.
Transport is shared and abstracted. `ipc.Dial` (`internal/ipc/client.go:94`) and `ipc.Listen`
(`internal/ipc/server.go:202`) both go through `netaddr`. A bare path is a unix socket with
`SO_PEERCRED` identity. A `tcp://host:port?token=...` address binds a network listener with a
mandatory token. Callers pass a string and never branch on scheme. That seam made the
workstation offload a deployment rather than a build. It is a library, it has nine clients in
this repository, and it earns its weight.
Startup is not shared, and should not be. There are twelve binaries under `cmd/`, each with a
hand-written `main`, and no common lifecycle package. `cmd/mavend/main.go` runs 830 lines. The
reason is visible in it. The daemon can boot **locked**, with no store at all, and wire its
components later from inside an unlock handler. Thirteen subsystem pointers are pre-declared
nil (`main.go:236`) and filled on one of two paths. The whole graph is built a second time
inside the unlock path (`main.go:478`). A container owning object lifecycle would have to model
a graph whose nodes do not exist at boot and may never exist.
The deeper reason is a design property. Every daemon degrades alone, and every ecosystem
client is nil unless configured. A wiring framework's job is to fail loudly when a dependency
is missing. Maven needs the opposite: a missing dependency is a named gap in one answer and a
working daemon everywhere else. An abstraction over startup would trade that property for a
shorter `main`.
The duplicated locked and unlocked wiring in `mavend` is a real defect and worth fixing. Fix it
by extracting one function in that file. That is not a framework.
## The honest comparison
Spring and Django own object lifecycle and request handling for applications they have never
seen. That is where the weight is paid for. The abstraction is general because the clients are
unknown.
Maven is one application, on one box, with one user. Its clients are known, there are nine of
them, and they are in this repository. A framework here would be an abstraction with a census.
So the right unit is a shared package and a convention. That is a smaller and more defensible
claim. The evidence for its size is that both halves already exist in that form, and nobody
called them a framework. `internal/ipc` is a shared package and a convention, and it carried
the daemons off the box. `internal/decision` is a shared package and a convention, and it made
the losers readable in one release. `internal/claim` is the third, written and waiting for a
caller.
The thing to disagree with: **the answer is two artifacts of different sizes. A package inside
Maven, a library across the four services, and neither one is Spring.**