Skip to content

Commit 202ff8a

Browse files
committed
docs(plan): wt2 root-cause delta + diff-level decade docs for the six bounds
Three explorer passes found wave-1 landings already on dev (77243d9 framework, d1408b9 continuation cap+spill, 034d320 cache caps, a616078 translator budgets), so the campaign narrows to refinements: lidge-jun#841 admission boundary (direct-spill oversized, bounded snapshot read, bounded replay), lidge-jun#847 collector per-call scope + mandatory budget + 502 normalization, lidge-jun#844 incremental frames + typed partial-EOF, lidge-jun#845 NOOP (superseded), lidge-jun#843 fixed-size SHA-256 key identities, lidge-jun#840 ACL timeout-memo release + destination keying.
1 parent 7e679c5 commit 202ff8a

7 files changed

Lines changed: 243 additions & 0 deletions
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# 001 — Root-cause delta: what wave 1 already landed vs what remains
2+
3+
Date: 2026-08-02. Basis: three read-only explorer passes over `codex/wt2-zero-leak-impl` @ `478354ee8` (= dev tip), plus `gh pr diff` for #840-#847. This doc SUPERSEDES the assumptions in `000_plan.md` where they conflict.
4+
5+
## Wave-1 landings (already on dev, do NOT re-implement)
6+
7+
| Commit | What landed |
8+
|--------|-------------|
9+
| `77243d932` | app-owned retained-state byte budget framework (`src/lib/app-owned-memory.ts`: 256 MiB eviction target, 512 MiB worst-case pinned ceiling, category-ordered eviction, re-snapshot-after-evict honesty) |
10+
| `d1408b92f` | Responses continuation hard cap + durable spill (`src/responses/state.ts`, `src/responses/spill-store.ts`) |
11+
| `034d320b8` | byte caps for blob, replay, vision, image caches |
12+
| `a61607894` | translator turn budgets: 2 MiB/tool call, 32 MiB/turn, 32 MiB SSE logical event (`src/lib/translator-budget.ts`) |
13+
| `17faddd24` | benchmark gap closure |
14+
15+
Framework note (`src/lib/app-owned-memory.ts:43`): the budget is an eviction target, not an admission boundary — it runs AFTER an owner allocated, cannot prevent a single oversized allocation, sees only owner-reported bytes, and cannot evict pinned state. Per-store admission caps remain necessary. That is the frame for every delta below.
16+
17+
## True remaining deltas (the actual work of this unit)
18+
19+
### #841 — Responses state: admission boundary, not rejection (refinement, NOT wave-1 redo)
20+
21+
Current: `setResidentEntry()` (`src/responses/state.ts:243`) fully materializes + measures the candidate, inserts it as resident, THEN prunes — an oversized candidate is fully allocated and older UNRELATED residents may be demoted first. Remaining gaps:
22+
23+
1. Oversized candidate (`sizeBytes > 64 MiB cap`) should go DIRECTLY to durable spill and install only its stub — never resident, never demoting unrelated chains. Keep spill (replay availability), do not adopt PR #841's plain rejection.
24+
2. Snapshot input not size-bounded before `readFileSync`/`JSON.parse` (`src/responses/state.ts:453`) — an externally oversized `responses-state.json` is parsed whole.
25+
3. Spill replay materialization unbounded: `readResponseSpill` (`src/responses/spill-store.ts:307`) reads+parses with no replay ceiling and does not charge `storedResponseBytes` (`src/responses/state.ts:666`).
26+
4. `writeBoundedSnapshot` (`src/responses/state.ts:485`) uses JS string length, not UTF-8 bytes, for the 2 MiB/24 MiB limits.
27+
28+
### #847 — tool-argument bounds: two narrow gaps (mostly landed)
29+
30+
Current: translator budget (2 MiB/call, 32 MiB/turn, 32 MiB SSE) covers OpenAI Chat (`src/adapters/openai-chat.ts:801`), streaming+batch bridge (`src/bridge.ts:851`, `:1468`), Responses-to-Chat streaming (`src/chat/outbound.ts:168-198`). Remaining gaps:
31+
32+
1. Non-stream collector `collectChatCompletion()` charges tool args to generic `retained_collectors` scope (`src/chat/outbound.ts:621`, `:700`) — one call can consume nearly the full 32 MiB turn budget instead of the 2 MiB per-call limit. Fix: per-call ownership by stable index/call ID.
33+
2. `translatorBudget` is OPTIONAL in the bridge option type (`src/bridge.ts:136`) — a future caller omitting it gets an unbounded append helper. Make it mandatory (all production callers pass one today).
34+
3. Overflow contract inconsistency: Chat outbound maps translator overflow to 413 `invalid_request_error`; adapter/bridge use 502 `upstream_error`. Normalize to 502.
35+
36+
Decisions (recorded, not silent): keep the shared SSE record ceiling at 32 MiB (PR #847's 4 MiB could reject legitimate large compatible-provider records); keep typed `translation_buffer_limit` overflow (no `arguments.done`, no completed item, no clean Chat DONE — already the bridge behavior).
37+
38+
### #844 — Cursor Connect frames: incremental remainder + partial-EOF (refinement)
39+
40+
Current: declared-length validation at header arrival exists (`src/adapters/cursor/framing.ts:171`), 32 MiB declared / 16 MiB effective caps exist (`src/lib/translator-budget.ts:4`), 1,024-frame flow control exists. Remaining gaps:
41+
42+
1. Concat-first pending handling (`src/adapters/cursor/live-transport.ts:894`, `concatBytes()` at :906-918): every chunk is concatenated with the ENTIRE pending remainder. Fix: complete only the missing header/payload portion incrementally; carry at most one bounded incomplete frame.
43+
2. Partial-EOF (`live-transport.ts:949`): complete frame(s) + trailing incomplete frame settles SUCCESSFULLY and silently discards the remainder. Fix: fail the turn with typed `frame_incomplete` on non-expected EOF when pending bytes remain (after accounting for queued async frame work; expected client-tool cancellation must NOT error).
44+
45+
Decision: do NOT adopt PR #844's flat 32 MiB effective inbound — current 16 MiB effective preserves the copy-overlap budget inside the 32 MiB transport budget.
46+
47+
### #845 — Cursor blob store: NOOP (verified superseded)
48+
49+
`src/adapters/cursor/native-exec.ts` already has: 16 MiB/entry, 64 MiB aggregate, 4,096 entries, 15-min TTL, request-scope pinning with seal/rollback (`:351`), typed atomic admission failures (`entry_too_large`, `pinned_saturation`, `request_pinned_conflict`, `:219`), protobuf error acknowledgement for rejected `setBlobArgs` (`:551`, wire shape `gen/agent_pb.ts:7904`), per-key hydration release (`:537`), app-owned-memory integration. PR #845's only unretained behavior is true access-LRU — a policy nicety, not a leak. Verdict: NOOP with this evidence; no code change. Residual edge (documented, accepted): remote `setBlobArgs` after scope sealing is TTL-protected only; PR has the same limitation.
50+
51+
### #843 — Antigravity replay: fixed-size identities (refinement)
52+
53+
Current: caps exist (10,240 sessions, 256 calls/session, 2 MiB/session, 64 MiB global counted, 64 KiB signature — `src/adapters/google-antigravity-replay.ts:29`), 1h TTL + centralized sweep. Remaining gaps:
54+
55+
1. Outer key retains raw `model`/`sessionId` (`replayKey`, `:57`) and inner key raw function name + canonical args (`functionCallKey`, `:61`/`:70`) — key bytes are NOT counted in `replayBytes`; an attacker-controlled long model/session pair retains unaccounted strings across up to 10,240 sessions. Fix: SHA-256 fixed-size identities with NUL separators for both key classes (PR #843 shape), preserving native `touchedAtMs`, exact deletion accounting, retained-store snapshot, sweeper, and shared-budget call.
56+
2. Transient canonical JSON allocation before admission checks — large arguments produce an unbounded temporary string. Fix: hash streaming/incrementally or pre-check serialized input size before canonicalization.
57+
58+
Decision: keep native TTL-refresh-on-duplicate-observation (PR #843 does not refresh; changing it alters TTL semantics for no leak benefit).
59+
60+
### #840 — Windows ACL memos: timeout release + destination keying (refinement)
61+
62+
Current: success memos already released after rename/confirmed removal (`src/config.ts:120`, `:137`); async writer keys timeouts by destination (`src/config.ts:187`); residual-file retention is fail-closed (`tests/config.test.ts:1536`). Remaining gaps:
63+
64+
1. Sync `atomicWriteFile` hardens the unique temp WITHOUT a destination memo key (`src/config.ts:107-109`) — a timeout retains `required:<unique-temp>` forever even after cleanup removes the temp. Fix: pass `timeoutMemoKey: destination` (matches async).
65+
2. `forgetHardenedSecretPath` (`src/lib/windows-secret-acl.ts:171`) clears only the success set, not timeout state. Fix: ephemeral release clearing `hardenedPaths` + `timedOutPaths` in BOTH namespaces (`required:`/`optional:`), invoked ONLY after proven absence (successful rename, successful unlink, ENOENT, or explicit `existsSync === false`) at `src/config.ts:125`, `:152`, `:211`, `:238` + management-token/tray temp writers.
66+
67+
Guardrails: never clear the stable DESTINATION timeout memo (intentional anti-restall state); retain memos when a residual temp remains on disk; preserve required/optional namespace isolation. Store F is not registered with the framework — registration is out of scope (memos become self-releasing instead).
68+
69+
## Revised landing order
70+
71+
020 (#841) → 030 (#847) → 040 (#844) → 045 (#845 NOOP record) → 050 (#843) → 060 (#840). Order is dependency-free across subsystems; sequence keeps the prep doc's order minus the NOOP. wt3 coordination stands: #847 edits stay in the translator-budget/collector paths, not `service_tier` injection sites.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# 020 — Fix #841: Responses state admission boundary (direct-spill oversized, bounded snapshot read, bounded replay)
2+
3+
Depends on: 001 root-cause delta. NOT a redo of wave-1 (`d1408b92f` hard cap + spill already landed).
4+
5+
## File map
6+
7+
- MODIFY `src/responses/state.ts`
8+
- `setResidentEntry()` (~:243): before `replaceMapEntry()`, when `candidate.sizeBytes > byteCap()`, write the candidate DIRECTLY to spill and atomically install only its measured stub. Never insert the oversized candidate as resident; never demote unrelated residents to make room for it.
9+
- `ensureLoaded()` (~:453): bound the snapshot file read — `statSync` first, refuse (or truncate-refuse with typed error + quarantine) a `responses-state.json` above an explicit ceiling (recommend 32 MiB, above the 24 MiB write bound). Enforce direct-spill/reject for oversized resident rows BEFORE map admission in `loadSnapshotEntry()` (~:301).
10+
- `writeBoundedSnapshot()` (~:485): use `Buffer.byteLength(value, "utf8")` instead of `.length` for the 2 MiB/24 MiB limits.
11+
- MODIFY `src/responses/spill-store.ts`
12+
- `readResponseSpill()` (~:307): reject `payloadBytes` above an explicit replay ceiling BEFORE read/parse (recommend the same 64 MiB as the store cap), typed error `spill_payload_too_large`; the continuation then fails as a structured `previous_response_not_found`-class miss rather than an unbounded allocation.
13+
- MODIFY `tests/responses-state.test.ts` — new regressions (below).
14+
15+
Scope OUT: changing TTL (1h), count cap (1,000), stub/tombstone semantics, Windows ACL/fsync behavior, `previous_response_not_found` wire shape.
16+
17+
## Acceptance + activation scenarios
18+
19+
1. Oversized candidate (sizeBytes > cap) with two unrelated small residents present: candidate lands as spill stub only; both unrelated residents remain resident (not demoted). Activation: test asserting map contents + stub presence + spill file exists; replay of the stub still works.
20+
2. At-cap-minus-epsilon candidate: admitted resident as today. Activation: boundary test.
21+
3. Externally oversized snapshot file (> ceiling): load refuses with typed error, process starts with empty state, no giant parse allocation. Activation: fixture writing a >ceiling `responses-state.json` in a temp config dir.
22+
4. Oversized spill payload on disk: replay rejects typed before read; no unbounded allocation; error surfaces as structured continuation miss. Activation: fixture spill file over the replay ceiling.
23+
5. Multibyte snapshot: entries whose UTF-8 bytes exceed 2 MiB but whose `.length` does not are now correctly excluded from snapshot output. Activation: multibyte fixture + byte-length assertion.
24+
6. Red-green: each new test fails on the pre-fix tree (verify at least #1, #3, #4 red first).
25+
26+
## Regression risks (watch in C)
27+
28+
- Continuation misses if direct-spill breaks same-ID crash consistency or deferred old-generation unlink ordering.
29+
- Stubs must stay NON-evictable in `responseContinuationRetainedStoreSnapshot()` (counted as pinned) or the shared budget spins.
30+
- v1/v2 snapshot compatibility; provider continuation metadata replay.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# 030 — Fix #847: per-call scope in the non-stream collector + mandatory budget + 502 normalization
2+
3+
Depends on: 001 root-cause delta. Translator budgets already landed (`a61607894`); this closes the two narrow gaps and one contract inconsistency.
4+
5+
## File map
6+
7+
- MODIFY `src/chat/outbound.ts`
8+
- `collectChatCompletion()` (~:621, ~:700): open/close per-call ownership by stable tool-call index (fall back to call ID) and charge argument bytes as `tool_args` under that call scope — 2 MiB per call, 32 MiB per turn — including authoritative replacement snapshots (last-write-wins replaces, not accumulates). Today args charge to generic `retained_collectors`, so one call can eat the whole turn budget.
9+
- Overflow mapping: translator/tool overflow in the non-stream Chat path becomes 502 `upstream_error` (matching adapter/bridge), not 413 `invalid_request_error`.
10+
- MODIFY `src/bridge.ts`
11+
- Option type (~:136): make `translatorBudget` mandatory. All production callers pass one today (`src/server/responses/core.ts:2644`); typecheck will catch any straggler — that is the point.
12+
- MODIFY `tests/chat-outbound.test.ts` (or the collector's owning suite — confirm at P) + bridge tests: new regressions (below).
13+
14+
Scope OUT: the SSE record ceiling (stays 32 MiB — recorded decision in 001), routing OpenAI Chat through the shared SSE decoder (nice-to-have, separate unit), `service_tier` paths (wt3's lane), PR #847's 4 MiB/8 MiB numbers (native 2 MiB/call is STRICTER; keep).
15+
16+
## Acceptance + activation scenarios
17+
18+
1. Non-stream collector: a single tool call streaming >2 MiB of arguments fails typed (`translation_buffer_limit`-class) at the 2 MiB per-call boundary — not at 32 MiB. Activation: feed chunked arguments over 2 MiB under the test budget; assert typed overflow, no completed tool call in the collected result.
19+
2. Two parallel calls each under 2 MiB but summing >32 MiB turn budget: turn-scope overflow fires. Activation: two-call fixture.
20+
3. Done-frame authoritative snapshot larger than streamed deltas replaces (does not double-charge). Activation: delta-then-done fixture asserting final charged bytes.
21+
4. Overflow surfaces as 502 `upstream_error` in the non-stream path. Activation: assert status+type on the mapped error (was 413).
22+
5. Omitting `translatorBudget` from a bridge call is a compile error. Activation: typecheck (the negative is structural).
23+
6. Red-green: #1 and #4 red on the pre-fix tree.
24+
25+
## Regression risks (watch in C)
26+
27+
- Mixed index/ID continuation chunks must attach to the same call scope.
28+
- Releasing call ownership too early while the finalized output retains the argument string.
29+
- 413→502 mapping: confirm no client relies on 413 for retry semantics (grep error-mapping consumers in `src/server/`).
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# 040 — Fix #844: Cursor Connect incremental remainder + partial-EOF failure
2+
3+
Depends on: 001 root-cause delta. Header-time validation, 32/16 MiB caps, and 1,024-frame flow control already landed; this closes the concat-first growth and the silent partial-EOF discard.
4+
5+
## File map
6+
7+
- MODIFY `src/adapters/cursor/live-transport.ts`
8+
- Pending-chunk handling (~:894, `concatBytes()` :906-918): replace concatenate-first with incremental completion — append only the bytes needed to complete the current header (5 bytes) then the current payload, leaving at most ONE bounded incomplete frame carried between chunks. Preserve translator reservations, copy-overlap accounting, frame-slot backpressure, and rollback.
9+
- EOF handling (~:949): when the stream ends and a bounded incomplete remainder exists (after queued async `frameWork` settles), fail the turn with typed `frame_incomplete` — today complete-frames-plus-trailing-partial settles successfully and silently discards. Expected client-tool cancellation must NOT produce this error.
10+
- Terminal paths: explicitly release any remaining pending-payload lease on every settle path.
11+
- MODIFY `src/adapters/cursor/framing.ts` (only if the streaming decode helper belongs there — wrap/extend :129, accepting the existing max-payload + reservation callbacks; keep `decodeConnectFrame` semantics for existing callers).
12+
- MODIFY `tests/cursor-framing.test.ts` + `tests/cursor-hardening.test.ts`: new regressions (below).
13+
14+
Scope OUT: raising the 16 MiB effective inbound cap (recorded decision in 001 — PR #844's flat 32 MiB breaks the copy-overlap budget), outbound uint32 framing (`framing.ts:59` stays), header-byte accounting (frame-count flow control defends tiny-frame floods; documented).
15+
16+
## Acceptance + activation scenarios
17+
18+
1. Chunked delivery of one frame split across many small chunks: pending buffer never exceeds one frame + header; byte accounting matches the old concat path's final state. Activation: chunk-size sweep test (1,3,7,64 KiB chunkings) asserting identical decoded frames and bounded high-water pending bytes.
19+
2. Complete frame + trailing partial frame + EOF: turn fails typed `frame_incomplete`; the completed frame was still delivered. Activation: hardening test driving exactly this sequence (red on pre-fix tree — today it settles clean).
20+
3. EOF with only partial header (<5 bytes): same typed failure. Activation: variant of #2.
21+
4. Expected cancellation with pending remainder: no `frame_incomplete`. Activation: cancellation fixture.
22+
5. Oversized declared length is still rejected at header arrival (existing behavior preserved through the refactor). Activation: existing :124 tests stay green.
23+
6. 1,024-frame flood + rollback behavior unchanged. Activation: existing :155 tests stay green.
24+
7. Red-green: #2 and #3 red on the pre-fix tree.
25+
26+
## Regression risks (watch in C)
27+
28+
- EOF must wait for already-admitted async frame work before declaring incompleteness.
29+
- Compressed/end-stream flags and frame order preserved.
30+
- HTTP/2 pause/resume (frame-slot backpressure) must keep working with incremental decode.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# 045 — #845 Cursor blob store: NOOP record (verified superseded)
2+
3+
Date: 2026-08-02. Verdict: **NOOP — no code change.** Evidence class: code-verified on `codex/wt2-zero-leak-impl` @ `478354ee8`.
4+
5+
## Why no change is needed
6+
7+
PR #845's headline items all exist on current dev, in a stronger native form:
8+
9+
| PR #845 item | Current dev |
10+
|---|---|
11+
| 16 MiB/entry, 64 MiB total, 4,096 entries | `src/adapters/cursor/native-exec.ts:79` — same numbers |
12+
| 15-minute TTL | same, :79 |
13+
| Pin every root/step/turn blob advertised by an active request | request scopes with seal/rollback, `:351`; construction pins at `protobuf-request.ts:306`; release on open failure/end/close/cancel/abort at `live-transport.ts:567`, `:665` |
14+
| Evict only expired/LRU unpinned; fail when pinned data leaves no capacity | typed atomic admission failures `entry_too_large` / `pinned_saturation` / `request_pinned_conflict`, `:219` |
15+
| Protobuf error for rejected `setBlobArgs` | `:551` + wire shape `gen/agent_pb.ts:7904` |
16+
17+
Native additions the PR lacks: identity-bearing scope tokens, per-key hydration release (`:537`), provenance classes, app-owned-memory integration, atomic rollback, richer metrics. The only unretained PR behavior is true access-LRU on `getBlob` — a policy nicety, not a memory-safety gap (TTL + byte/count caps + budget eviction bound retention regardless). Not implemented deliberately.
18+
19+
## Accepted residual (documented, matches PR's own limitation)
20+
21+
Remote `setBlobArgs` arriving after request-scope sealing cannot gain a request pin; it is TTL-protected (15 min) only. A request outliving 15 minutes could theoretically lose a late remote blob. PR #845 shares this limitation. If it ever bites, the fix is an ownership contract in `setBlob()`/`handleCursorNativeKv()` — separate unit, not this campaign.
22+
23+
## Verification
24+
25+
Existing suite coverage is extensive (`tests/cursor-blob.test.ts:731-1141`, `tests/cursor-live-transport.test.ts:164`). C-phase of this work-phase = run the blob suites fresh and record green output; no new tests.

0 commit comments

Comments
 (0)