diff --git a/docs/plans/20-two-artifacts-and-neither-is-spring.md b/docs/plans/20-two-artifacts-and-neither-is-spring.md new file mode 100644 index 0000000..4cd4be7 --- /dev/null +++ b/docs/plans/20-two-artifacts-and-neither-is-spring.md @@ -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.**