Skip to content
This repository was archived by the owner on Sep 8, 2026. It is now read-only.

feat(viewport): bounded best-effort tail read and snapshot-first attach (plan #959) - #963

Merged
btipling merged 12 commits into
mainfrom
feat/a5-bounded-tail-read
Sep 7, 2026
Merged

btipling merged 12 commits into
mainfrom
feat/a5-bounded-tail-read

Conversation

@btipling

@btipling btipling commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Summary

Adds the opt-in, read-only, bounded recent-viewport backend from plan #959:
a disposable partial view (never a full transcript or model seed), a bounded
recent tail + latest Blob head-only recovery, an explicit resume position,
and a snapshot-first live stream with indexed event records.

Status: HANDOFF-READY phase 1. The current production host still uses the
unnegotiated legacy stream; switching it and closing #924 belongs to #960.

What ships

  • Bounded recovery (lib/sessions/viewportRead.ts, lib/workflows/viewportRunReader.ts)
    • Samples at most 2048 recent stored frames, 8 MiB decoded bytes, 5 s
      optional work, with one final 1 s tail probe. No origin replay, no
      ancestry/prev reconstruction, no retry chase; overload omits history and
      resumes at a known raw index with gap/incomplete flags.
    • readViewportHead reads only the current scoped Blob head (≥0, ≤1
      transcriptPointer), never foreign bodies, and makes no writes.
  • Disposable rows (lib/sessionViewport.ts) — allowlisted event/role parsing,
    bounded recent rows, stripped sampling reasoning, visible excerpt markers,
    new carrier allowlist (no persona/notes/model/compaction bodies or secrets),
    and whole-JSON 2 MiB/2048 fitting. Missing prompt/earlier rows are
    optional-history misses, not errors.
  • Versioned protocol (lib/viewportStreamProtocol.ts) — incremental
    UTF-8/CRLF SSE codec; viewport_state → one viewport_snapshot → indexed
    turn_event records; synthetic viewport_end/viewport_error never consume
    a stored raw index. Known malformed frames skip at their index; a lost
    position closes instead of guessing.
  • Negotiated routes
    • GET /api/turns/:runId/stream?sessionId=:id&viewportVersion=1&hydrate=tail
      (cold) and …&viewportVersion=1&startIndex=N (hot indexed) — one response,
      no second cold GET. Default/legacy route bytes are unchanged.
    • POST /api/turns?viewportVersion=1 — indexed events for new runs without
      changing inference/start args.
    • GET /api/sessions/:id/viewport — head-only JSON display view.
  • Safety — existing session auth and owned current-run mismatch checks reuse
    the stream route's tenancy gate (404/401); a scoped isObjectIdBoundTo
    check precedes any read; ownership is rechecked before releasing a recovered
    snapshot. No inference/tool execution, run start/cancel or session writes in
    recovery. Negotiated pre-stream errors return sanitized 503 and never leak
    SDK details. historyComplete:false is unconditional.

Tests

  • npm test (default + tenancy): 3,581 passed, including the existing POST
    turns suite (70) and new negotiated-route, auth/foreign, streaming
    cleanup/budget, SDK-failure and codec/reducer/fitting suites.
  • npm run test:int (real Wasm): 14 passed, including 2 new
    int/viewport-read.int.test.ts rows that run the production read service →
    real parser → real Wasm ring: historical reasoning is never painted,
    sampled latest rows survive, and post-handoff live reasoning/text arrive with
    a strict absolute cursor.
  • npm run typecheck and npm run build pass; DI/drizzle gates and
    git diff --check pass.

Docs

docs/session-model.md, docs/agent-stream.md, docs/harness-limits.md,
SECURITY.md and AGENTS.md now document the bounded read path, negotiated
record grammar, recovery budget caveats, and security boundary. README/native
protocol/env remain untouched (no production host flip or new secret/export in
this phase).

Performance / resource notes

  • Work is linear in one legal Blob head plus ≤2048 sampled frames; no
    all-chain/all-history retained payloads, no global exact merge.
  • VIEWPORT_RECOVERY_MAX_BYTES counts decoded sample bytes, including
    reasoning that is immediately discarded.
  • VIEWPORT_RESPONSE_MAX_BYTES bounds one escaped snapshot/control payload;
    the long-lived live SSE stream may exceed it over time (unchanged).

Not included

Plan / issue links

Fixes #959 · Refs #958 #553 #924 (does not close #924 before phase 2).

@vercel

vercel Bot commented Sep 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
invincible Ignored Ignored Sep 7, 2026 5:41pm UTC

Request Review

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #963

Verdict: CONCERNS
Repo: btipling/invincible
Scope: mainfeat/a5-bounded-tail-read · 23 files · opt-in bounded viewport read + snapshot-first SSE (plan #959)
Lenses run: L1, L2, L5, L6, L7, L8 (skip: L3 no host/Wasm/UI flip; L4 no CI/wasm/deploy; L9 no palette/DOM chrome)
AGENTS.md read: yes (also docs/feature-divide.md, SECURITY.md, plan #959)

Findings

Sev Lens Finding Break scenario Refutation attempt Confidence
Major L1+L5 parseViewportMode (lib/viewportStreamProtocol.ts · GET) treats ?viewportVersion=1 with neither hydrate=tail nor startIndex as {kind:'indexed', startIndex:0} via q.get('startIndex') ?? '0'. That is full origin replay of the durable chunk log under the new framing. Plan G1/G2 and #924 exist specifically because legacy startIndex=0 ships historical thinking and is unbounded in run length. POST defaulting to 0 is correct (new run). GET is not. 1. Long-running turn, H0 ≫ 2048. 2. Client opts into v1 the same way POST does: GET /api/turns/:runId/stream?sessionId=:id&viewportVersion=1 (no hydrate, no cursor) — #960 is likely to copy this. 3. Handler takes the indexed path, open(0), and forwards every stored reasoning_delta as turn_event. Cold caps (2048 / 8 MiB / 5 s) never run. Defender: docs list only hydrate=tail and startIndex=N; implicit 0 matches legacy. Fails: docs never describe this third GET form; protocol tests never assert GET viewportVersion=1 alone is rejected (lib/viewportStreamProtocol.test.ts only checks POST); route tests never hit it (viewport.test.ts rejects version=2 / hydrate+startIndex / 1e2 / duplicate sessionId, not the omitted-selector case). Production host is not flipped yet, so this is not a current-user outage — it is a merge-now protocol footgun that makes G1 false for the obvious v1 GET. high
Minor L1 Negotiated live path (lib/agent/viewportStream.ts · nextFrame) schedules the first run.status poll at TURN_STREAM_STATUS_POLL_MS (1 s). bodyForRun / pipeRunReadable uses a 0-delay first poll specifically to unstick cancelled/failed hanging getReadable(). The v1 route never calls bodyForRun on the negotiated branch, so it also skips the cancelled/failed “never getReadable()” short-circuit. Attach hydrate=tail or hot startIndex=N to a cancelled/failed run whose producer never closed the writable (documented C16 class). Legacy: immediate synthetic SSE, no SDK readable. v1: open() hangs; recovering/live waits 5 s sample budget (cold) + 1 s first poll before viewport_end. Defender: recoverViewport already times out reads at 5 s; T8 is best-effort history for terminal runs. Partially true — not unbounded — but the 0-delay poll exists in-tree for this hang class and was not carried over. Tests only cover hung readables after a 1 s advance (viewportStream.test.ts). high
Nit L8 SECURITY.md “Read-only partial viewport” says these routes never “publish session/transcript data.” They exist to publish a partial transcript/carrier view to the owning session. Overstated; the rest of the paragraph (auth, bound pointer, no secrets/run-inputs) is accurate. A later reader treats “never publish transcript data” as the security invariant and flags the JSON viewport 200 as a regression, or omits row allowlisting. Defender: meant “don’t publish as a public dataset / don’t write.” The sentence still says “publish.” high

Residual risk

Cold recovery is best-effort: a burst produced during the sample window is skipped (gap:true); one Blob read() still materializes a whole ≤8 MiB object before the byte check; blob.read / SDK getInfo are not abort-linked so late work can finish after the deadline (documented in docs/harness-limits.md). getReadable is a durable-chunk stream (one formatTurnSse write per stored index) so treating each read() as one frame is consistent with Workflows 4.8.4 startIndex — but unlike pipeRunReadable there is no parseSseChunk rest-buffer; if a world implementation ever coalesces or splits chunk bytes, parseViewportEvent (blocks.length !== 1) will skip a whole batch as one index or fatal UTF-8 will close live with STREAM_UNAVAILABLE. stillOwned is only checked before releasing the snapshot, not during the live tail (same TOCTOU as today’s attach). No production HarnessHost flip — #924 stays open.

Merge guidance

  • CONCERNS: do not merge until the Major is fixed — GET v1 must not origin-replay unless the client explicitly sent startIndex=0 (or hydrate=tail for bounded recovery).
  • Suggested patch: parseViewportMode GET returns null when viewportVersion=1 and neither hydrate nor startIndex is present; add protocol + route tests that this 400s before getRun / Blob. Optional: 0-delay first status poll; tighten the SECURITY.md sentence.

What was not attacked

Live Workflows getReadable / getInfo against origin Production; Vercel Blob byte-range behavior; DO runner; Gateway; Wasm paint beyond the two int/viewport-read.int.test.ts rows (read as source, not re-executed here); #960 host wiring.

Adversarial review on PR #963: GET ?viewportVersion=1 with neither
hydrate=tail nor startIndex silently origin-replayed (indexed 0), the
#924 class this path exists to avoid. POST still defaults to 0 for new
runs. First status poll is now 0-delay like pipeRunReadable.

btipling commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

Follow-up on the adversarial review (CONCERNS): landed in 81ad3fe.

  • Major: GET viewportVersion=1 without hydrate or startIndex is now 400, not implicit origin replay. Explicit startIndex=0 still allowed; POST still defaults to 0 for new runs.
  • Minor: first live status poll is 0-delay (same hang class as pipeRunReadable).
  • Nit: SECURITY.md no longer claims these routes never publish transcript data.

tsc --noEmit and vitest run --changed (126) green.

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #963

Verdict: CONCERNS
Repo: btipling/invincible
Scope: mainfeat/a5-bounded-tail-read · 23 files · opt-in bounded viewport read + snapshot-first SSE (plan #959), including follow-up 81ad3fe
Lenses run: L1, L2, L5, L6, L7, L8 (skip: L3 no host/Wasm/UI flip; L4 no CI/wasm/deploy; L9 no palette/DOM chrome)
AGENTS.md read: yes (also docs/feature-divide.md, SECURITY.md, plan #959)

Prior review (81ad3fe) fixed implicit GET v1 origin replay, the 0-delay live status poll, and the SECURITY.md wording. Those do not survive re-attack. The cancelled/failed getReadable gate did.

Findings

Sev Lens Finding Break scenario Refutation attempt Confidence
Major L1+L5+L6 Negotiated GET still touches getReadable on already-cancelled/failed runs. bodyForRun exists because that readable can hang while run.status is terminal (C16). The v1 branch never calls bodyForRun. Cold attach (app/api/turns/[runId]/stream/route.ts) awaits runReader.nextIndex() before the SSE body, and nextIndex is getReadable({ startIndex: -1 }) — the live tail, the hang point. Timeout maps to sanitized 503 with no viewport_state, no head snapshot, no viewport_end. Plan T8 / locked decision 2: a terminal run still returns sampled/head display; emit recovering state before H0. Indexed v1 open(N) has the same hole if open() itself never returns (the 0-delay poll never starts). 1. Stop a live turn so Workflow status is cancelled but the producer never closed the writable. 2. GET /api/turns/:runId/stream?sessionId=:id&viewportVersion=1&hydrate=tail. 3. Handler calls nextIndex() → hanging getReadable({startIndex:-1}). 4. After VIEWPORT_RECOVERY_MAX_MS the route catch returns {error:'Viewport stream unavailable.'} 503. Blob head is never offered. Legacy attach would have returned synthetic SSE immediately and never called getReadable. Defender: 0-delay poll in viewportStream.nextFrame unsticks a hanging reader. Fails: poll is armed only after open().getReader(); the route hang is before viewportStream is constructed. Defender: recoverViewport already times out sample reads at 5s and falls back to head. Fails: that code is not reached if nextIndex() throws into the route catch. Defender: production host is not flipped. Fails: this is the #960 cold GET; T8 is in-scope; viewport.test.ts mocks getTailIndex as instant and never asserts cancelled+hanging getReadable is skipped (pipeRunReadable.test.ts rows 13b/13c cover that class on the legacy path only). high

Residual risk

Cold recovery remains best-effort: a burst during the sample window is skipped (gap:true); one Blob read() still materializes a whole ≤8 MiB object before the byte check; blob.read / SDK getInfo are not abort-linked so late work can finish after the deadline (documented in docs/harness-limits.md). getReadable is treated as one stored frame per read() with no parseSseChunk rest-buffer — coalesced/split chunk bytes skip a batch or fatal UTF-8 closes live with STREAM_UNAVAILABLE. stillOwned is only checked before releasing the snapshot, not during the live tail (same TOCTOU as today’s attach). No production HarnessHost flip — #924 stays open. getTailIndex is a structural optional; absent helper fail-closes 503 rather than origin-falling-back (tested).

Merge guidance

  • CONCERNS: do not merge until the Major is fixed — negotiated GET must not call getReadable when run.status is already cancelled/failed (same gate as bodyForRun). Cold must still return head snapshot + viewport_end. Emit viewport_state before the H0 probe so a slow/unavailable tail cannot 503 with an empty body.
  • Suggested patch: race run.status (1 s, same as bodyForRun) before nextIndex/open. On cancelled/failed: skip every SDK readable; cold uses readViewportHead only; then synthetic viewport_end. Move H0 capture into viewportStream after viewport_state. Add route tests that a hanging getReadable is never invoked for cancelled/failed hydrate=tail and indexed GET.

What was not attacked

Live Workflows getReadable / getTailIndex against origin Production; Vercel Blob byte-range behavior; DO runner; Gateway; Wasm paint beyond the two int/viewport-read.int.test.ts rows (read as source, not re-executed here); #960 host wiring.

Adversarial review on PR #963: negotiated GET raced nextIndex/open
before the C16 cancelled/failed gate, so a hanging tail readable 503'd
with no snapshot. Status is checked first (same 1s race as bodyForRun);
cold hydrate still returns a head snapshot then viewport_end.

btipling commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

Follow-up on the second adversarial review (CONCERNS): landed in 7e35603.

  • Major: negotiated GET no longer calls getReadable when run.status is already cancelled/failed (same C16 gate as bodyForRun). Cold hydrate=tail still returns a Blob-head snapshot then viewport_end; indexed GET emits viewport_end only. A hanging tail readable can no longer 503 the attach with an empty body.
  • Route races status (1 s) before nextIndex/open. recoverViewport({ skipStream: true }) is head-only.
  • Tests: hanging getReadable is never invoked for cancelled/failed hydrate=tail and indexed GET; stream unit rows cover the skip path.

tsc --noEmit and the viewport/turns vitest files (181) green.

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #963

Verdict: CONCERNS
Repo: btipling/invincible
Scope: mainfeat/a5-bounded-tail-read · 23 files · opt-in bounded viewport read + snapshot-first SSE (plan #959), including follow-ups 81ad3fe and 7e35603
Lenses run: L1, L2, L5, L6, L7, L8 (skip: L3 no host/Wasm/UI flip; L4 no CI/wasm/deploy; L9 no palette/DOM chrome)
AGENTS.md read: yes (also docs/feature-divide.md, SECURITY.md, plan #959)

Prior reviews fixed implicit GET v1 origin replay (81ad3fe) and the cancelled/failed getReadable gate (7e35603). Those do not survive re-attack. The remaining hole is the running cold path still blocking the SSE body on H0.

Findings

Sev Lens Finding Break scenario Refutation attempt Confidence
Major L1+L5 Cold GET still awaits runReader.nextIndex() and a second status() before new Response() (app/api/turns/[runId]/stream/route.ts). Plan #959 locked decision 2 step 1: after auth, emit viewport_state so #960 can show Busy/Stop while recovering; H0 is step 2 under the same 5 s recovery clock as head+sample. getTailIndex is world.streams.getInfo (workflow 4.8.4) — independent of the data reader, but still a network metadata call. A slow/unavailable tail 503s with no viewport_state, no Blob head, no viewport_error. Route also spends up to 1 s + 5 s (H0) + 5 s (redundant status) outside recoverViewport's fresh 5 s clock, so optional recovery can exceed the locked 5 s budget before the first SSE byte. 7e35603 only skipped this for already-cancelled/failed. 1. Running turn; Workflows getInfo / getTailIndex hangs or the readable has no helper. 2. GET …/stream?sessionId=:id&viewportVersion=1&hydrate=tail. 3. Handler awaits nextIndex() (getReadable({startIndex:-1}) + getTailIndex) up to VIEWPORT_RECOVERY_MAX_MS before constructing the body. 4. Timeout/throw → {error:'Viewport stream unavailable.'} 503, empty body. Blob head is never offered. Client cannot paint recovering/Busy. Contrast: cancelled/failed now returns state+head+viewport_end. viewport.test.ts locks the 503 (unavailable initial tail fails attach, never guesses zero). Defender: plan decision 1 / 2.5 says 503 when initial live-tail metadata is unavailable; guessing startIndex=0 was the first review's Major. Fails: 503-vs-guess-zero is the right failure mode, not the right ordering. After a 200 SSE has started, in-band viewport_error + head snapshot (replace:false / skipStream) fails the live attach without guessing origin and without an empty HTTP body. Defender: production host is not flipped. Fails: this is the #960 cold GET; T8/decision 2 step 1 is in-scope; previous review already asked to move H0 after viewport_state and only the C16 skip landed. Defender: getReadable construction is inert until pull, so startIndex:-1 is not origin replay. True — not this finding. The hang/fail is getInfo, not replay. high
Minor L1 Cancelled/failed cold snapshot reports resumeIndex: 0 and sampledRange:{start:0,end:0} because the route forces initial=0 to avoid nextIndex. Terminal path correctly skips getReadable and ends, but the snapshot cursor is a lie about the tail. 1. Cancel a long run. 2. hydrate=tail → snapshot resumeIndex:0 then viewport_end. 3. A #960 client that persists resumeIndex as turnStreamCursor will later send explicit startIndex=0. Indexed GET on a still-cancelled run now short-circuits to viewport_end (no replay) — bounded. Residual is a poisoned cursor if a later non-terminal attach uses it. Defender: viewport_end follows; docs say resumeIndex is not proof of history; host is not wired. Partially true — not a live origin replay today. Still a dishonest H on the snapshot the host is supposed to consume. medium

Residual risk

Cold recovery remains best-effort: a burst during the sample window is skipped (gap:true); one Blob read() materializes a whole ≤8 MiB object before the byte check; blob.read / SDK getInfo are not abort-linked so late work can finish after the deadline (documented in docs/harness-limits.md). getReadable is treated as one stored frame per read() with no parseSseChunk rest-buffer — coalesced/split chunk bytes skip a batch or fatal UTF-8 closes live with STREAM_UNAVAILABLE. stillOwned is only checked before releasing the snapshot, not during the live tail (same TOCTOU as today’s attach). Status-probe timeout (1 s) then treating the run as non-hang-class is the same race as bodyForRun. No production HarnessHost flip — #924 stays open. getTailIndex is a real 4.8.4 helper (getInfo); absent helper fail-closes rather than origin-falling-back.

Merge guidance

  • CONCERNS: do not merge until the Major is fixed — negotiated cold GET must return new Response(viewportStream(…)) immediately after auth. Capture H0 after viewport_state, under the same 5 s recovery deadline as head+sample. Missing/hanging H0: head-only snapshot (do not open(0)), then in-band viewport_error / no live attach — never empty 503. Keep the C16 skip (already in viewportStream). Drop the route’s redundant second status() wait.
  • Suggested tests: running + missing getTailIndex is 200 with viewport_state then viewport_error, getReadable never called with {startIndex:0}; hanging getTailIndex still emits state before the deadline; existing cancelled/failed rows stay green.

What was not attacked

Live Workflows getReadable / getInfo against origin Production; Vercel Blob byte-range behavior; DO runner; Gateway; Wasm paint beyond the two int/viewport-read.int.test.ts rows (read as source, not re-executed here); #960 host wiring.

Adversarial review on PR #963: cold GET awaited nextIndex/status
before the SSE body, so a slow/unavailable getTailIndex 503'd with
no viewport_state and no Blob head. H0 now runs after recovering
state under the same 5s clock as head+sample; missing tail is
in-band viewport_error after the head snapshot, never open(0).

btipling commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

Follow-up on the third adversarial review (CONCERNS): landed in 669563a.

  • Major: cold GET no longer awaits nextIndex() / a second status() before the SSE body. viewport_state is emitted first; H0 is captured under the same 5s recovery clock as head+sample (head starts in parallel). Missing/hanging getTailIndex returns 200 with state + head snapshot + in-band viewport_error — never empty 503, never open(0) origin replay. Cancelled/failed C16 skip is unchanged (still inside viewportStream).
  • Minor (cancelled resumeIndex:0): left as-is; that path still ends with viewport_end and never live-attaches. A trustworthy tail would require the same hanging getReadable we skip.

tsc --noEmit and the viewport/turns vitest files (156) green.

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #963

Verdict: CONCERNS
Repo: btipling/invincible
Scope: mainfeat/a5-bounded-tail-read · 23 files · opt-in bounded viewport read + snapshot-first SSE (plan #959), including follow-ups 81ad3fe, 7e35603, 669563a
Lenses run: L1, L2, L5, L6, L7, L8 (skip: L3 no host/Wasm/UI flip; L4 no CI/wasm/deploy; L9 no palette/DOM chrome)
AGENTS.md read: yes (also docs/feature-divide.md, SECURITY.md, plan #959)

Prior reviews fixed implicit GET v1 origin replay (81ad3fe), the cancelled/failed getReadable gate (7e35603), and H0-before-SSE (669563a). Those do not survive re-attack. The remaining hole is the live nextFrame poll treating completed like hang-class cancel.

Findings

Sev Lens Finding Break scenario Refutation attempt Confidence
Major L1+L6 nextFrame (lib/agent/viewportStream.ts) 0-delay-polls isTerminalRunStatus, which includes completed, and a new nextFrame runs on every stored frame. pipeRunReadable 0-delay-polls only on the first waiting pull, injects only while pullWaiting is still true (buffered microtask read beats the macrotask), then uses a 1 s cadence — with an explicit test completed + buffered text_delta then hang → delta then done; inject does not precede. Viewport copied the 0-delay hang-unstick from review 1 but not the completed-drain discipline. Docs (docs/agent-stream.md) already claim “Completed-run buffered frames drain before a hung read is resolved from a terminal status poll.” The only completed unit row is a hung empty readable (viewportStream.test.ts polls a hung readable and emits synthetic completed). 1. Completed turn with stored chunks still in the Workflow log (C16 completed replay), or a live v1 POST whose run.status flips to completed while done/text_delta is still a network read away. 2. GET …/stream?sessionId=:id&viewportVersion=1&startIndex=0 (documented explicit replay) or the live loop after snapshot/POST. 3. After any delivered frame (or on the first network fetch), nextFrame starts read() and setTimeout(poll, 0). 4. status === 'completed' is already resolved; the 0-delay macrotask wins over an async getReadable chunk. 5. Client gets viewport_end and never sees remaining text_delta / producer done (finishReason, cwd, usage). Indexed reconnect of a just-finished turn is empty. Defender: in-memory test streams enqueue in start(), so read() is a microtask and beats setTimeout(0) — existing rows are green. Fails: production getReadable is WorkflowServerReadableStream over getInfo/chunk fetch, not sync enqueue; pipeRunReadable.test.ts exists because that race is real. Defender: skipReadable already covers cancelled/failed; completed is supposed to wrap getReadable (same C16 comment on bodyForRun). Fails: wrap-and-drain is the point; 0-delay completed skips the drain. Defender: no production host yet. Fails: POST ?viewportVersion=1 is in this PR; #960 hot resume of a completed run is the documented startIndex=N path. high
Minor L1 ViewportReducer.apply (lib/sessionViewport.ts) does if (ev.type === 'reasoning_delta') { this.lastKind = ''; return; }. Omitting historical thinking is required; resetting lastKind is not. Locked decision 3: “Adjacent sampled text deltas concatenate into a bounded recent assistant excerpt.” A thinking token between two text deltas becomes two assistant rows. 1. Cold hydrate=tail samples text_delta "Hello"reasoning_deltatext_delta " world". 2. Snapshot paints two assistant rows "Hello" and " world" instead of "Hello world". 3. #960 host hydrates that split into the ring. Defender: live host treats thinking as a ring boundary, so splitting is consistent. Fails: the snapshot does not paint the thinking row, so the boundary is invisible and the concatenate rule is the one that applies. Defender: modest fragmentation is waived. True for splice/alignment; not a license to break the one coalesce the reducer claims to do. Tests only cover reasoning-then-text, never reasoning-between-text (never retains historical thinking or appends aggregate done over text). high

Residual risk

Cold recovery remains best-effort: a burst during the sample window is skipped (gap:true); one Blob read() materializes a whole ≤8 MiB object before the byte check; blob.read / SDK getInfo are not abort-linked so late work can finish after the deadline (documented in docs/harness-limits.md). getReadable is treated as one stored frame per read() with no parseSseChunk rest-buffer — coalesced/split chunk bytes skip a batch or fatal UTF-8 closes live with STREAM_UNAVAILABLE. stillOwned is only checked before releasing the snapshot, not during the live tail. Cancelled/failed cold snapshot still reports resumeIndex:0 (accepted after review 3). Status-probe timeout then treating the run as non-hang-class is the same race as bodyForRun. No production HarnessHost flip — #924 stays open. Tests mock DI (createProdServices / getRun / requireSessionUser); no new PGlite / createDbConnection (L6 cost gate not tripped).

Merge guidance

  • CONCERNS: do not merge until the Major is fixed — nextFrame 0-delay poll may unstick cancelled/failed only (hang class). completed must drain buffered/in-flight stored frames; synthetic viewport_end only after a 1 s hung read or readable EOF (same discipline as pipeRunReadable). Add a unit row: completed + buffered text_delta then hang → delta(s) then viewport_end, inject does not precede.
  • Minor: stop resetting lastKind when dropping reasoning_delta; add a reducer row that thinking-between-text still concatenates.

What was not attacked

Live Workflows getReadable / getInfo against origin Production; Vercel Blob byte-range behavior; DO runner; Gateway; Wasm paint beyond the two int/viewport-read.int.test.ts rows (read as source, not re-executed here); #960 host wiring.

Adversarial review on PR #963: nextFrame 0-delay-polled isTerminalRunStatus
(including completed) on every frame, truncating C16 completed replay and
live producer done. 0-delay unsticks cancelled/failed only; completed waits
1s hung or EOF. Reducer no longer splits adjacent text across omitted thinking.

btipling commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

Follow-up on the fourth adversarial review (CONCERNS): landed in 4e3998d.

  • Major: nextFrame 0-delay poll now unsticks cancelled/failed only. completed drains buffered/in-flight stored frames; synthetic viewport_end waits for a 1 s hung read or readable EOF (same discipline as pipeRunReadable). A later text_delta 20 ms after the first chunk is no longer dropped.
  • Minor: ViewportReducer no longer resets lastKind when omitting reasoning_delta; adjacent text deltas still concatenate.
  • Docs: docs/agent-stream.md now states the 0-delay vs 1 s split explicitly.

tsc --noEmit and the viewport/turns vitest files (133) green.

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #963

Verdict: CONCERNS
Repo: btipling/invincible
Scope: mainfeat/a5-bounded-tail-read · 23 files · opt-in bounded viewport read + snapshot-first SSE (plan #959), including follow-ups 81ad3fe, 7e35603, 669563a, 4e3998d
Lenses run: L1, L2, L5, L6, L7, L8 (skip: L3 no host/Wasm/UI flip; L4 no CI/wasm/deploy; L9 no palette/DOM chrome)
AGENTS.md read: yes (also docs/feature-divide.md, SECURITY.md, plan #959)

Prior reviews fixed implicit GET v1 origin replay (81ad3fe), the cancelled/failed getReadable gate (7e35603), H0-before-SSE (669563a), and completed 0-delay truncation (4e3998d). Those do not survive re-attack. The remaining hole is that unknown H0 is still encoded as resume cursor 0.

Findings

Sev Lens Finding Break scenario Refutation attempt Confidence
Major L1+L6 viewportStream (lib/agent/viewportStream.ts) on hanging/missing nextIndex() sets initialIndex = 0, skipStream: true, then yields viewport_snapshot with resumeIndex: 0 before viewport_error STREAM_UNAVAILABLE. ViewportStreamDecoder treats an explicit snapshot as a cursor jump (this.nextIndex = index). Plan #959 / docs/agent-stream.md: unknown position must close, never guess; GET v1 without a selector is already 400 because implicit 0 is the #924 class. This path reintroduces that cursor through the snapshot the decoder is required to apply. recoverViewport skipStream copies opts.initialIndex through as resumeIndex (lib/sessions/viewportRead.ts). Route test unavailable initial tail still emits recovering state and head; never origin-replays (app/api/turns/[runId]/stream/viewport.test.ts) only asserts this response did not open(0) — it never asserts resumeIndex is absent/non-zero, and the decoder starts with nextIndex === undefined so the jump to 0 is untested. Empty-run H0 is a successful getTailIndex() === -1 → next 0; that is not this catch. 1. Long running turn, H0 ≫ 2048; Workflows getInfo / getTailIndex hangs or the readable has no helper. 2. GET …/stream?sessionId=:id&viewportVersion=1&hydrate=tail. 3. Client uses ViewportStreamDecoder (the contract this PR ships). 4. Records: viewport_state → snapshot {resumeIndex:0, source:'stored_head', sampledRange:{start:0,end:0}}viewport_error. Decoder nextIndex is now 0. 5. Docs say preserve last applied cursor on error. 6. Next attach GET …&viewportVersion=1&startIndex=0 is explicit origin replay of historical reasoning_delta. Server did not open(0) on the failed hydrate; it published 0 as the applied resume. Defender: stream then errors; #960 is not flipped; open(0) is not called on this response; resumeIndex is “just a transport position.” Fails: the decoder in this PR must jump on snapshot; error handling says keep last applied cursor; startIndex=0 is a legal indexed GET (not 400); tests never pin resumeIndex on the H0-fail fixture; G1/G2 are false for the documented hydrate client that reconnects from the snapshot it just applied. high

Residual risk

Attack did not re-breach C16 cancelled/failed getReadable, implicit GET v1 without a selector, H0-before-viewport_state, or completed 0-delay drain. Workflows getTailIndex is streams.getInfo (independent of the data reader; construction is inert until pull) so cancel-immediately on the -1 probe is not origin replay. Live chunk = one stored write, so per-read parseViewportEvent matches the SDK index. Best-effort omission, 8 MiB JSON.parse cost, and a 1 s C16 status race before the first SSE byte remain accepted. Production host is still unnegotiated — this finding is the protocol footgun #960 will copy.

Merge guidance

CONCERNS: do not merge until the Major is fixed. On unknown H0, emit the head snapshot for display without a resume cursor jump (resumeIndex omitted / not applied by the decoder). Empty-run resumeIndex: 0 stays valid only when nextIndex() actually returned 0. Pin it in the existing H0-fail route + decoder tests.

What was not attacked

Live DO runner, prod Gateway, real Workflows getInfo latency, #960 host consumer, Wasm paint beyond int/viewport-read.int.test.ts.

Adversarial review on PR #963: hanging/missing getTailIndex still
yielded viewport_snapshot with resumeIndex:0 before STREAM_UNAVAILABLE.
The decoder treats a snapshot as a cursor jump, so the next attach
became explicit origin replay — the #924 class GET v1 already 400s.
Display-only snapshots now omit resumeIndex; the decoder does not jump.
Empty-run 0 remains valid only when nextIndex() actually returned 0.

btipling commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

Follow-up on the fifth adversarial review (CONCERNS): landed in e9f39d7.

  • Major: hanging/missing H0 no longer publishes resumeIndex: 0 on the head snapshot. The snapshot is display-only (property omitted); ViewportStreamDecoder does not jump, so a reconnect cannot treat the failed hydrate as explicit startIndex=0 origin replay. Empty-run resumeIndex: 0 remains only when nextIndex() actually returned 0. Cancelled/failed cold snapshots are also display-only (no guessed cursor).
  • Tests: decoder rejects a live turn_event after a display-only snapshot; H0-fail and cancelled/failed hydrate rows assert resumeIndex is absent.

tsc --noEmit and the viewport/turns vitest files (122 targeted / 136 --changed) green.

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #963

Verdict: CONCERNS
Repo: btipling/invincible
Scope: mainfeat/a5-bounded-tail-read · 23 files · opt-in bounded viewport read + snapshot-first SSE (plan #959), including follow-ups through e9f39d7
Lenses run: L1, L2, L5, L6, L7, L8 (skip: L3 no host/Wasm/UI flip; L4 no CI/wasm/deploy; L9 no palette/DOM chrome)
AGENTS.md read: yes (also docs/feature-divide.md, SECURITY.md, plan #959)

Prior reviews fixed implicit GET v1 origin replay (81ad3fe), cancelled/failed getReadable (7e35603), H0-before-SSE (669563a), completed 0-delay drain (4e3998d), and display-only snapshots when H0 is unknown (e9f39d7). Those do not survive re-attack. The remaining hole is the T9 real-Wasm proof, which still models the pre-669563a caller-captured H0 and was not re-run after H0 moved inside viewportStream.

Findings

Sev Lens Finding Break scenario Refutation attempt Confidence
Major L6 int/viewport-read.int.test.ts pre-calls adapter.nextIndex() (first getTailIndex = H0−1) then passes that as startIndex into viewportStream({ cold }). After 669563a, cold overwrites that value with a second nextIndex() (lib/agent/viewportStream.ts · H0 capture). The mock's second probe returns H = H0+7, so the sample open is H−2048 (97959) not H0−2048 (97952). expect(opens).toContain(H0 - 2048) fails. Follow-up comments after 669563a only ran targeted viewport/turns vitest files — not npm run test:int. T9/T1 no longer proves production samples from max(0,H0−2048). There is also no unit row that nextIndex returning 5000 then 5070 samples open(2952) and live-attaches at 5070 (constant-100 stream tests cannot catch this). 1. vitest run --project int int/viewport-read.int.test.ts on e9f39d7. 2. First it throws on opens.toContain(100000-2048) while opens is [-1,-1,97959,-1,100007]. 3. Plan T9/T10 and the PR body (“14 passed, including 2 new int rows”) are stale vs HEAD. merge-pr’s default npm test does not run this project, so the red T9 can ship. Defender: production GET passes startIndex: 0 for cold and never pre-probes, so the live algorithm still samples from the first in-stream H0. True for app/api/turns/[runId]/stream/route.ts — this is not a current origin-replay bug. Fails as a merge gate: T9 is the locked real-Wasm evidence that G1’s origin skip holds through the production composition (viewportStream + recoverViewport + decoder + ring), and it is red / proving the old caller-H0 shape. Defender: later assertions (no HISTORICAL_THINKING, latest sampled assistant, live text) still pass so the row is “mostly green.” Fails: those still pass because 97959 < H0 keeps the mock on the historical branch; they do not assert the sample origin. high

Residual risk

Cold recovery remains best-effort: a burst during the sample window is skipped (gap:true); one Blob read() materializes a whole ≤8 MiB object before the byte check; blob.read / SDK getInfo are not abort-linked so late work can finish after the deadline (documented in docs/harness-limits.md). getReadable({startIndex:-1}) for H0 is construction-inert until pull (workflow 4.8.4) and getTailIndex is a separate streams.getInfo; cancel-before-pull is the intended metadata probe, not proven against origin Production. getReadable is treated as one stored frame per read() with no parseSseChunk rest-buffer — coalesced/split chunk bytes skip a batch or fatal UTF-8 closes live with STREAM_UNAVAILABLE. stillOwned is only checked before releasing the snapshot, not during the live tail (same TOCTOU as today’s attach). Stored done/error close the iterator without viewport_end (synthetic end is status-only); #960 must treat stream EOF after a stored done as terminal. No production HarnessHost flip — #924 stays open.

Merge guidance

  • CONCERNS: do not merge until the Major is fixed — T9 must drive cold recovery the same way the route does (startIndex: 0, no caller nextIndex()), and a unit row must lock sample open(H0-2048) vs a later final-probe tail.
  • Suggested patch: drop the pre-probe in int/viewport-read.int.test.ts; pass startIndex: 0 and let viewportStream capture H0. Add viewportStream.test.ts coverage that nextIndex 5000 then 5070 samples from 2952 and live-opens 5070. Re-run vitest run --project int plus the viewport unit files.

What was not attacked

Live Workflows getReadable / getInfo against origin Production; Vercel Blob byte-range behavior; DO runner; Gateway; Wasm paint beyond reading int/viewport-read.int.test.ts (not re-executed here — no node_modules / harness artifact in this review workspace); #960 host wiring.

Adversarial review on PR #963: T9 pre-called nextIndex() then passed that
as startIndex, so after H0 moved inside viewportStream the sample window
was the later final-probe tail. Drive cold recovery like the route
(startIndex 0) and unit-lock open(H0-2048) vs a grown final probe.

btipling commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

Follow-up on the sixth adversarial review (CONCERNS): landed in 19308d1.

  • Major: T9 no longer pre-captures H0. Cold recovery is driven the same way as GET hydrate=tail (startIndex: 0; H0 captured inside viewportStream). Sample origin is H0-2048, not the later final-probe tail. A unit row locks nextIndex 5000 then 5070 → open(2952) then live open(5070).

tsc --noEmit green. Viewport unit files 54 (including the new H0-vs-final-probe row). vitest run --project int int/viewport-read.int.test.ts 2 passed against current harness.wasm.

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #963

Verdict: PASS WITH NOTES
Repo: btipling/invincible
Scope: mainfeat/a5-bounded-tail-read · 23 files · opt-in bounded viewport read + snapshot-first SSE (plan #959), including follow-ups through 19308d1
Lenses run: L1, L2, L5, L6, L7, L8 (skip: L3 no host/Wasm/UI flip; L4 no CI/wasm/deploy; L9 no palette/DOM chrome)
AGENTS.md read: yes (also docs/feature-divide.md, SECURITY.md, plan #959, workflow@4.8.4 getReadable/getTailIndex contract)

Prior reviews fixed implicit GET v1 origin replay (81ad3fe), cancelled/failed getReadable (7e35603), H0-before-SSE (669563a), completed 0-delay drain (4e3998d), display-only snapshots when H0 is unknown (e9f39d7), and T9 sampling from the first in-stream H0 (19308d1). Those do not survive re-attack.

Findings

Sev Lens Finding Break scenario Refutation attempt Confidence
Minor L1+L6 recoverViewport (lib/sessions/viewportRead.ts) still writes resumeIndex: opts.initialIndex on skipStream: true. That value was never captured from nextIndex() — it is the caller’s inbound guess (cold route always passes 0). viewportStream deletes the property before encode, so today’s HTTP path is safe, but the service returns a guessed cursor and lib/sessions/viewportRead.test.ts skipStream is head-only… never asserts it is absent. Review 5’s invariant lives only in the stream wrapper. 1. Future caller (JSON compose, #960 helper, another route) uses recoverViewport({ skipStream: true, initialIndex: 0 }) and forwards resumeIndex into a snapshot. 2. ViewportStreamDecoder jumps to 0. 3. Next attach GET …&startIndex=0 is explicit origin replay of historical reasoning_delta — the #924 class GET v1 already 400s when the selector is omitted. Defender: only viewportStream calls recoverViewport and it deletes resumeIndex when !resumeKnown. True for this PR’s HTTP path (hydrate cancelled/failed + H0-fail tests pin absence). Fails as a merge-quality lock: the function that “never probes the run” still mints a transport cursor; the unit row that names skipStream does not pin the review-5 invariant at the service that produces the snapshot. high
Minor L6 Plan T5/T9 require a single omission note on a replacing snapshot (VIEWPORT_HISTORY_NOTE). recoverViewport appends it, but no unit, route, or int/viewport-read.int.test.ts row asserts the text. A regression that drops [...rows, note] or the rows.length > 1 replace gate stays green. 1. Delete the note append in recoverViewport. 2. vitest run + vitest run --project int int/viewport-read.int.test.ts still pass. 3. #960 hydrates a replacing tail with no “earlier omitted” system row. Defender: gap / incomplete / historyComplete:false are asserted and are the machine contract; the note is display copy. Partially true for flags. Fails T9’s locked shipping matrix (“omission note”) and leaves the only user-visible omission sentence unpinned. high

Residual risk

Attack did not re-breach C16 cancelled/failed getReadable, implicit GET v1 without a selector, H0-before-viewport_state, completed 0-delay drain, guessed resumeIndex: 0 on the encoded snapshot, or T9’s sample origin. Cold recovery remains best-effort: a burst during the sample window is skipped (gap:true); one Blob read() materializes a whole ≤8 MiB object before the byte check; blob.read / SDK getInfo are not abort-linked so late work can finish after the deadline (documented in docs/harness-limits.md). getReadable({startIndex:-1}) is construction-inert until pull (workflow 4.8.4) and getTailIndex is a separate streams.getInfo; cancel-before-pull is the intended metadata probe. getReadable is treated as one stored frame per read() with no parseSseChunk rest-buffer — coalesced/split chunk bytes skip a batch or fatal UTF-8 closes live with STREAM_UNAVAILABLE. stillOwned is only checked before releasing the snapshot, not during the live tail (same TOCTOU as today’s attach), and is itself 1 s-bounded. Stored done/error close the iterator without viewport_end (synthetic end is status-only); #960 must treat a stored done/error and stream EOF as terminal. Status snapshot hang still has the same 1 s race as bodyForRun before the hang-class gate. No production HarnessHost flip — #924 stays open.

Merge guidance

PASS WITH NOTES: safe to merge from this attack. The two Minors should land in this PR (omit resumeIndex inside recoverViewport when skipStream; pin the omission note in the skipStream/sample unit rows and T9) so the review-5 cursor invariant and T9 matrix are not wrapper-only.

What was not attacked

Live Workflows getReadable / getInfo against origin Production; Vercel Blob byte-range behavior; DO runner; Gateway; Wasm paint beyond reading int/viewport-read.int.test.ts (not re-executed in this sandbox); #960 host wiring.

Adversarial review on PR #963: recoverViewport minted resumeIndex from
the caller's inbound guess on skipStream. Omit it at the service so a
guessed cursor cannot be encoded. Pin VIEWPORT_HISTORY_NOTE on replacing
snapshots (unit + T9).

btipling commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

Follow-up on the seventh adversarial review (PASS WITH NOTES): landed in 77cc674.

  • Minor: recoverViewport({ skipStream: true }) no longer mints resumeIndex from the caller’s inbound guess. Display-only snapshots omit the cursor at the service, not only in viewportStream.
  • Minor: replacing snapshots pin VIEWPORT_HISTORY_NOTE (sample + skipStream unit rows and T9 ring).

tsc --noEmit green. Viewport unit files 65. POST turns 70. vitest run --project int int/viewport-read.int.test.ts 2 passed against current harness.wasm.

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #963

Verdict: PASS WITH NOTES
Repo: btipling/invincible
Scope: mainfeat/a5-bounded-tail-read · 23 files · opt-in bounded viewport read + snapshot-first SSE (plan #959), HEAD 77cc674
Lenses run: L1, L2, L5, L6, L7, L8 (skip: L3 no host/Wasm/UI flip; L4 no CI/wasm/deploy; L9 no palette/DOM chrome)
AGENTS.md read: yes (also docs/feature-divide.md, SECURITY.md, plan #959, workflow getReadable/getTailIndex contract)

Prior reviews fixed implicit GET v1 origin replay (81ad3fe), cancelled/failed getReadable (7e35603), H0-before-SSE (669563a), completed 0-delay drain (4e3998d), display-only snapshots when H0 is unknown (e9f39d7), T9 sampling from the first in-stream H0 (19308d1), skipStream resumeIndex + omission-note pins (77cc674). Those do not survive re-attack.

Findings

Sev Lens Finding Break scenario Refutation attempt Confidence
Minor L2 ViewportStreamDecoder snapshot parse (lib/viewportStreamProtocol.ts · parse) spreads the raw JSON object ({...rest}) and only checks objectRecord(o.carriers). Rows are re-run through parseViewportRow; carriers are not re-run through viewportCarriers / normalizeSessionCwd / sanitizeQueue. Extra keys (workingNotes, personaSnapshot, host-absolute cwd) survive into the decoded record. This PR ships that decoder as the client contract (docs/agent-stream.md). 1. A buggy encoder or any proxy that can rewrite SSE data: adds carriers: { cwd: "/etc", workingNotes: "…" } (or extra snapshot keys). 2. Decoder returns them. 3. #960 folds snapshot.carriers into the session the way the server-side allowlist promised it would not. Defender: only this server encodes; same-origin authenticated SSE; TypeScript erases extras; #960 can sanitize again. True for today's HTTP path. Fails as a merge-quality lock: SECURITY.md says carriers are allowlisted; the encoder honors that and the published parser does not. viewportCarriers() cannot be called as-is on encoded carriers (logicalCwd vs cwd). high

Residual risk

Attack did not re-breach C16 cancelled/failed getReadable, implicit GET v1 without a selector, H0-before-viewport_state, completed 0-delay drain, guessed resumeIndex: 0 on the encoded snapshot, skipStream minting a transport cursor, T9 sample origin, or T9 omission-note pins. Cold recovery remains best-effort: a burst during the sample window is skipped (gap:true); one Blob read() materializes a whole ≤8 MiB object before the byte check; blob.read / SDK getInfo are not abort-linked so late work can finish after the deadline (documented in docs/harness-limits.md). getReadable({startIndex:-1}) is construction-inert until pull (workflow 4.8.4) and getTailIndex is a separate streams.getInfo; cancel-before-pull is the intended metadata probe. Live getReadable is treated as one stored chunk per read() matching the SDK write index; parseViewportEvent rejects multi-block chunks (legacy pipeRunReadable still uses a rest buffer only to detect terminal events on the byte pipe). stillOwned is only checked before releasing the snapshot, not during the live tail (same TOCTOU as today's attach), and is itself 1 s-bounded. Stored done/error close the iterator without viewport_end (synthetic end is status-only); #960 must treat a stored done/error and stream EOF as terminal. Status snapshot hang still has the same 1 s race as bodyForRun before the hang-class gate. JSON GET /api/sessions/:id/viewport does not append VIEWPORT_HISTORY_NOTE (flags only); that surface is not the SSE snapshot. No production HarnessHost flip — #924 stays open.

Merge guidance

PASS WITH NOTES: safe to merge from this attack. The Minor should land in this PR (reconstruct snapshot records from allowlisted fields; re-sanitize encoded carriers on decode) so the SECURITY.md carrier boundary is not encoder-only.

What was not attacked

Live Workflows getReadable / getInfo against origin Production; Vercel Blob byte-range behavior; DO runner; Gateway; Wasm paint beyond reading int/viewport-read.int.test.ts (durable-turn int check on this SHA is green); #960 host wiring.

Adversarial review on PR #963: ViewportStreamDecoder spread raw snapshot
JSON and accepted carriers via objectRecord only. Reconstruct from
allowlisted fields and re-sanitize encoded carriers (cwd, not envelope
logicalCwd) so extra keys and host-absolute paths cannot ride into #960.

btipling commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

Follow-up on the eighth adversarial review (PASS WITH NOTES): landed in 3c1d1f2.

  • Minor: ViewportStreamDecoder no longer spreads raw snapshot JSON. Snapshots are reconstructed from allowlisted fields; encoded carriers are re-sanitized via parseViewportCarriers (cwd shape, not envelope logicalCwd). Extra keys (workingNotes, personaSnapshot) and host-absolute cwd are dropped. Unit rows pin encoder round-trip and a hostile SSE block.

vitest run --changed (default + tenancy): 158 passed. tsc --noEmit green.

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #963

Verdict: CONCERNS
Repo: btipling/invincible
Scope: mainfeat/a5-bounded-tail-read · 23 files · opt-in bounded viewport read + snapshot-first SSE (plan #959), including follow-ups through 3c1d1f2
Lenses run: L1, L2, L5, L6, L7, L8 (skip: L3 no host/Wasm/UI flip; L4 no CI/wasm/deploy; L9 no palette/DOM chrome)
AGENTS.md read: yes (also docs/feature-divide.md, SECURITY.md, plan #959)

Prior reviews (81ad3fe3c1d1f2) fixed implicit GET v1 origin replay, cancelled/failed getReadable, H0-before-SSE, completed 0-delay drain, guessed resumeIndex:0, T9 sample origin, skipStream cursor minting, and decoder snapshot allowlisting. Those survive re-attack. GET negotiated pre-stream errors stay sanitized. POST does not.

Findings

Sev Lens Finding Break scenario Refutation attempt Confidence
Major L2+L6 Negotiated POST /api/turns?viewportVersion=1 still interpolates failClosed(err) (app/api/turns/route.ts outer catch ~826, and the live-guard getRun catch ~389). SECURITY.md’s new viewport section says negotiated stream failures use sanitized codes, not backend connection details. GET v1 already maps throws to {error:'Viewport stream unavailable.'} and unit-locks a planted provider URL/token-private-detail. POST v1 does not. 1. POST /api/turns?viewportVersion=1 with Accept: text/event-stream. 2. start() (or the in-flight getRun probe) throws Unable to connect to https://workflow.internal/?token=…. 3. Client receives 503 {error:'Unable to start durable turn (fail closed): Unable to connect to https://workflow.internal/?token=…'}. No private, no-store. Legacy POST is unchanged (G4); this is the new negotiated client. route.test.ts covers invalid negotiation 400 and happy-path indexed events; it never asserts a planted SDK throw is redacted on v1. Defender: G4 leaves unnegotiated POST errors alone; failClosed is the existing start-failure contract. Fails: the SECURITY.md paragraph is in this diff and names negotiated mode; GET already treated the same leak as Major. Defender: production host is not flipped. Fails: this is the #960 POST; the sanitization test exists only on GET. Defender: viewportStream itself emits in-band viewport_error. Fails: start() throws before new Response(viewportStream(…)), so the HTTP catch is the body. high

Residual risk

Cold recovery remains best-effort: a burst during the sample window is skipped (gap:true); one Blob read() materializes a whole ≤8 MiB object before the byte check; blob.read / SDK getInfo are not abort-linked so late work can finish after the deadline (documented in docs/harness-limits.md). getReadable is treated as one stored frame per read() with no parseSseChunk rest-buffer — coalesced/split chunk bytes skip a batch or fatal UTF-8 closes live with STREAM_UNAVAILABLE. stillOwned is only checked before releasing the snapshot, not during the live tail (same TOCTOU as today’s attach). Status-probe timeout (1 s) then treating the run as non-hang-class is the same race as bodyForRun. nextIndex still constructs getReadable({startIndex:-1}) (inert until pull; getTailIndex is getInfo) and cancels the data side — a world that aborted getInfo on cancel() would fail H0 closed rather than origin-replay. No production HarnessHost flip — #924 stays open.

Merge guidance

  • CONCERNS: do not merge until negotiated POST never interpolates err.message.
  • Suggested patch: when indexedViewport, both POST failClosed catches return the same sanitized 503 + private, no-store, no-transform as GET v1. Leave legacy POST failClosed strings unchanged (G4). Add a route row: start() throw with a planted URL/token is 503 {error:'Viewport stream unavailable.'} and the secret is absent.

What was not attacked

Live Workflows getReadable / getTailIndex / getInfo against origin Production; Vercel Blob byte-range behavior; DO runner; Gateway; Wasm paint beyond the two int/viewport-read.int.test.ts rows (read as source, not re-executed here); #960 host wiring.

Adversarial review on PR #963: POST ?viewportVersion=1 interpolated
failClosed(err) on start()/live-guard throws, leaking SDK connection
details the GET v1 path already redacts. Return the same sanitized 503
plus private/no-store; leave legacy POST strings unchanged.

btipling commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

Follow-up on the ninth adversarial review (CONCERNS): landed in 54abaf9.

  • Major: negotiated POST no longer interpolates failClosed(err). start() throws and the live-guard getRun catch return the same sanitized 503 {error:'Viewport stream unavailable.'} + private, no-store, no-transform as GET v1. Legacy POST failClosed strings are unchanged (G4).
  • Tests: planted provider URL/token-private-detail on start-throw and live-guard getRun throw; both 503 without the secret.

tsc --noEmit green. POST turns 72 (was 70) + GET viewport 16 passed.

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #963

Verdict: PASS WITH NOTES
Repo: btipling/invincible
Scope: mainfeat/a5-bounded-tail-read · 23 files · opt-in bounded viewport read + snapshot-first SSE (plan #959), HEAD 54abaf9
Lenses run: L1, L2, L5, L6, L7, L8 (skip: L3 no host/Wasm/UI flip; L4 no CI/wasm/deploy; L9 no palette/DOM chrome)
AGENTS.md read: yes (also docs/feature-divide.md, SECURITY.md, plan #959)

Prior reviews (81ad3fe54abaf9) fixed implicit GET v1 origin replay, cancelled/failed getReadable, H0-before-SSE, completed 0-delay drain, guessed resumeIndex:0, T9 sample origin, skipStream cursor minting, decoder snapshot allowlisting, and negotiated POST failClosed interpolation. Those survive re-attack.

Findings

Sev Lens Finding Break scenario Refutation attempt Confidence
Minor L1+L6 readViewportHead (lib/sessions/viewportRead.ts) spreads emptyViewport() (gap:true) and never sets gap:false after a successful scoped head parse. recoverViewport overwrites gap from the sample; the JSON route returns the head view as-is. app/api/sessions/[id]/viewport/route.test.ts pins hasEarlier/replace and never gap. 1. GET /api/sessions/:id/viewport on a 10-row head with no prev. 2. Body is source:'stored_head', hasEarlier:false, gap:true. 3. #960 idle restore treats gap as a stream-sample skip (the field’s meaning on snapshots) and shows omission chrome on every session open, including a one-message session. Defender: incomplete:true is unconditional; #960 can key off hasEarlier. Fails as a published flag: gap is the sample-skip bit (start>0 / unread interval / growth), not “view is partial.” hasEarlier:false + gap:true is incoherent. Head-only JSON is the idle path in docs/session-model.md. high
Minor L1+L6 ViewportReducer.apply (lib/sessionViewport.ts) drops skill_attached (parsed/allowlisted, never painted). Plan emits that event at turn start before the model. Cold hydrate=tail prefers a non-empty stream sample over the Blob head (no merge), so a short in-flight turn’s kind-7 row is in the sample window and then discarded. sessionViewport.test.ts parses the event and never asserts reducer output. 1. /create-plan (or any attach) then a few hundred text_deltas. 2. Refresh GET …/stream?viewportVersion=1&hydrate=tail. 3. Sample includes skill_attached + text; reducer keeps assistant text only. 4. Snapshot replace:true has no skill_attached row; Wasm never shows Skill attached: create-plan. Envelope attachedSkills is often still the previous turn (running PATCH is turn id/status only). Defender: plan waived exact history; live post-H0 events still forward skill_attached; Blob head may have the role. Fails for the short-turn case: H0 < 2048 so the event is in the recent window (not “earlier omitted”), and choosing stream_tail throws away a head that might have held the row. Live events after H0 do not replay start-of-turn attaches. high
Minor L5 GET /api/sessions/:id/viewport copies the SSE stream maxDuration = 1800 (app/api/sessions/[id]/viewport/route.ts). Work is a 5 s viewportWait around one Blob read() that is not abort-linked (docs/harness-limits.md). After the JSON 200, a hung blob.read keeps the Node event loop non-empty. Authenticated client issues concurrent JSON viewport GETs while Blob GET hangs. Each isolate can remain alive until the 30-minute Function ceiling instead of dying near the 5 s recovery budget. Stream routes need 1800; this route does not. Defender: hang is a Blob brownout, already listed as residual. Fails: maxDuration is the kill ceiling for this new route; copying 1800 turns a documented late-read into a 30-minute concurrency amplifier. Vercel Node stays up while the event loop has pending work. high
Nit L2 ViewportStreamDecoder snapshot parse (lib/viewportStreamProtocol.ts) accepts any typeof sessionId === 'string'. runId is sanitizeTurnRunId; rows/carriers are re-sanitized. sessionId is not isRedisSafeOpaqueId. Hostile SSE block with sessionId that is not Redis-safe. Decoder returns it. #960 that keys restore/PUT off snapshot.sessionId (instead of the request id) adopts a non-opaque id. Defender: same-origin encoder; #960 can ignore the field. True today. The eighth-review allowlist lock already treats this parser as the client contract; sessionId is the one identity field left unchecked. high

Residual risk

Attack did not re-breach C16 cancelled/failed getReadable, implicit GET v1 without a selector, H0-before-viewport_state, completed 0-delay drain, guessed resumeIndex: 0, skipStream cursor minting, T9 sample origin, decoder extra-key/cwd allowlisting, or negotiated POST failClosed interpolation. Cold recovery remains best-effort: a burst during the sample window is skipped (gap:true); one Blob read() materializes a whole ≤8 MiB object before the byte check; blob.read / SDK getInfo are not abort-linked so late work can finish after the deadline (documented in docs/harness-limits.md). getReadable is treated as one stored frame per read() with no parseSseChunk rest-buffer — coalesced/split chunk bytes skip a batch or fatal UTF-8 closes live with STREAM_UNAVAILABLE. stillOwned runs only on the cold path before releasing the snapshot, not during the live tail (same TOCTOU as today’s attach), and is itself 1 s-bounded. Stored done/error close the iterator without viewport_end; #960 must treat those and stream EOF as terminal. Status-probe timeout (1 s) then treating the run as non-hang-class is the same race as bodyForRun. Explicit GET startIndex=0 is still origin replay (documented, not the default). No production HarnessHost flip — #924 stays open.

Merge guidance

PASS WITH NOTES: safe to merge from this attack. The Minors should land in this PR (JSON/readViewportHead gap:false on a successful scoped parse; paint skill_attached in ViewportReducer; JSON viewport maxDuration near the 5 s budget, not 1800) so #960 does not inherit a lying gap flag, missing kind-7 rows on short turns, or a 30-minute Function pin. The Nit is a one-line decoder bind.

What was not attacked

Live Workflows getReadable / getTailIndex / getInfo against origin Production; Vercel Blob byte-range behavior; DO runner; Gateway; Wasm paint beyond reading int/viewport-read.int.test.ts (not re-executed here); #960 host wiring.

Adversarial review on PR #963: successful Blob heads inherited
emptyViewport's gap:true; ViewportReducer dropped start-of-turn
skill_attached; JSON viewport copied the SSE 1800s maxDuration.
Decoder now requires a Redis-safe snapshot sessionId.

btipling commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

Follow-up on the tenth adversarial review (PASS WITH NOTES): landed in d5debb6.

  • Minor: successful scoped Blob heads now set gap:false (unavailable/corrupt still gap:true). JSON GET /api/sessions/:id/viewport no longer lies hasEarlier:false + gap:true.
  • Minor: ViewportReducer paints start-of-turn skill_attached as a kind-7 row (Skill attached/detached: …) and does not concatenate following text onto it.
  • Minor: JSON viewport maxDuration is 15 (5 s recovery budget), not the SSE 1800 s attach ceiling. Unit-locked.
  • Nit: decoder requires Redis-safe sessionId on snapshots.

tsc --noEmit green. Targeted viewport files: 64 passed (was 63 on HEAD 54abaf9; +1 maxDuration pin; other new assertions folded into existing rows).

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #963

Verdict: PASS WITH NOTES
Repo: btipling/invincible
Scope: mainfeat/a5-bounded-tail-read @ d5debb6 · 23 files · opt-in bounded viewport read + snapshot-first SSE (plan #959), eleventh pass
Lenses run: L1, L2, L5, L6, L7, L8 (skip: L3 no production host/Wasm/UI flip; L4 no CI/wasm/deploy files; L9 no palette/DOM chrome)
AGENTS.md read: yes (also docs/feature-divide.md, SECURITY.md, plan #959)

Prior reviews (81ad3fed5debb6) fixed implicit GET v1 origin replay, cancelled/failed getReadable, H0-before-SSE, completed 0-delay drain, guessed resumeIndex:0, T9 sample origin, skipStream cursor minting, decoder snapshot allowlisting, negotiated POST failClosed interpolation, JSON gap:false on a successful head, skill_attached paint, JSON maxDuration, and Redis-safe snapshot sessionId. Those survive re-attack at d5debb6.

Findings

Sev Lens Finding Break scenario Refutation attempt Confidence
Minor L8+L1 Living protocol docs (docs/agent-stream.md record table) never say a stored done/error closes the iterator with no viewport_end. Code in lib/agent/viewportStream.ts · live loop if (event?.type === 'done' || event?.type === 'error') return. EOF without those events does synthesize viewport_end from run.status. 1. Negotiated POST/GET; producer writes a normal done chunk. 2. Server yields turn_event{done} and closes. 3. #960 (or any EventSource-style consumer) waits for viewport_end to clear Busy/Stop. 4. Stream is already EOF; Busy hangs until Function maxDuration 1800s or abort. The first viewportStream.test.ts row already ends on turn_event only; the completed-drain row ends on viewport_end because it never stored done. Defender: plan said keep stored done/error distinct from synthetic status; int consumer already breaks on reader.done. True, and merging them would lie about cancelled (error: Request cancelled.failed). The hole is the living contract: #960 has no in-tree sentence that EOF + done/error are terminal. Residual of earlier passes never promoted this to a finding. high

Residual risk

Attack did not re-breach C16 cancelled/failed getReadable, implicit GET v1 without a selector, H0-before-viewport_state, completed 0-delay drain, guessed resumeIndex: 0, skipStream cursor minting, T9 sample origin, decoder extra-key/cwd/sessionId allowlisting, negotiated POST failClosed interpolation, JSON gap, skill-row paint, or JSON maxDuration. Cold recovery remains best-effort: a burst during the sample window is skipped (gap:true); one Blob read() materializes a whole ≤8 MiB object before the byte check; blob.read / SDK getInfo are not abort-linked so late work can finish after the deadline (documented in docs/harness-limits.md). getReadable is treated as one stored frame per read() with no parseSseChunk rest-buffer — consistent with Workflows 4.8.4 chunk/startIndex semantics (one write() of formatTurnSse per index) but a world that coalesced/split bytes would skip a batch or fatal UTF-8-close live with STREAM_UNAVAILABLE. stillOwned runs only on the cold path before releasing the snapshot, not during the live tail (same TOCTOU as today’s attach), and is itself 1 s-bounded. Status-probe timeout (1 s) then treating the run as non-hang-class is the same race as bodyForRun; envelope turnStatus other than cancelling is collapsed to running for cold.status and only matters when that probe times out. Explicit GET startIndex=0 is still origin replay (documented, not the default). createViewportRunReader.nextIndex still constructs getReadable({startIndex:-1}) (SDK: last one chunk) and cancels the data side; getTailIndex is getStreamInfo. No production HarnessHost flip — #924 stays open.

Merge guidance

PASS WITH NOTES: safe to merge from this attack. Land the Minor in this PR so #960 does not inherit an unstated close rule: document that stored done/error are producer-terminal and do not emit viewport_end; consumers must treat those events, viewport_end, viewport_error, and reader EOF as terminal. Do not synthesize viewport_end after done (cancelled inject is an error event, not failed).

What was not attacked

Live Workflows getReadable / getTailIndex / getInfo against origin Production; Vercel Blob byte-range behavior; DO runner; Gateway; Wasm paint beyond reading int/viewport-read.int.test.ts (not re-executed here; int-durable is green on this SHA); #960 host wiring.

Adversarial review on PR #963: living protocol docs did not say a stored
done/error closes the iterator with no viewport_end, so a #960 consumer
waiting on viewport_end would hang Busy after a normal turn. Document
the close set (done/error, viewport_end, viewport_error, reader EOF)
and unit-lock that error/done do not synthesize viewport_end.

btipling commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

Follow-up on the adversarial review (PASS WITH NOTES): landed in 2ba7691.

  • Minor: living protocol docs now state that a stored done/error is producer-terminal and does not emit viewport_end. Consumers must treat those events, viewport_end, viewport_error, and reader EOF as terminal. Cancelled inject stays an error event (not synthetic failed). Unit-locked in viewportStream.test.ts.

vitest run --project default lib/agent/viewportStream.test.ts (16) green.

@btipling
btipling merged commit f167422 into main Sep 7, 2026
3 checks passed
@btipling
btipling deleted the feat/a5-bounded-tail-read branch September 7, 2026 18:14
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

1 participant