Uh oh!
There was an error while loading. Please reload this page.
fix(runtime): isolate an unclaimed control fact from the session view - #1618
Merged
Conversation
RuntimeReadModel decided which projection diagnostics are fatal by restating their codes, so the projection declared the diagnostics and its caller declared what they mean. Move that decision next to the codes as a table keyed by `RuntimeEventReadModelDiagnosticCode`: a new diagnostic cannot compile without saying whether it means a user-visible row may be missing. The caller and the persisted-compat test now ask that authority instead of listing codes. No behavior change — the table restates today's hard set exactly.
One RuntimeEvent the projection did not claim made an entire session unreadable. The catch-all emitted a hard `unsupported_event`, RuntimeReadModel threw on it, and the whole projection went with it — so getMessages, listTurns, branching, revising and every turn-scoped action failed over a fact that owns no chat row. #1607 was one instance; #1609 claimed those two shapes but left the amplification in place. Split the catch-all on the RuntimeEvent's own structure: `content` is its message payload, `actions` its control intent. Every row this projection emits from an unclaimed shape would have come from content, so a content-bearing event stays hard — "a message is never silently dropped" is the invariant the hard failure exists for. A control-only fact has nothing to lose, so it becomes `unclaimed_control_fact` and degrades the view instead of discarding it. A projector that tried to build a row and failed still reports its own hard diagnostic, so this softens nothing that attempted a message. A future gap is still caught before a user meets it. The projection-coverage contract now asserts on the unclaimed codes at either severity rather than the hard one alone, so a new SessionEvent variant with no claim still fails CI, and AiSdkFlow's exhaustiveness guard is what a variant becomes: a content-free control fact that lands on the degradable side by construction. Fixes#1613
…meet The soft path rests on a premise that was not machine-checked: an unclaimed content-free event degrades the view instead of withholding it, which is only safe while no unclaimed action can owe a row. `content === undefined` does not prove that on its own — permissionDecision, tokenUsage and the terminal fact all produce rows, and runtime-event-backfill already writes a content-free event that becomes a visible `permission_decision`. What actually holds the rule up is claim coverage, so make coverage the thing that is proven. The SessionEvent contract only covers events built by `mapSessionEventToRuntimeEvent`; tool-runtime, terminal-run-commit and the backfill write RuntimeEvents directly, so a new action field on those paths was invisible to it. A second contract keyed on `RuntimeEventActions` gives every field a reachable sample typed to its own key: a new field cannot compile without one and cannot pass without being claimed. Writing it found three fields the projection never claimed — `artifactDelta`, `transferToAgent` and `runtimeProtocol`, the last of which real emitters already write. All three are control-only, so claim them, and say in the fallback what the rule actually depends on.
Two regressions the suite could not see. The unmapped-SessionEvent test compared the raw code string, so dropping `unclaimed_control_fact` from `isUnclaimedRuntimeEventDiagnostic` would have quietly narrowed the coverage contract to `unsupported_event` with every test still green; it now filters through the predicate itself. And the hard side was only asserted inside the projector, so a caller that stopped enforcing the policy went unnoticed: append a content-bearing unclaimed event to a completed run's ledger and getSessionView must still refuse the view — the counterpart of the soft reproduction beside it.
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Jul 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
One RuntimeEvent the projection does not claim made an entire session unreadable, even when every message in it was intact. The projection emitted a hard
unsupported_eventfor any unrecognised shape,RuntimeReadModel.buildViewthrows on any hard diagnostic, and the whole projection was discarded — failing everygetSessionViewcaller, not just the transcript: branching, revising, and every turn-scoped action went with it. #1607 was one instance; #1609 claimed those two shapes but left the amplification in place.The hard failure is deliberate — it exists so messages are never silently dropped — so this splits it rather than relaxing it wholesale:
unsupported_event, hard.unclaimed_control_fact, soft.Severity now lives in one table keyed by code, so a new diagnostic cannot exist without deciding which side it falls on, and
buildViewasks the projection instead of restating the codes.What makes the soft side safe is claim coverage, not the absence of
content. Actions do own user-visible rows —permissionDecision,tokenUsage, and the terminal fact all produce one, andruntime-event-backfill.tsalready writes a content-free event that projects to a visiblepermission_decision. Nothing with a row reaches the degrading branch only because every action field a reader can meet is claimed, so the projection-coverage contract now has to prove exactly that.The contract previously enumerated
BackendSessionEvent['type']alone, which left every RuntimeEvent produced outsidemapSessionEventToRuntimeEventuncovered —tool-runtime,terminal-run-commit, andruntime-event-backfillall write actions directly. It now also enumerates every field ofRuntimeEventActions, keyed so a new field cannot compile without a sample and cannot pass without being claimed.Writing that contract found three action fields the projection had never claimed:
artifactDelta,transferToAgent, andruntimeProtocol. The first two have no emitter yet.runtimeProtocoldoes —runtime-runner.tswrites it, andRecoveryResolverreads it; it only ever avoided breaking a session because it has so far always ridden on an already-claimed carrier. All three are now claimed as control facts.#1609 declined this downgrade because it "would hide a future projection gap". That is answered by keeping the two questions separate: severity decides whether a session opens, the coverage contract decides whether coverage is missing. The contract asserts on the union of both unclaimed codes, so softening a severity cannot soften the contract.
Closes#1613. Refs #1607, #1609.
Verification
@maka/runtimefull suite: 2795 tests, 2786 pass, 0 fail, 9 skipped.@maka/headless1428 pass, CLI 668 pass, 0 fail.SessionEventvariant the mapping was never taught, the turn completes through a realsendMessage, and the session reads back intact with the unclaimed event reported as oneunclaimed_control_fact. Reverting the fallback to always-hard makes it fail with the originalRuntimeReadModelError.getSessionViewthrow. Verified to bite by making the fallback unconditionally soft.runtimeProtocolclaim fails it at runtime; removing its table entry fails compilation withTS2741.unclaimed_control_factfrom it can no longer silently narrow both contracts tounsupported_event.fix/sandbox-boundary-pending-restart(fix: let a pending sandbox boundary request survive a host restart #1612), which touches the same file: auto-merges clean, combined suite green.npm run format,npm run lint,npm run typecheck --workspacesclean. Not run: desktop E2E — this change does not reach renderer or main.Review focus
A malformed control fact — one that half-matches a known shape — is soft alongside a genuinely unknown one. The read model is not the enforcement authority (boundary enforcement has its own durable revisions) and an unopenable session is the worst answer available, but if ledger corruption should outrank forward compatibility that argues for a third severity tier, which this PR does not add.
stateDeltais an open record, so the contract can only cover the field's existence, not new keys inside the delta. That limit is recorded in the table rather than papered over.artifactDeltaandtransferToAgentare claimed as silent control facts before either has an emitter. If a hand-off should eventually render as a visible row, that claim is where it has to change.