Repository navigation
Design proposal: tier the dispatch verdict so unmodeled shapes degrade the check, not the turn #761
Description
Short description
Tier the dispatch verdict so an unmodeled host shape degrades the check, not the turn: keep sending the same bytes (no cache change), count the divergence, and pin the session to admit-unverified after its first mismatch — then shrink the comparison from rows to tool-call windows so the largest divergence class stops existing at all.
What problem does this solve?
The recurrence, measured
Since the row-by-row comparison landed in 0.49.1, ten of the twenty 0.49 releases contain a fix for a turn refused or a request corrupted on this path:
| Release | Mechanism that failed |
|---|---|
| 0.49.0 | lifecycle — agent_end after LKG publication (#673) |
| 0.49.1 | lifecycle — late end notification; adds the structural comparison |
| 0.49.2 | structure — turns refused by the 0.49.1 check |
| 0.49.3 | structure — more turns, same message |
| 0.49.4 | structure — turns 0.49.3 still refused |
| 0.49.5 | structure — two more cases |
| 0.49.10 | structure — Codex lane, every turn after the first tool call |
| 0.49.15 | durable state — damaged records block a session (#723) |
| 0.49.16 | durable state — long Pi sessions refusing since 0.49.15 |
| 0.49.18 | structure — false dispatch refusals on OMP (#751) |
Plus ten filed issues in the same window (#663, #667, #673, #686, #692, #698, #714, #723, #731, #751). #686 alone took 33 commits, and the fence family grew from 5 files / 102 KB at 0.49.1 to 14 files / 207 KB at 0.49.19 without ever removing a branch. 0.49.16 exists to undo a regression from 0.49.15.
That is not ten coincidences. It is one design assumption with a bounded return.
Why it recurs
- The predicate is equality with a re-implementation of the host's encoder.
piPreparedStructureportstransformMessagesfor 14 APIs × 2 hosts × 3 lanes, pinned to five upstream versions (18.2.6 … 18.12.5). Each fix adds one more ported behavior (e.g.45c0bcff4portstakeRealToolResult/flushPendingToolCalls). The set of histories where the port is exactly right is unbounded, so the residual is unbounded, and the host ships independently. - The check runs where it cannot act. At
before_provider_requestthe payload is already built;refuse()notifies and callsctx.abort(). No repair, no partial outcome. - The check is blind to the harm it exists for. Replacing every tool result with
[dropped N](All tool results come back as dropped n #731) keeps roles, counts and ids intact and passes; a well-formed request whose rows the host reordered (0.49.16: false 'comparison=role' refusal — the host pulls a tool result forward across an interrupting message, the prepared projection does not model the move #751) fails. The check is precise about shape and silent about meaning. - A fixed bug returns because the rule has many copies. "Where does this row land" is implemented per lane, per host, per phase (initial / chained / retry / rebuild), and new phases keep arriving (positioned system rows, WebSocket continuations).
- The failure has no memory. 0.49.8: false 'comparison=content' refusal on cross-model assistant history (Responses lane) — 0.49.0 unaffected #714 and 0.49.16: false 'comparison=role' refusal — the host pulls a tool result forward across an interrupting message, the prepared projection does not model the move #751 reproduce at a stable row index on every retry. One unmodeled shape denies every subsequent turn in the session until a patch ships. The gate remembers what it sent (
dispatchFields,preparedStructure) but never that it was wrong, so nothing self-limits — and that is what turns a modelling error into a release.
Proposed solution
1. Split the verdict into two questions
type PiDispatchVerdict =
| { kind: "admit" }
| { kind: "admit-unverified"; soft: PiSoftDivergence[] } // log, count, send
| { kind: "unsafe"; hard: PiHardViolation }; // repair, else degradeHard — the provider will actually reject this, or we lost our own content:
| Violation | Why it is hard |
|---|---|
| unpaired result — a result id with no call in its window | provider 400; dangling result |
| duplicate result — the same id twice | double-counted tool output |
| open call — a call still unanswered at end of turn | provider 400 on strict lanes |
| orphan signed thinking — signed reasoning detached from its assistant row | signature rejection |
| unauthorized placeholder — a drop marker for an id the plan did not authorize | #731 |
| lost managed content — on a full replay, none of the pass's managed block hashes present | ownership lost |
| stale pass — payload matches a superseded artifact better than the current one | sends the wrong context |
Soft — the host is entitled to do this, and the provider does not validate it:
row order and moved rows (the takeRealToolResult forward pull) · extra host rows (custom notices, reminders, compaction carriers) · _dupN id renames · replies from other models, phase, thinking demotion, grouping · cache_control stripping · image trimming · timestamp differences · leading system/developer placement.
Every item on the soft list has either caused a fix in the table above or is special-cased inside the projection today. None of them is a request the provider rejects; they are differences between our prediction and the host's encoder.
2. Failure memory — the smallest change that breaks the loop
The state we want already exists: structuralSkipReason makes piPreparedStructureMismatch return undefined, and the gate logs once and proceeds. Today it only engages when the plan is unmodelable at prepare time.
Extend it to observed failure: the first structural mismatch in a session admits, records a shape key (api, model, row count, first divergent role pair), logs once, and pins the session to skip-structure mode. Ownership and pairing stay enforced. Reset the pin on a model/API change or a new operation.
Result: a permanent, deterministic outage becomes one odd turn plus one log line, and it costs nothing in bytes.
3. Compare windows, not rows
Every failure above lives on the order axis. Compare tool-call windows — the set of calls in an assistant turn and the results that answer them — instead of positional rows. Order inside the history stops being observable, so the forward pull, grouping and reordering classes disappear by representation rather than by casework, and projectMissingToolResults (≈400 of the projection's 1,533 lines, almost all of it predicting where rows land) shrinks to a membership check. The comparison becomes set equality over windows plus the hard invariants.
4. Hard invariants where the array is still ours
Run the hard list on the array the context hook is about to return — the only point where a violation is still fixable. Repair once (restore or drop the offending row, rebuild from history); if it still fails, return the host's array for this turn (existing pass-through path) and mark the operation unmanaged. Refuse only if nothing legal can be produced.
5. Receipt by content, not position
piManagedSignature takes a marker (or a 128-char prefix) from one of the first 8 messages, and piHasManagedSignature scans 8 messages × 32 blocks × 4096 chars — fewer rows than a normal OMP turn prepends. Replace it with the hashes of the blocks we injected (lkgContentFields already provides a stable walk), matched by membership with a length prefilter. On a previous_response_id replay that re-sends no history, absence is expected and the chain anchor authorizes the pass.
6. Make the corpus automatic (adjacent, separate issue)
lkg_slots already stores the exact failing pass, and MAGIC_CONTEXT_PI_SERVED_BODY_CAPTURE already captures served bodies. Wiring capture → oracle fixture → CI turns each future divergence into a failing build instead of a user report, and issue-751-host-oracle.test.ts is the harness to grow.
Cache safety
The managed prefix is a byte-stable artifact here (exactReusablePrefix, replay fixtures, cacheWrite in the accounting), so the proposal has to be judged by what each tier does to the prefix:
| Tier | Bytes sent | Cache effect |
|---|---|---|
| admit | unchanged | none |
| admit-unverified | unchanged | none |
| repair at prepare time | tail only | prefix survives |
| unmanaged fallback (hard violation) | whole array | one prefix write; the previous prefix normally survives the detour |
| refuse | nothing | none, but the retry re-sends the prefix |
The second row is the whole point: the classes that produced 0.49.2–0.49.5, #714 and #751 are soft, and admitting them sends exactly the bytes we send today. There is no cache cost to the part that removes most of the refusals.
The trap is an oscillating fallback — alternating managed and unmanaged prefixes makes every turn a miss, which is worse than either policy. That is why the session pins to one prefix and stays there, and why failure memory matters more than the fallback.
Lane note: these refusals are mostly Responses/Codex, where caching is implicit (longest-common-prefix match), so a tail change costs only the tail. Explicit-breakpoint lanes (Anthropic: writes ≈1.25× base, reads ≈0.1× at the 5-minute TTL) are where a full prefix change is expensive — and are not where these refusals come from.
What changes
- The gate gains a third verdict and a per-session shape pin; mismatch no longer aborts.
- The comparison unit becomes windows + hard invariants (stage 2), so the projection is no longer load-bearing for admission.
- The receipt becomes a content-hash set (stage 3); the 8-message/4096-char window goes away.
- Diagnostics:
comparison=…strings survive as the soft vocabulary;doctor --issuegains divergence/degraded counters. - Test contracts: wire-fence and structure tests move from admission assertions to classification assertions. Oracle fixtures are kept as corpus.
What does not change
- Bytes sent for admitted turns, including admit-unverified — prompt-cache stability and the
system-deliverygoldens hold. - The planning side: tags, summaries, compartments, historian, dreamer, LKG content model, budget/force bands.
- The
previous_response_idchain anchor semantics — it becomes the authority for replay, not a competitor to a text marker. - Provider-facing request shape: no new fields, headers or metadata.
- No new user-facing configuration. (Stage 1 can sit behind an internal flag for one release, removable after.)
- OpenCode's existing path in stage 1, unless the invariant is placed there deliberately (see Harness coverage).
- Any of the existing oracle fixtures.
Why now, and in what order
The host moved 18.11.3 → 18.12.5 in days; any strategy whose period is longer than the host's change interval loses on structure regardless of effort. The real host oracle now exists (c8c0988eb, issue-751-host-oracle.test.ts), which makes both the corpus and the window comparison cheaper than they would have been two weeks ago.
Ordered by cache cost and diff size:
- Failure memory + tiered verdict — patch-sized, no byte changes. Stops the P0 class.
- Window comparison — minor-sized (0.50.x; it changes the diagnostic vocabulary). Shrinks the model structurally.
- Hard invariants at prepare time — tail-only repair; the first tier that costs cache, and only when it acts.
- Content receipt — removes the positional heuristic.
- Corpus automation — the release gate that keeps all of the above from decaying.
Neither stage should land during a host-version bump window; stage 1 is the one to ship first because it is small, reversible, and testable against stored payloads.
Risks, cons, and how they are contained
- A shape we did not verify gets sent. True, and already true of the paths that reach
structuralSkipReason. Contained by the hard invariants, which cover the things the provider rejects, and by ownership, which covers "is our content there". It does not cover meaning — that is a separate fix, and this proposal does not claim it. - Regressions get masked. Mitigated by counting and surfacing every soft divergence in
doctor --issue, and by failing CI on new divergence classes once the corpus is wired. - The pin goes stale if the history changes shape later (model switch). Reset on api/model change and on a new operation.
- Behaviour change for users: a turn that would have been refused now proceeds. That is the intended trade — the refused turn was usually a legal request.
- Test churn in the most sensitive module. Real cost for a small team; I am offering to do the mechanical part (fixture reclassification) so review focuses on the policy.
- It does not fix everything. All tool results come back as dropped n #731-class corruption is a plan/artifact consistency problem; it needs edit obligations, and is called out as a follow-up rather than folded in here.
Expected ROI
- Availability: the structural-mismatch class (6 of the 10 releases above) stops being user-visible. Expected effect: at most one unaffected turn and one log line per (session, shape), instead of a dead turn on every retry.
- Cost: zero cache delta for the tiers that carry the win; the first cache-costing tier is rare by construction. Measure with
cacheWriteper turn over a week to confirm — my prediction is no movement. - Engineering: each host bump becomes a metric rather than an incident; the fix-latency requirement drops from hours to days, which is what makes the planned work possible.
- Code: stage 2 retires the adjudicating branch, not the predictor (≈400–500 lines of comparison plus three receipt fields eventually); the projection survives as diagnostics.
- Not in scope: semantic corruption, cancellation/abort races, and the OpenCode lanes beyond an explicit decision.
Acceptance criteria
- Replaying stored payloads from
lkg_slotsof past failing sessions: every request that refused withcomparison=role|content|countand has no hard violation is admitted. - One synthetic fixture per hard violation is repaired or degraded as specified; none aborts the turn.
- Bytes sent for admitted turns are unchanged, including admit-unverified (
system-deliverygoldens do not move). - A session that hits one unmodeled shape shows exactly one divergence and then a stable prefix — no alternating prefixes;
cacheWritestays flat across following turns. - Divergences and degraded turns are counted and visible in
doctor --issuewith their class. - The gate runs on Pi as well as OMP (
fenceEnabledis OMP-only today), or the reason it cannot is documented.
Falsifiable check for the premise: if most historical comparison= refusals contain a hard violation, this proposal is wrong and the edit pipeline is the priority. That is measurable from the LKG corpus before any code changes, and I am happy to run it first.
Alternatives considered
- Warn only, keep the comparison. Cheapest, but drops ownership on replay and leaves the check load-bearing for nothing. The tiered verdict keeps the useful half.
- Model the host more accurately. What 0.49.2–0.49.19 have been doing. It lowers the rate, cannot lower the class, and taxes every host release.
- Turn the fence off. Loses ownership and stale-pass detection.
- Retry the same pass on mismatch. Reproduces deterministically at the same row, so it multiplies the abort.
- Never edit inside an open tool window. Would remove the class by construction, but it conflicts with mid-turn budget pressure (Force band never folds during a long turn of a prefix-bound thinking model (Opus 5.5): context stays at 85-99% until the user writes again #729). A product trade, not a bug fix; worth naming but not folded in here.
Harness coverage
Following CONTRIBUTING.md: the invariant module is host-independent, which is the argument for placing it once rather than per harness. Stage 1 is Pi/OMP (where the check lives). OpenCode 1 and 2 need either the same module in their message path or an explicit not-applicable with a reason; the Rust module is N/A for dispatch verification unless it owns any pairing rules. I will fill the PR template's harness table honestly rather than assume either way.
Additional context
- Prior reports that feed this: 0.49.8: false 'comparison=content' refusal on cross-model assistant history (Responses lane) — 0.49.0 unaffected #714 (cross-model content equality), [pi/0.49.12] NUL-corrupted served-ledger JSONL files permanently block a session with a false 'storage is busy' refusal (Windows/Bun) #723 (durable-state integrity), 0.49.16: false 'comparison=role' refusal — the host pulls a tool result forward across an interrupting message, the prepared projection does not model the move #751 (host forward pull), plus the standalone
comparison=rolereproducer against the shipped 0.49.16 bundle (4-message case, drives the shipped fence to the identical verdict). - The design detail behind each claim — the release table, the six signatures, the shadow-growth numbers, the failure-memory argument — is in a longer analysis I can post on request; this issue is the actionable subset.
- I can implement stage 1 (verdict + pin) behind an internal flag with the LKG replay test, and stage 6 (corpus automation) in parallel, once the shape is agreed. Draft PR until the
design-approvedlabel.
Activity
magic-alfonso commentedon Oct 22, 2026
Thanks, @Zireael. This is a clear write-up, and the release table and the falsifiable check make it easy to evaluate.
The proposal changes how Magic Context decides whether to send a request, so it needs a maintainer decision before anyone builds it. We'll reply here with that decision. Please hold any implementation until then.
Zireael commentedon Oct 22, 2026
Ran the falsification check the proposal asks for.
Short version: on 86 replayable stored passes, the 0.49.16 projection flags 4 (4.7%) and the current one flags 3 (3.5%). All of them are soft. A hard-invariant-only gate rejects zero.
Method
lkg_slotsrow in the localcontext.db— 100 passes, the last prepared pass per session. 86 resolved to a full model definition from the localmodels.dbmodel catalog; the other 14 aretokenrouter/...entries the catalog no longer carries.lkg_slot_chunks), run the shipped projection (buildPiPreparedStructurefrom the installed bundle; internals exported by appending an export line to a temp copy, install untouched) and the real host transform (@oh-my-pi/pi-ai18.12.5transformMessages), then compare rows: role sequence, call ids, result ids, order.Calibration. The same pipeline reproduces #751 exactly against the 0.49.16 bundle: session
01a1329b…, first divergence at row 57, expecteduser/ actualtoolResult, same 60 rows — i.e. the historicalrow=57 expectedRows=60 actualRows=60 expectedRole=user actualRole=toolResult comparison=role. The same pass is clean on the current bundle, which matches the fix in 0.49.18.Results
By API:
openai-completions49 passes / 0 divergences,opencode-free-zen36 / 3,openai-codex-responses1 (1 → 0).The four cases
01a1329b…(codex, 60 rows, 0.49.16 only) — an assistant tool call, then acustomnotice, then the real result. The host pulls the result into the open window; the projection left it in place. Order-only: identical call and result multisets, pairing intact.01a15d5e…,01a15db1…,01a16095…(opencode-free-zen, 3 of 36) — the history hasassistant [thinking], thenuser, thenassistant [thinking, toolCall×2]. The host drops the thinking-only assistant turn, so it emits one fewer row than the projection expects. No call or result is lost, duplicated, or re-parented.So: 100% of the refusals in this corpus are normalisation differences. Under the proposed tiers all four land in
admit-unverified; none reaches repair, degradation, or refusal.Comparison kernel
Reading it
Limits
lkg_slotsstores the last prepared pass per session, not a uniform sample of turns, and the historically reported refusals are already fixed in 0.49.18/0.49.19 — they cannot be re-observed here. This measures the residual rate, not the historical set.responseTexts/phase(the 0.49.8: false 'comparison=content' refusal on cross-model assistant history (Responses lane) — 0.49.0 unaffected #714 class) is not compared, so content-class refusals are under-counted.fenceEnabled = resolvePiHarnessKind() === "omp"), so some of these passes may never have been fenced.Happy to post the harness, or turn the four cases into fixtures for the design discussion.
Zireael commentedon Oct 22, 2026
Fixture for the thinking-only shape — and a correction to my previous comment
Minimising one of the three
opencode-free-zencases found both the host rule and a correction.The rule.
transformMessagesdrops an assistant turn that was cut off and carries no text and no tool call:Applied at
transform-messages.ts:1398. The comment there explains why: a provider that returnsmax_tokensmid-thinking leaves a[thinking]-only message with a valid signature but nothing for the next turn to anchor on, and keeping it produces back-to-back assistant turns.Minimal fixture — 3 messages, no tools, model-independent:
Result (shipped bundle +
@oh-my-pi/pi-ai18.12.5), rows are[role,…]:messagesopenai-completions[user,assistant,user]chatMessages2[user,user][user,user]openai-responses[user,assistant,user]responsesMessages2[user,user][user,user]opencode-free-zen(no lane plan)[user,assistant,user][user,user]stopReason: "stop"is the control: host, lane plan and base all keep the turn, so the trigger is specifically the truncating stop reason.Correction. Both lane plans already model this drop, so the fence agrees on every modeled API. The divergence is reachable only through the fallback projection used when no lane plan exists — and
opencode-free-zenappears in no installed host package (I grepped@oh-my-piand the plugin tree), so I cannot confirm the host transforms that API at all; the plugin's own comment classifies an extension's custom API as unknown and assumes it does not. So the accurate tally is:Why it is still worth pinning. This is one rule implemented in
chatMessagesand inresponsesMessagesbut not in the basemessagesprojection — the same shape that produced #686, #714 and #751, one copy at a time. A test that asserts the two lanes and the fallback separately makes the next lane addition or fallback change show up as a failing test rather than as a refused turn.Test sketch for
issue-751-host-oracle.test.ts:Both are cheap and hermetic — no session, no tools, no timing. If the fallback is ever changed to model the drop, the second test should be flipped to expect agreement, which is exactly the signal you want.
magic-alfonso commentedon Oct 23, 2026
Thanks for the corpus check and the fixture. The correction is useful too: the only confirmed divergence was the forward pull fixed in #751, and the thinking-only shape only diverges through the fallback projection.
We're accepting the direction. Please go ahead with stage 1 (failure memory plus the tiered verdict, behind an internal flag for one release) as a draft PR. What we'll check in review:
Stages 2 and later stay separate decisions after stage 1 has shipped. We're in the middle of a larger internal change, so review may take a few days.
Zireael commentedon Oct 26, 2026
One more data point for the "the check runs where it cannot act" argument — same session, 2026-10-25 07:12:53Z,
plugin 0.49.22, OMP 18.12.6:
The prepared side is three rows — the pass was entered with a 1-message history (
findSessionId … messages=1)while the session had 761, and its own tail-hygiene cache expected 216 parts. The payload carries 174 rows.
That is not a projection gap (the families in #714, #751 and #789 all have matching row counts and differ in one
row's shape). The two sides describe different requests, and the fence still refused the user's turn.
What I can prove: the refactorings this proposal wants (degrade instead of refuse, remember the mismatch) would have
made this event harmless. What I cannot prove from the log: which of the two possible pairings occurred —
pre-boundary branch, or
operationskeyed by session, latest passwins), so a payload produced by run A was checked against the pass of run B.
The log cannot distinguish them because neither the pass nor the payload records a run identity, and the adjacent
agent_end attributed to an earlier runline shows the plugin already knows runs overlap.Concrete ask, independent of which one it is: stamp a pass/run identity on both sides of the comparison (pass →
preparedStructure, payload → the request that produced it), and when they disagree, do not compare — the payload was
not produced by this pass, so there is no managed dispatch to protect. That is the same "degrade the check, not the
turn" rule, applied before the comparison rather than after it, and it gives the diagnostic needed to fix the pairing
itself.