SnapshotDescriptor.blocked carries the drain's over-budget hold — the op it is holding and the bytes it needs — and blueprint/engine.md makes it a snapshot field rather than an event precisely because it is a state that clears, so a lost "resumed" would strand the UI on a blockage that is gone.
What already landed
The command-refusal half of this is delivered (#1071). When the command is refused over budget, crates/wasm/src/host.rs maps EngineError::OverBudget to the stable code overBudget, apps/web/src/hooks/useDropUpload.ts:237 captures it onto the upload row, and apps/web/src/components/file-browser/UploadListItem.tsx:32 renders it as transient rather than as terminal failure.
blocked itself now has one reader: apps/web/src/engine/introspection.ts:84 folds view.blocked === null into the e2e settled predicate. That is a test seam, not a rendered surface.
What remains
The snapshot-sourced hold. The error code only fires when the command is refused at the boundary; it says nothing about an op the drain is already holding. A queued op the drain holds emits no opProgress, so it sits at queued forever with no explanation and no action — which is the case blocked exists to describe.
Scope
- Read
blocked (and retainedRecords, which explains staged bytes the session cannot account for) from the snapshot the store already holds — packages/client/src/worker/protocol.ts:107,114, projected through apps/web/src/engine/snapshotStore.ts.
- Match
blocked.opId to the upload row and render the hold with neededBytes, distinct from queued, from the overBudget command refusal already rendered, and from a terminal failure.
- Decide where the hold is owned: the upload rows are transient hook state today (
useDropUpload), so this needs the snapshot store and the upload rows to meet somewhere. That choice is the substance of this issue — and UploadListItem already takes a code, so the join point is the row's props, not a second store.
Gate
web unit.
Corrected 2026-08-06 against main @ 3d087d673. Stale before this edit: "Nothing in apps/web reads it" (introspection.ts:84 reads view.blocked), and the implicit claim that no over-budget state reaches the upload row at all — the command-refusal path landed with #1071. Rescoped to the residual: the snapshot-sourced blocked.opId/neededBytes hold, which the error-code path does not cover.
SnapshotDescriptor.blockedcarries the drain's over-budget hold — the op it is holding and the bytes it needs — andblueprint/engine.mdmakes it a snapshot field rather than an event precisely because it is a state that clears, so a lost "resumed" would strand the UI on a blockage that is gone.What already landed
The command-refusal half of this is delivered (#1071). When the command is refused over budget,
crates/wasm/src/host.rsmapsEngineError::OverBudgetto the stable codeoverBudget,apps/web/src/hooks/useDropUpload.ts:237captures it onto the upload row, andapps/web/src/components/file-browser/UploadListItem.tsx:32renders it as transient rather than as terminal failure.blockeditself now has one reader:apps/web/src/engine/introspection.ts:84foldsview.blocked === nullinto the e2e settled predicate. That is a test seam, not a rendered surface.What remains
The snapshot-sourced hold. The error code only fires when the command is refused at the boundary; it says nothing about an op the drain is already holding. A queued op the drain holds emits no
opProgress, so it sits atqueuedforever with no explanation and no action — which is the caseblockedexists to describe.Scope
blocked(andretainedRecords, which explains staged bytes the session cannot account for) from the snapshot the store already holds —packages/client/src/worker/protocol.ts:107,114, projected throughapps/web/src/engine/snapshotStore.ts.blocked.opIdto the upload row and render the hold withneededBytes, distinct fromqueued, from theoverBudgetcommand refusal already rendered, and from a terminal failure.useDropUpload), so this needs the snapshot store and the upload rows to meet somewhere. That choice is the substance of this issue — andUploadListItemalready takes acode, so the join point is the row's props, not a second store.Gate
web unit.Corrected 2026-08-06 against
main@3d087d673. Stale before this edit: "Nothing inapps/webreads it" (introspection.ts:84readsview.blocked), and the implicit claim that no over-budget state reaches the upload row at all — the command-refusal path landed with #1071. Rescoped to the residual: the snapshot-sourcedblocked.opId/neededByteshold, which the error-code path does not cover.