Skip to content

fix: stop inferring V2 provider-checkpoint ownership from array position - #4

Open
ranxianglei wants to merge 2 commits into
drexb-ops:masterfrom
ranxianglei:2026-09-17_v2-checkpoint-provenance-guard
Open

ranxianglei wants to merge 2 commits into
drexb-ops:masterfrom
ranxianglei:2026-09-17_v2-checkpoint-provenance-guard

Conversation

@ranxianglei

@ranxianglei ranxianglei commented Sep 17, 2026 •

Copy link
Copy Markdown

Fixes ranxianglei#425

Problem

ACP assumed ctx.session.context({sessionID}) (public context) and the model-aware runner history (SessionProviderContext.provenance(model) + checkpoint re-expansion) describe the same boundary. They diverge across native checkpoints:

  • Compatible view: the outgoing request starts after the decoded checkpoint message; ACP claimed it correctly.
  • Incompatible model switch: OpenCode re-expands the original transcript for the runner while the public context still starts at the latest checkpoint. ACP's claimCheckpointRanges then classified the re-expanded originals as one opaque checkpoint by array position (start = previous source's max outgoing index + 1, end = next source's min outgoing index). Direct-tool history could not address any original message, and every original became an unaddressable opaque blob.
  • Direct-tool view (normalizeV2ProjectedHistory(projected, [])): a provider checkpoint with no outgoing values normalized to zero parts, so direct tools saw an empty checkpoint.

Repro confirmed against official v2.0.3 toLLMMessages: public [nativeCheckpoint, nextUser], outgoing [originalUser, originalAssistant, nextUser]; ACP reported a valid projection whose checkpoint claimed outgoing [0,1] as opaque.

Fix

  • claimCheckpointRanges (lib/v2/projection/normalize.ts) now collects the unclaimed candidates inside the positional window and claims a provider checkpoint only when exactly one candidate exists. Two or more candidates mean re-expanded originals from an incompatible model switch; zero means the checkpoint is absent from this outgoing view. In both cases nothing is claimed: the originals stay individually addressable as ordinary sources, and the host's own opaque entries remain byte-identical (verified through applyV2ContextPatch round-trip).
  • buildProviderCheckpoint renders the checkpoint from source data when it has no outgoing correlation: lowerCompactionText(source) under origin key source:{i}:checkpoint:source, marked opaque, so direct tools see the real summary/recent content instead of zero parts. Because the rendered message carries a known normalized ID, the patcher can never inject it into outgoing requests — it is visible only in the normalized view.
  • restore.ts (found during review): once the shared engine compresses the normalized checkpoint away, restoreMissingV2OpaqueSources would reject the whole patch for the uncorrelated entry ("no exact lowered correlation") and lib/v2/context.ts would preserve the raw provider request — silently disabling every ACP edit for the rest of such sessions. A provider checkpoint with empty outgoingMessageIndices is now skipped instead of rejected: the outgoing request already carries its information as re-expanded host-owned originals. Non-checkpoint opaque sources keep the fail-closed rejection.
  • V2ProvenanceEntry.providerCheckpoint doc comment documents the unsupported-window disclosure and the single-re-expanded-original residual; DESIGN.md §5 records all known residual limitations.

No version bump, no publish, no merge. No changes to V1 code paths, persisted state formats, or internal tags.

Review

Two independent agent reviews (AGENTS.md §5.3 + §5.6). The code reviewer found the restore.ts blocker above (new failure mode in exactly the scenario this PR fixes) — fixed in the second commit with a regression test verified to fail against the unfixed code. All other findings were nits, applied.

Verification

  • 4 new tests: incompatible model switch keeps re-expanded originals uncorrelated and individually addressable (patch round-trip object-identity), compatible single-decoded-checkpoint regression lock, direct-view fallback rendering (with the disclosed invalid state pinned), and the restore-skip regression test.
  • Bug-catching check per AGENTS.md §5.7.3: each bug-detection test fails with its fix reverted (verified empirically by swapping in the pre-fix file and re-running).
  • v2 test files: 33/33 pass. Full suite: 1414/1415 pass — the single failure is tests/soft-block.test.ts hardcoding mkdirSync('/tmp/opencode-dcp-dangerous-<pid>') at import time, which crashes on this sandbox's read-only /tmp mount (environmental, pre-existing, passes in CI — same failure noted in PR fix: recognize failed V2 ACP tool results as failures #3).
  • Typecheck, build, and devlog (devlog/2026-09-17_v2-checkpoint-provenance-guard/) complete.

Related finding (reported on the issue thread, not fixed here)

The direct-tool view rejects any user/assistant history with valid: false ("Patchable origin source:N:part:M has no exact lowered outgoing match") even though .messages are fully built and consumed by lib/v2/host.ts. Pre-existing on master, unrelated to this change.

claimCheckpointRanges now claims a provider checkpoint only when exactly one
unclaimed candidate exists in its window. More than one means re-expanded
originals after an incompatible model switch; zero means the checkpoint is
absent from the outgoing view. Both stay uncorrelated so host-owned messages
remain individual opaque entries instead of being swallowed into the
checkpoint entry.

buildProviderCheckpoint renders lowerCompactionText(source) when no outgoing
indices exist, so the direct-tool view (outgoing = []) keeps the checkpoint's
summary and recent context instead of normalizing to zero parts.

Unsupported windows are disclosed via providerCheckpoint + empty
outgoingMessageIndices (documented on V2ProvenanceEntry).

Tests: compatible native checkpoint regression lock, incompatible model switch
with re-expansion (object-identity patch round-trip), direct-view rendering.
Bug-catching tests verified to fail at HEAD without the fix.

Refs: #425
…s compressed away

Dual-agent review of the provenance guard found a cross-module blocker: once the shared engine compresses the normalized checkpoint away, restoreMissingV2OpaqueSources rejected the whole patch for the uncorrelated entry ('no exact lowered correlation') and lib/v2/context.ts preserved the raw provider request - silently disabling every ACP edit for the rest of incompatible-switch sessions. A provider checkpoint with empty outgoingMessageIndices is now skipped instead of rejected: the outgoing request already carries its information as re-expanded host-owned originals. Non-checkpoint opaque sources keep the fail-closed rejection.

Also applies review nits: protected-flag pin, disclosed invalid direct-view pin, types.ts comment precision, DESIGN.md residual-limitation section, restore regression test. Verified the regression test fails against the unfixed restore.ts.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

V2: public context and model-aware runner history diverge across native checkpoints

1 participant