Skip to content

Design proposal: tier the dispatch verdict so unmodeled shapes degrade the check, not the turn #761

Open

Description

@Zireael

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

  1. The predicate is equality with a re-implementation of the host's encoder. piPreparedStructure ports transformMessages for 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. 45c0bcff4 ports takeRealToolResult / flushPendingToolCalls). The set of histories where the port is exactly right is unbounded, so the residual is unbounded, and the host ships independently.
  2. The check runs where it cannot act. At before_provider_request the payload is already built; refuse() notifies and calls ctx.abort(). No repair, no partial outcome.
  3. 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.
  4. 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).
  5. 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 degrade

Hard — 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 --issue gains 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-delivery goldens hold.
  • The planning side: tags, summaries, compartments, historian, dreamer, LKG content model, budget/force bands.
  • The previous_response_id chain 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:

  1. Failure memory + tiered verdict — patch-sized, no byte changes. Stops the P0 class.
  2. Window comparison — minor-sized (0.50.x; it changes the diagnostic vocabulary). Shrinks the model structurally.
  3. Hard invariants at prepare time — tail-only repair; the first tier that costs cache, and only when it acts.
  4. Content receipt — removes the positional heuristic.
  5. 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 cacheWrite per 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

  1. Replaying stored payloads from lkg_slots of past failing sessions: every request that refused with comparison=role|content|count and has no hard violation is admitted.
  2. One synthetic fixture per hard violation is repaired or degraded as specified; none aborts the turn.
  3. Bytes sent for admitted turns are unchanged, including admit-unverified (system-delivery goldens do not move).
  4. A session that hits one unmodeled shape shows exactly one divergence and then a stable prefix — no alternating prefixes; cacheWrite stays flat across following turns.
  5. Divergences and degraded turns are counted and visible in doctor --issue with their class.
  6. The gate runs on Pi as well as OMP (fenceEnabled is 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

Activity

  1. magic-alfonso commented on Oct 22, 2026

    @magic-alfonso

    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.

  2. Zireael commented on Oct 22, 2026

    @Zireael
    ContributorAuthor

    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

    • Corpus: every lkg_slots row in the local context.db — 100 passes, the last prepared pass per session. 86 resolved to a full model definition from the local models.db model catalog; the other 14 are tokenrouter/... entries the catalog no longer carries.
    • Per pass: rebuild the messages from the chunked LKG (lkg_slot_chunks), run the shipped projection (buildPiPreparedStructure from 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-ai 18.12.5 transformMessages), then compare rows: role sequence, call ids, result ids, order.
    • Hard audit: on the host's output, count unpaired results (a result id with no call), duplicate result ids, results present in the projection but absent from the host, and calls present in the projection but absent from the host.

    Calibration. The same pipeline reproduces #751 exactly against the 0.49.16 bundle: session 01a1329b…, first divergence at row 57, expected user / actual toolResult, same 60 rows — i.e. the historical row=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

    0.49.16 current (0.49.19)
    passes analysed 86 86
    rows diverge (would be refused) 4 (4.7%) 3 (3.5%)
    forward pull / order-only 1 0
    host drops a thinking-only assistant turn 3 3
    unpaired results 0 0
    duplicate result ids 0 0
    results missing vs projection 0 0
    calls missing vs projection 0 0

    By API: openai-completions 49 passes / 0 divergences, opencode-free-zen 36 / 3, openai-codex-responses 1 (1 → 0).

    The four cases

    1. 01a1329b… (codex, 60 rows, 0.49.16 only) — an assistant tool call, then a custom notice, 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.
    2. 01a15d5e…, 01a15db1…, 01a16095… (opencode-free-zen, 3 of 36) — the history has assistant [thinking], then user, then assistant [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
    const multiset = (xs) => { const m = new Map(); for (const x of xs) m.set(x, (m.get(x) ?? 0) + 1); return m; };
    const sameMulti = (a, b) => a.size === b.size && [...a].every(([k, v]) => b.get(k) === v);
    
    const projCalls = prepared.messages.flatMap((r) => r.calls);
    const projRes   = prepared.messages.flatMap((r) => r.results);
    const hostRows  = rowize(transformMessages(messages, model));   // role, calls[], results[]
    const hostCalls = hostRows.flatMap((r) => r.calls);
    const hostRes   = hostRows.flatMap((r) => r.results);
    
    const hard =
      hostRes.filter((id) => !new Set(hostCalls).has(id)).length +      // unpaired
      [...multiset(hostRes).values()].filter((n) => n > 1).length +     // duplicate
      [...multiset(projRes).keys()].filter((id) => !multiset(hostRes).has(id)).length +
      [...multiset(projCalls).keys()].filter((id) => !multiset(hostCalls).has(id)).length;
    
    const soft = !hard && (
      sameMulti(multiset(projCalls), multiset(hostCalls)) &&
      sameMulti(multiset(projRes), multiset(hostRes))
    ); // order differs, or the host carries fewer normalised rows

    Reading it

    • The refusal class is entirely soft on this corpus. That is the population the tiers change: every one of these becomes a counted divergence instead of a dead turn, and none of them is a request the provider would reject.
    • The residual shape (a thinking-only assistant turn dropped by the host, on an API the projection does not model) has not been reported yet. It is the next issue, already sitting in the corpus — which is the concrete argument for capture-to-fixture over another special case.

    Limits

    • lkg_slots stores 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.
    • Comparison is at host-message level. Wire-level 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.
    • The fence only runs on OMP (fenceEnabled = resolvePiHarnessKind() === "omp"), so some of these passes may never have been fenced.
    • 14 passes unresolved because their provider is absent from the local model catalog.

    Happy to post the harness, or turn the four cases into fixtures for the design discussion.

  3. Zireael commented on Oct 22, 2026

    @Zireael
    ContributorAuthor

    Fixture for the thinking-only shape — and a correction to my previous comment

    Minimising one of the three opencode-free-zen cases found both the host rule and a correction.

    The rule. transformMessages drops an assistant turn that was cut off and carries no text and no tool call:

    // transform-messages.ts:480
    function shouldDropTruncatedThinkingOnlyAssistant(msg) {
      const isTruncatedStop = msg.stopReason === "length" || msg.stopReason === "error" || msg.stopReason === "aborted";
      return isTruncatedStop && !msg.content.some(b => b.type === "toolCall" || b.type === "text");
    }

    Applied at transform-messages.ts:1398. The comment there explains why: a provider that returns max_tokens mid-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:

    const history = (stopReason) => [
      { role: "user", content: [{ type: "text", text: "go" }], timestamp: 1 },
      { role: "assistant", content: [{ type: "thinking", thinking: "weighing options" }], stopReason, timestamp: 2 },
      { role: "user", content: [{ type: "text", text: "continue" }], timestamp: 3 },
    ];

    Result (shipped bundle + @oh-my-pi/pi-ai 18.12.5), rows are [role,…]:

    model api base messages lane plan host fence rows (lane ?? base)
    openai-completions 3 [user,assistant,user] chatMessages 2 [user,user] 2 [user,user] agree
    openai-responses 3 [user,assistant,user] responsesMessages 2 [user,user] 2 [user,user] agree
    opencode-free-zen (no lane plan) 3 [user,assistant,user] — 2 [user,user] diverge by one row

    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-zen appears in no installed host package (I grepped @oh-my-pi and 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 chatMessages and in responsesMessages but not in the base messages projection — 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:

    test("a truncated thinking-only turn is dropped by the host and by both lane plans", async () => {
      const messages = history("length");
      for (const model of [chatModel, responsesModel]) {
        const prepared = piPreparedStructure(messages, model)!;
        const transformed = (await omp.transform(messages, model)) as { role: string }[];
        expect(transformed.map((m) => m.role)).toEqual(["user", "user"]);
        const lane = prepared.chatMessages ?? prepared.responsesMessages;
        expect(lane?.map((r) => r.role)).toEqual(["user", "user"]);
      }
    });
    
    test("the base projection keeps it, so an unmodeled api would be refused", () => {
      const prepared = piPreparedStructure(history("length"), chatModel)!;
      expect(prepared.messages.map((r) => r.role)).toEqual(["user", "assistant", "user"]);
      expect(prepared.messages.flatMap((r) => [...r.calls, ...r.results])).toEqual([]);
    });

    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.

  4. magic-alfonso commented on Oct 23, 2026

    @magic-alfonso

    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:

    • Admitted turns, including admit-unverified, send exactly the bytes they send today.
    • Hard faults still refuse: an unpaired tool call or result, lost Magic Context content, or a stale request.
    • The session pin can't oscillate between managed and unmanaged prefixes.
    • Your two thinking-only tests are included, asserting the lanes and the fallback separately.
    • The stored-payload corpus is run against the new verdict, with the tally in the PR.

    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.

  5. added
    design-approvedDesign agreed by a maintainer; a PR referencing this issue can be reviewed
    on Oct 23, 2026
  6. Zireael commented on Oct 26, 2026

    @Zireael
    ContributorAuthor

    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:

    07:12:51.953 injected m[0]/m[1] into Pi messages (65624 + 90 bytes)
    07:12:51.968 tail hygiene prefix invalidated: part_index=3 message=a830ef3a field=shorter
                 frozen_parts=216 measured_parts=3 action=re-measured
    07:12:51.970 transform completed in 74.6ms (3 messages, 1 targets, watermark: 1909)
    07:12:51.981 Pi publication numbers_only reason=lkg_entry_ids_unavailable; serving managed context
    07:12:53.053 CONTEXT REFUSED "… row=2 expectedRows=3 actualRows=174
                 expectedRole=user actualRole=assistant comparison=role"
    07:12:53.087 agent_end attributed to an earlier run; keeping the latest context pass
    

    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 —

    • a pending compaction boundary trimmed the history the hook was shown while the dispatch still carried the
      pre-boundary branch, or
    • a second in-process request shared the session's single operation slot (operations keyed by session, latest pass
      wins), 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 run line 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    design-approvedDesign agreed by a maintainer; a PR referencing this issue can be reviewed

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions