refactor(runtime): unify durable model-context projection authority - #4350
Conversation
A Tool Result body that has been archived must stop being model-visible, and until now each prune path invented its own way to say so: the active prune kept a Turn-local placeholder map, the stale prune carried a ref table on the compaction policy. Neither is durable, so neither survives a restart, a concurrent Turn or a copy, and a reader had two places to look before it could say what the model actually sees. Add one closed, versioned record that says it once. A transition names the target RuntimeEvent and projection part, the source projection digest it is allowed to replace, the replacement projection and optional Session-owned archive reference, and the predecessor and high-water identity a reducer needs to fold a sparse set deterministically. Three properties come from the shape rather than from a writer's care: - The record id is derived from its content, so an append duplicated by a concurrent writer is the same record, not a second one. - The source digest binds the record to exactly one prior projection, so a writer that decided against stale state is permanently inert instead of racing. - The archive reference is optional, because "replace this projection" and "replace it with something the Session stores" are different facts and only the first is required. This is deliberately not a generalization of HistoryCompactCheckpoint, which replaces a contiguous prefix. A transition is sparse and per-event, and conflating the two would make the checkpoint a sparse override store. Refs #4283 Generated-by: Claude Code
A tool result with several inline images publishes one artifact per image before the codec decides whether the finished projection is admissible. If a later image fails validation or its write fails, the projection is refused — but the images already written stay in the Session, referenced by nothing that will ever be replayed. Reclamation has no authority to name them, so they are permanently unreachable and permanently retained. Make publication all-or-nothing. The planner now exposes a retraction for a publication that turns out not to be admitted, and the codec retracts every artifact it published before returning the failure sentinel. Retraction is best effort: a failed retraction only delays reclamation, whereas admitting a partly published projection would leave durable state no reader can explain. The retraction path needs a system delete for one Session-owned artifact, which already existed for Deep Research alone. Generalize it to deleteOwnedArtifactInSession, which takes the source the caller believes it owns and throws on a mismatch — one seam with a declared authority instead of one narrow seam per subsystem. Refs #4283 Generated-by: Claude Code
Both prune paths removed content from the model's view without recording that they had. The active prune kept its replacement in a per-Turn map that a restart discarded, so a continuation could show the full body again after the archive had already been written. The stale prune carried archive refs on the compaction policy and re-derived them on every replay, so what the model saw depended on which policy the reader happened to reconstruct. Two private recovery contracts, neither of them durable, and no single answer to "what does the model see for this Tool Result". Give the Session one answer. A reducer folds the transition ledger onto the RuntimeEvents and produces the effective history that budgeting, replay and compaction all read, and one writer commits every archive decision: - Archive the body, then append the transition, and only then may a caller show the replacement. A failure at either step leaves the model-visible content exactly as it was, so visible history is never lossy without a durable record explaining it. - The fold applies a transition only when its source digest still matches and its predecessor is the last applied one, so a writer holding stale state is refused rather than racing, and two readers reduce identically regardless of append order. - The replaced projection and the legacy result field are rewritten together, so a consumer still reading the older field cannot resurrect a body the model was supposed to have lost. - Reachability is derived from the reduction: an artifact is live exactly when an applied transition or a surviving placeholder names it. Nothing keeps a parallel table that could disagree. The reduction runs once, where prior-Turn context is assembled, so there is no second path to keep in step. Because the fold is the only prune memory, the migrated structures are deleted here rather than left behind: the per-Turn placeholder map, the archive-ref policy table and its replay policy path, the second archived-placeholder kind, and the stale prune's own event rewriter. The active prune now requires a durable recorder and a Turn ledger to address the target; without them it does nothing, which is the correct consequence of "a lossy change must be durable first". Refs #4283 Generated-by: Claude Code
…ersation A copied conversation gets new RuntimeEvent ids and new artifact ids, so a transition cannot be carried across verbatim: its target would name an event in the source Session and its source digest would describe a projection the target does not have. Left that way the record would be inert, and an inert transition means the archived body reappears in the copy — the one failure this protocol exists to prevent. Rebuild each transition inside the target instead. The target event, the placeholder and the archive reference are remapped, the source digest is re-derived from the cloned event, and lineage is preserved through the remapped predecessor id so a chain of transitions still folds in order. Because the digest is re-derived rather than copied, a remap this code failed to perform cannot produce a silently inert record: it throws. A transition whose target left the copied slice is dropped, which is safe in exactly one direction — the target is absent too, so nothing it replaced can come back. Copy admission also has to see archives that only a transition names, now that the placeholder is no longer the sole record of one. Reading the operational ledger alongside the RuntimeEvents keeps a copied placeholder from pointing at an artifact the copy never carried over. Refs #4283 Generated-by: Claude Code
… owner Review found the fold sitting one level too low. It ran inside the prior-Turn budget preparation, so every other producer of model-visible messages kept its own opportunity to show content a transition had already replaced — and the current Turn took that opportunity on the very next provider step. Reproduced against the previous build: a result archived at step N is rebuilt in full at step N+1 and every step after it, because each step rebuilds its prompt from the raw durable Turn ledger. The prune had grown a per-run `committed` map to remember what it had replaced — the same private memory this workstream set out to delete, under a new name — and measuring the threshold against that map is what let the raw body through: the map held the placeholder, the placeholder is small, so the oversized result was never replaced again. The archive stayed durable and correct throughout; what came back is the prompt, not the ledger. Fix it at the owner. The Turn's own events are folded through the same reducer before they become messages, so the prune needs no memory at all: what it sees is already the effective history, and an archived result is simply no longer large. The `committed` map and its parameter are gone, and the regression test drives four provider steps; with the fold removed it archives the same result twice and shows the raw body in the fourth prompt. Two more paths could resurrect replaced content: - A ledger read that failed, or a record this build cannot decode, was smoothed into "there are no transitions". Now both are reported, and a reader that cannot see the whole chain refuses to extend it: a successor built on partly known state would name the wrong predecessor and be inert forever, losing the content it archived. - A transition is recorded by the run that decided it, which for a prior-Turn archive is a LATER run than the one holding its target. Copying by run therefore kept the target and dropped the record. Transitions are now gathered by target, so a copy carries every record that applies to what it copied; the comment claiming this was already true is corrected. The same review showed the protocol had grown fields the fold does not need, and one of them was actively wrong. `highWaterSeq` was a wall-clock reading used both as the reduction cursor and as part of the record id, so "the same decision yields the same record" was false, and two transitions written in one millisecond could order the successor first and leave it permanently inert. The chain the record already carries is the ordering authority, so the fold now follows `previousTransitionId` instead of sorting, and ties among writers naming the same predecessor go to the smallest content-derived id. With ordering off the clock, `highWaterSeq` and its unread `highWaterName` are deleted and the id is genuinely derived from content alone. Deleted alongside them, for the same reason — a second representation of a fact that already has an owner: - `archive`, which repeated the artifact id, body digest and original size that the replacement's placeholder already carries; - the reduction's `reachableArchiveArtifactIds`, whose contents were a subset of what scanning the folded events yields, plus a loop over rejected transitions that could not remove anything; - `reason`, a second spelling of the placeholder's own reason with no reader and a hand-written mapping table between the two; - a public `collectStaleToolResultArchiveCandidates` wrapper with no callers, and a test shim parameter for a map that no longer exists. The StoredMessage fallback projection is still outside the fold. That is a second history representation predating this work, and the comment that overstated the guarantee now says which paths it covers. Refs #4283 Generated-by: Claude Code
5f72f5e to
3ed5276
Compare
jackwener
left a comment
There was a problem hiding this comment.
Reviewed the latest head 3ed5276b1. No P0 or P1 — approving. Nothing at P2 or P3, so no inline comments. One gate caveat below.
The atomic-publication fix reasons about failure ordering correctly, which is the part that could have gone wrong. A projection carrying several images publishes them one at a time, so a later failure leaves earlier publications orphaned. The comment states the priority explicitly and gets it the right way round: retraction is best-effort because a failed retraction only delays reclamation, while a failed rejection would put an unreferenced artifact in front of the user as if the tool had produced it. Rejection must be reliable; cleanup need not be.
The implementation matches. retract() acts only when this planner published (published flag, cleared on use, so no double-retraction), it is scoped to the artifact id it created, and it swallows errors by design. retractPublished being optional is fine here and — unlike the optional guard I flagged on #4300 — is explicitly reasoned: without it retract() is a no-op and reclamation falls back to reachability. That is a documented degradation to a slower correct path, not a silently skipped safety check.
The transition ledger's tests cover the hazards a single-owner reducer actually has to survive, and they are the reason I am comfortable with the ownership consolidation:
- refuses a stale concurrent writer instead of restoring its source — lost-update rejection.
- orders concurrent Turns deterministically regardless of ledger arrival order — the essential property for a fold over an async ledger; without it the same inputs could yield different histories.
- rolling compaction cannot re-measure or re-archive replaced content — idempotence against double processing.
- an archive failure leaves the model-visible content untouched and a ledger failure leaves the content untouched and the artifact unreachable — both failure directions of the two-step commit, each asserting content is not corrupted and, in the second, that the orphan stays reclaimable.
That last pair is the right shape for a non-transactional two-step: neither half failing may damage model-visible content, and it lines up with the retract design above rather than contradicting it.
Gate caveat. Only windows_recovery is green; audit, label and test were all still queued when I reviewed, so CI has effectively not run on this head. Required checks still gate the merge, so this approval does not move ahead of them — but I would not read the current state as evidence of anything yet.
Scope. I drove the artifact-publication atomicity and the transition ledger's concurrency and failure properties. I did not line-by-line review ai-sdk-compaction.ts (+283), the tool-result-archive.ts restructure, or the conversation-copy rebuild. On +2,647 lines, "approved" means the parts I probed hold up, not that every line was verified.
Automated review notice: This comment was posted by an automated review agent operated by jackwener. It is not an independent human review and does not replace one.
hqhq1025
left a comment
There was a problem hiding this comment.
Reviewed exact head 3ed5276b10da8ed8eaa4ac3ea7353057f6da9698. I found one P1 that prevents approval: an unreadable projection-transition record does not stop either current-Turn or prior-history replay from exposing the raw tool result again. The inline comment contains the failure path and required fail-closed behavior.
The remaining transition reducer, archive/ledger failure ordering, active and stale pruning, artifact rollback, and clean-chain copy behavior passed review. Validation: clean install; full build:test; full workspace typecheck; Runtime 3,094 pass / 13 skip; focused transition, pruning, backend, copy, projection, and budget tests 294/294; lint, format, ASF headers, diff check, and clean merge-tree against current main. Hosted test, audit, and label are still queued; windows_recovery is green.
Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.
…dable Review found the reader still trusting raw history when it could not read a record. A transition written by a newer build — or damaged — was counted and then skipped, so replay showed the original body: the very record that may have removed that content became the reason for showing it again. Both the current-Turn fold and the prior-history fold did this, and a downgrade or a partially written record is enough to reach it. The envelope names the target outside the payload, so a record this build cannot read can still be confined to the one event it concerns. That event's projection is withheld — replaced with the codec's existing failure projection, the same answer this system already gives for anything it cannot represent safely — and every other record for that target is refused, since an unreadable link makes the rest of the chain's order unknown. The session keeps working; only the content that might have been removed stops being shown. A record that does not name a target cannot be confined to anything, so model-history replay fails instead. Failing is recoverable by fixing or removing the record; showing content another version removed is not. Refs #4283 Generated-by: Claude Code
M4n5ter
left a comment
There was a problem hiding this comment.
Reviewed exact head c95dd52f4dea4063e8e2a6104dc57ebf77f60ada. The unreadable-transition replay defect reported on the previous head is fixed: unreadable targets are withheld, unscoped records fail closed, and the focused current-head regressions pass. I found one remaining P1 and three P2s, detailed inline. The P1 prevents approval because rollback can delete an artifact that an already-committed model projection still references.
Validation: Core, Storage, MCP, Runtime, and Runtime Host builds; 80 focused transition, pruning, copy, projection, and artifact tests; the previous head's complete Runtime (3,103 pass / 7 skip) and Runtime Host (1,455 pass / 12 skip) suites; clean current-main merge tree plus the same five builds and 80 focused tests on that merge result. windows_recovery is green on this head; test and audit are still queued.
Automated review notice: This comment was posted by an automated review agent operated by M4n5ter. It is not an independent human review and does not replace one.
…lished Artifact ids are derived from the bytes and `create` replays an existing record idempotently, so a projection that repeats an image an earlier projection already published gets that record back. The planner read every successful create as its own publication, so retracting the second plan deleted the artifact the first, already-committed projection still points at: a Tool Result the model can still see becomes an artifact that reads back as deleted. Publication now carries a receipt. The writer facade probes and creates under one write lease and reports whether that call is the one that published; a plan retracts only what it published. A caller without the receipt seam does not retract at all, which is the same conservative outcome as having no reclaim seam. Refs #4283 Generated-by: Claude Code
…appended A successful append does not make a transition the reducer's answer. Two Turns can load the same source projection and append rival roots, and the fold deliberately accepts exactly one of them — the smaller content-derived id — so a writer that showed its own replacement could have the next read swap the placeholder underneath the model, metadata and all. The writer now re-reads the ledger after appending and returns whatever the chain settles on for that target, using the same successor rule the fold uses rather than a second copy of it. A writer whose record lost shows the winner's placeholder; its own record stays durable and inert, and the body it archived stays unreachable exactly as a refused transition's archive should be. Also corrects a claim these comments were making: reducer-derived reachability is the authority a reclaiming pass must ask, but no such pass exists yet, so an archive whose transition append failed is retained rather than reclaimed. Saying "cleanup may reclaim the rest" described a pass that is not there. Refs #4283 Generated-by: Claude Code
Conversation copy rebuilt transitions in the order it happened to encounter them and, when a predecessor had not been mapped, quietly turned its successor into a root. Neither matches the source's authority: rival roots are resolved by content-derived id, not by run or timestamp, and a chain whose predecessor is missing is not a chain. A copy could therefore apply a transition the source fold had refused, showing a placeholder the source never showed. The source reduction now decides here too. Every record for a copied target is gathered — including from runs outside the copied slice, since a prior-Turn archive is recorded by a later run — folded once, and only the applied chain is rebuilt, in fold order. A predecessor that cannot be mapped fails the copy instead of being dropped, and an unreadable record for a copied target fails it too, rather than silently losing whatever that record removed. Refs #4283 Generated-by: Claude Code
ARE404
left a comment
There was a problem hiding this comment.
Holding approval at exact head c95dd52f4dea4063e8e2a6104dc57ebf77f60ada: there is a live, unaddressed P1 plus CI not green at this head.
① Live P1 — artifact retraction can tombstone an artifact another projection still uses (M4n5ter's thread at packages/storage/src/artifact-attachments.ts:216). I re-verified the current head still carries the vulnerable shape:
createReadImageSnapshotPlannercomputes a deterministic artifact ID from(sessionId, turnId, name, mimeType, bytes)andArtifactStore.createreturns an existing exact record idempotently.retract()callsdeleteOwnedArtifactInSession(sessionId, id, 'tool_result_projection')wheneverpersist()"succeeded" — but ownership is inferred only from the sharedsource === 'tool_result_projection'guard, not from a per-plan creation receipt.- Wiring at
packages/runtime-host/src/server/execution-model-composition.ts:336-339passes a real deleter asretractPublished, so the path is reachable in production. If a later Tool Result in the same Turn re-persists the same image (same deterministic ID → reuses plan A's record) and then fails while publishing another image, itsretract()will delete the artifact plan A's already-committed projection still references. Reachability: M4n5ter reproduced this against the production store. - The drift for this head (
c95dd52f) fixed hqhq1025's unreadable-record P1 and is sound, but it did not touchartifact-attachments.tsor the retraction/ownership path — so this P1 is still open here.
② CI not green at head: test is completed/cancelled at c95dd52f4 (not a green result); audit ✅, windows_recovery ✅. Need a green test at the exact head too.
Note the hqhq1025 P1 at ai-sdk-compaction.ts:547 (withhold instead of replaying a raw event when a transition record is undecodable) is addressed in this head via c95dd52f and I verified that fix is sound — that specific thread is clear. The blocking item above is the artifact-ownership/retraction P1.
Please (a) confirm retraction only deletes an artifact this plan actually created (creation-ownership receipt, or reference-aware cleanup) and (b) get test green at the head — then this can be re-reviewed ahead of merge.
zhiiw
left a comment
There was a problem hiding this comment.
Reviewed at exact head 5ea474c0 (verified unchanged at review time; test 24m, windows_recovery, and audit completed/success on this head). The head gained four fix commits during review; I read each and re-ran the evidence against the final head.
Durable projection transitions, verified rather than assumed:
- The record is the whole decision: target + source digest + replacement + predecessor link, with a content-derived id — so a duplicated concurrent append is idempotent, and the fold follows the chain instead of any clock, cursor, or ledger order.
nextInChainrequires predecessor match + current-digest match + target identity, with ties broken by the smallest content id, so every reader picks the same successor. - Fail-closed in both directions: a transition written by a newer build, or a damaged one, makes its target's Tool Result withheld (replaced by the failure sentinel) rather than shown raw — a record that may have removed content can never become the reason that content reappears (this is the
c95dd52fcorrection, and it is the right call). Provider-native opaque results have no addressable base, so no transition can target them at all. - Publication is all-or-nothing: the artifact plan validates and freezes without writing, persists only after the projection admits, and a refused projection retracts what it already published — best-effort, with failure delaying reclamation rather than surfacing unreferenced content (
b6a680ce, scoped by47a782efto what the plan itself published). - Writers answer with the fold's result: after appending, the writer re-reads the ledger and returns whatever the chain settles on for that target — a rival root that lost stays durable and inert instead of briefly impersonating the accepted state (
4f7f0896). - Copy rebuilds, never carries: a transition's target event id, artifact ids, and digest all differ across the copy boundary, so the copy re-derives each transition against the cloned event and replays the chain the source fold actually applied (
5ea474c0); a transition whose target left the slice is dropped, which is safe in exactly that direction. A missed remap fails loudly. - The retired structures are gone, not deferred: the per-Turn placeholder map, the archive-ref policy table and its replay path, and the stale prune's event rewriter are deleted;
HistoryCompactCheckpointkeeps its contiguous-prefix meaning, with the sparse transition deliberately not modeled as one.
The two declared gaps are honestly stated and correctly scoped out (the StoredMessage fallback representation, and the prune's pre-durability size comparison).
Executed on a real Windows machine at the final head: clean forced rebuild, then the projection/ledger/prune/copy set 68/78 (all 10 failures are the pre-existing EBUSY temp-SQLite teardown class on this machine — zero assertion failures, and the new transition-copy regression tests pass their bodies), plus ai-sdk-backend + history-compact-checkpoint 247/247. The hosted test job runs the same files green on this head; the heavy runtime-host composition suite does not finish locally (pre-existing slowness) and is covered by that run.
Automated review notice: This comment was posted by an automated review agent operated by zhiiw. It is not an independent human review and does not replace one.
简体中文
durable 投影迁移,全部核实而非假设:记录自带全部判定(目标+源摘要+替换+前驱链),内容寻址 id 使并发重复追加幂等,折叠跟链不跟时钟/cursor/台账顺序;nextInChain 要求前驱+当前摘要+目标身份三重匹配,平手取最小内容 id——每个读者选出同一后继。双向 fail-closed:不可读/新版记录让目标的 Tool Result 被扣留(换成失败 sentinel)而不是原样展示——一条可能删过内容的记录永远不会变成内容重现的理由(c95dd52f 修正);provider 原生不透明结果没有可寻址基座,迁移根本指不到它。发布全有或全无:plan 验证冻结不写盘、准入后才 persist、被拒投影回撤已发布内容(47a782ef 把回撤收窄到 plan 自己发布的)。写者追加后重读台账、返回折叠认定的结果(4f7f0896)。copy 跨边界重建每条迁移(重导摘要,漏 remap 即抛),目标离开切片才丢弃——那个方向恰好安全。退役结构是删了不是缓期。两个申报缺口如实且出界正确。本机真 Windows 最终 head:68/78(10 个失败全是既有 EBUSY 拆除类,零断言失败,新的迁移 copy 回归测试本体通过)+ 247/247;hosted test 同 head 同文件绿。
ARE404
left a comment
There was a problem hiding this comment.
Re-reviewing at the current head 5ea474c0b116fcb615c4a9a53cb91d26e1edc420 (matches the head carrying the review fixes; fresh at posting). No P0/P1 — approving.
This head supersedes the earlier discussion threads by addressing each one:
Artifact-ownership / retraction P1 (M4n5ter) — fixed. 47a782ef gives publication a receipt: createOwned probes then creates under the same write lease and reports whether this call actually published (deterministic content-derived id + idempotent create otherwise make success ambiguous). A plan now retracts only what it published (owned), so a same-Turn plan that reuses an earlier projection's image can no longer tombstone the artifact that earlier projection still references. A caller without the receipt seam defaults to publishedByThisCall:false and never retracts — the same conservative outcome as no reclaim seam. Diff is correct: the probe (getInSession) and create share one run(...) write-lease scope, so the receipt is exact.
Fold-vs-writer transition (M4n5ter P2) — fixed. 4f7f0896 has archiveToolResultAsTransition re-read the ledger after appending and return whatever the fold accepts via the shared nextInChain successor rule (not a second copy), so a writer whose rival root lost shows the winner's placeholder instead of its own replacement; its losing record stays durable and inert. Covered by a test that runs both append orderings.
Copy must reproduce the source fold (M4n5ter P2) — fixed. 5ea474c0 gathers every record for copied targets (including from runs outside the copied slice, since a prior-Turn archive is recorded by a later run), folds once via the authoritative reduceEffectiveModelProjections, and rebuilds only the applied chain in fold order; an unmappable predecessor or an unreadable record for a copied target fails the copy rather than silently losing content or surfacing a source-rejected placeholder. Matches the source reducer's authority (content-derived id, not run/timestamp).
Unreadable-record withholding (hqhq1025 P1) — fixed. c95dd52f withholds a target's Tool Result projection when a transition record is unreadable, confining the unreadable record to its one event and refusing the decodable records of that target; an unscoped unreadable record makes replay fail-recoverably instead of showing content a record may have removed.
Reclaim-claim correction (M4n5ter P2) — fixed. The comment overstating reachability-driven reclamation is corrected to state honestly that no cleanup pass consumes it yet (#4283).
CI is green at this exact head: test ✅ (finished 14:56Z), audit ✅, windows_recovery ✅. Every review thread raised on this PR now maps to a fix commit in the head. Approving on code + CI.
…ion-transitions Generated-by: Claude Code # Conflicts: # packages/runtime/src/__tests__/execution-boundary-test-helpers.ts # packages/runtime/src/ai-sdk-backend.ts # packages/runtime/src/ai-sdk-compaction-contract.ts
…pache#4350) Archiving a Tool Result removed content from the model's view without recording that it had: the active prune kept its replacement in a per-Turn map a restart discarded, and the stale prune carried archive refs on the compaction policy and re-derived them on every replay. Neither was durable, so there was no single answer to what the model sees for a given Tool Result, and a replay path could resurrect content a prune had already removed. This adds one append-only model-projection-transition record on the operational AgentRunEvent ledger and migrates both prune paths onto it. A Session-scoped reducer folds the ledger onto the canonical RuntimeEvent stream, and replay, budgeting, compaction, the current Turn and conversation copy all read that fold. Source-digest validation plus the previousTransitionId chain make the result independent of ledger order, arrival order and clocks; a content-derived id makes a duplicated concurrent append idempotent. A record this build cannot decode withholds its target's projection instead of replaying the raw body, and a record that names no target fails the read rather than guessing. The migrated structures are deleted rather than deferred: the per-Turn placeholder map, the archive-ref policy table and its replay path, the second placeholder kind, and the stale prune's own event rewriter. Projection artifact publication is now all-or-nothing and carries an ownership receipt, so a plan retracts only what it itself published — the partial-publication leak found in apache#4287's review. Compatibility: the RuntimeEvent ledger keeps its existing shape and is never rewritten; sessions with no transition records fold to their current history unchanged. HistoryCompactCheckpoint keeps its contiguous-prefix meaning. Refs apache#4283 Refs apache#4287 Generated-by: Claude Code
Summary
Archiving a Tool Result removed content from the model's view without recording that it had. The active prune kept its replacement in a per-Turn map a restart discarded; the stale prune carried archive refs on the compaction policy and re-derived them on every replay. Neither was durable, so there was no single answer to what the model sees for a given Tool Result.
This adds one append-only projection-transition record on the operational ledger and migrates both prune paths onto it. A Session-scoped reducer folds the ledger onto the RuntimeEvents, and replay, budgeting, compaction and the current Turn all read that result. Source-digest validation plus the
previousTransitionIdchain make the fold independent of ledger order, arrival order and clocks; a content-derived id makes a duplicated append idempotent. A record this build cannot read withholds its target's projection instead of replaying the raw body.The migrated structures are deleted here rather than deferred: the per-Turn placeholder map, the archive-ref policy table and its replay path, the second placeholder kind, and the stale prune's own event rewriter. Conversation copy rebuilds each transition against the cloned event, re-deriving the digest so a missed remap fails loudly instead of letting the body reappear.
Also fixes the partial artifact publication leak from #4287's review through this lifecycle rather than locally: projection artifact publication is all-or-nothing, and a refused projection retracts what it published.
HistoryCompactCheckpointkeeps its contiguous-prefix meaning; a sparse per-event transition is deliberately not modelled as one.Refs #4283
Refs #4287
flowchart LR subgraph writer["One archive decision"] w1["archive body\nto artifact"] --> w2["append transition"] --> w3["show the fold's winner,\nnot the record just written"] end re[("RuntimeEvent ledger\ncanonical, never rewritten")] ar[("AgentRunEvent ledger\nappend-only transitions")] w2 --> ar re --> fold["reduceEffectiveModelProjections\nsource digest + previousTransitionId chain"] ar --> fold fold --> c1["current Turn rebuild"] fold --> c2["next-Turn replay plan"] fold --> c3["budget + compaction"] fold --> c4["conversation copy"]Per Tool Result the fold walks a chain rather than a sorted list:
base projection → transition whose predecessor is in effect and whose source digest still matches → …. A record that names a digest some other writer already replaced never applies, on any machine, at any later time.Known gaps
Both pre-existing and out of scope here:
Verification
npm run format,npm run lintandnpm run typecheckare clean across the workspace. Focused suites pass: runtime 400, core 5, storage 60, runtime-host 15. Covered scenarios: current-Turn continuation, next-Turn replay, cold restart, concurrent Turns, rolling compaction, copy/branch, artifact cleanup failure, unreadable ledger record, legacy retry. The whole-repo suite was not run locally.AI use
Select exactly one:
Tool(s) and scope: Claude Code authored the implementation, tests and commit messages under human direction and review. Commits carry a
Generated-bytrailer.Checklist
Does this PR entail a change in behavior?