Skip to content

fix(session-render): refuse a managed output the reader would refuse - #72

Merged
andrei-hasna merged 1 commit into
mainfrom
ope15-00066-write-bound
Aug 7, 2026
Merged

fix(session-render): refuse a managed output the reader would refuse#72
andrei-hasna merged 1 commit into
mainfrom
ope15-00066-write-bound

Conversation

@andrei-hasna

@andrei-hasna andrei-hasna commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Row OPE15-00066. Base origin/main @ 6091ba6 (0.4.23).

The defect, reproduced end to end on current main

0.4.23 raised the read bound on managed session-render outputs and left the write unbounded. The two agree only by headroom. Measured on origin/main @ 6091ba6, before this change:

WRITE applied= true conflicts= 0
WRITE exists= true size= 8389886
BOUND= 8388608
GUARD err= ProjectContextError: PROJECT_CONTEXT_INPUT_TOO_LARGE: managed input exceeds 8388608 bytes

The second render of that home does not merely warn — planSessionRender throws:

ProjectContextError: PROJECT_CONTEXT_INPUT_TOO_LARGE: managed input exceeds 8388608 bytes
      at readUtf8RegularFile (project-context.ts:3325)
      at currentFileHash (project-context.ts:3332)
      at observeProjectContextSessionGuard (project-context.ts:691)
      at composeProjectContextSessionRender (project-context.ts:582)
      at planSessionRender (session-render.ts:1573)

Including a render that would have shrunk the file back under the bound. Planning reads the oversized file before it can decide to replace it, so once written the home is unrecoverable through this tool at all — only an rm outside it recovers.

The decision: refuse, at plan time, not truncate and not warn-only

  • Refuse, not truncate. A silently shortened instruction home looks complete to every agent that reads it and is missing directives. A stale home is wrong in a way somebody notices.
  • Refuse, not warn-only. A warning that still writes yields the warning and the unrecoverable file.
  • At plan time, not at the write. planSessionRender writes nothing, so the previous home survives intact and merely stale, and session plan / --dry-run predict the failure. Refusing inside applyPlannedFile would land mid-loop over per-file atomic replacements and leave the home half-new, half-old with a manifest describing neither.
  • Plus a headroom warning at half the bound, which is what answers "a bound that refuses eventually stops every render unless someone is watching". The refusal is a backstop; the warning is the runway. Half is safe because the corpus grows by whole sources: the largest single rendered source measured is 26,564 bytes, so no single addition can jump from silent to refused.

Not one path — the invariant

The 0.4.23 comment already stated the invariant ("these two bounds must not disagree") and implemented one side. The same shape had happened once before: currentFileHash carved out session-render-manifest.json and left the render target on the default. Widening a second path would be the third instance.

So the bound is one exported constant that both the reader and this check read, and the check covers every allowlisted managed output including the manifest — a guard on the entrypoint alone moves the identical wedge onto the manifest.

isSessionManagedOutputRelativePath matches on either path base deliberately: SESSION_MANAGED_OUTPUT_PATHS is workspace-relative, a plan's relativePath is target-home-relative, and for codewith those differ by one segment. A direct string comparison passes for claude and codex and leaves codewith silently unguarded — which the control run confirms, since the codewith arm fails without the fix.

Second item on the row

The comment on SESSION_MANAGED_OUTPUT_PATHS claimed to list "every path in projectContextSessionGuardPaths()". It does not: the guard also hashes the project-context manifest, cache and fragment, which stay at FOREIGN_INPUT_MAX_BYTES on purpose. A maintainer adding a guard path and following that comment promotes a foreign-content path to the 8 MiB bound. Corrected, with the criterion for adding a path stated.

Evidence

Two-sided and demonstrated, not asserted. With only the rejectOversizedManagedOutputs(managedOutputs) call removed and nothing else changed:

Expected to contain: "managed read bound"
Received: "DID NOT THROW"
 2 pass
 3 fail

Both negative controls keep passing in that run — the ordinary render, and the headroom arm that must still apply. With the fix:

 5 pass
 0 fail
 18 expect() calls

Full suite and typecheck on the branch:

 632 pass
 0 fail
 2643 expect() calls
Ran 632 tests across 54 files. [62.16s]     rc=0
tsc --noEmit                                 rc=0

Negative control on the real corpus, rendered into a scratch home (44 live fragments, no live home touched):

SOURCES        = 44
CORPUS BYTES   = 291752
AGENTS.md      = 298164
WARN AT        = 4194304
REFUSE AT      = 8388608
HEADROOM x     = 28.1
MANAGED WARNS  = []
APPLIED        = true conflicts= 0

Is 8 MiB the right number

It is inherited rather than derived — SESSION_MANAGED_OUTPUT_MAX_BYTES aliases SESSION_COMPATIBILITY_MANIFEST_MAX_BYTES, a constant chosen for the manifest. This change does not re-derive it, deliberately. What it fixes is that raising it is now a one-line, single-constant change that both sides pick up; raising one side is what produced this defect.

What I did not check

  • No live instruction home was rendered, applied or written. Every probe used a scratch directory.
  • Whether any fleet sweep uses set -e such that one home's non-zero exit aborts the rest. planSessionRender is invoked once per --tool/--profile from the CLI, so the in-process blast radius of a refusal is one home; the shell driver was not inspected.
  • The pre-existing wedge has no recovery path added here. A home already written past the bound still needs a manual rm. Worth a separate row.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

0.4.23 raised the READ bound on managed session-render outputs to
SESSION_MANAGED_OUTPUT_MAX_BYTES and left the WRITE unbounded, so the two
agreed only by headroom. Measured on origin/main @ 6091ba6: a render emitted
an 8,389,869-byte AGENTS.md at rc=0 with zero conflicts, and every subsequent
planSessionRender on that home threw PROJECT_CONTEXT_INPUT_TOO_LARGE from
observeProjectContextSessionGuard -- including the render that would have
shrunk it back under the bound. Once written, the home is unrecoverable
through this tool, because planning reads the oversized file before it can
decide to replace it.

Refuse at plan time, against the same constant the reader uses. planSessionRender
writes nothing, so the previous home survives intact and merely stale, and
`session plan` / `--dry-run` predict the failure instead of discovering it.
Refusing at the write instead would land mid-loop over per-file atomic
replacements and leave the home half-new, half-old.

Not truncation: a silently shortened instruction home looks complete to every
agent that reads it and is missing directives.

A headroom warning at half the bound is what keeps the refusal a backstop
rather than the thing that discovers the problem. Measured on station01
2026-08-08 the real corpus renders to 298,164 bytes, 28.1x under the bound,
with no warning.

Also corrects the comment on SESSION_MANAGED_OUTPUT_PATHS, which claimed to be
"every path in projectContextSessionGuardPaths()". It is not: the guard also
hashes the project-context manifest, cache and fragment, which deliberately
stay at FOREIGN_INPUT_MAX_BYTES. A maintainer adding a guard path and following
that comment would promote a foreign-content path to the 8 MiB bound.

Regression is two-sided and demonstrated, not asserted: with the refusal call
removed and nothing else changed, 3 of 5 arms fail ("DID NOT THROW") while both
negative controls -- an ordinary render, and the headroom arm that must still
apply -- keep passing.

Agent: Silvanus
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] GO — #72 @ ab82cbb — lens: correctness+blast-radius, reviewer Priscus (1 of 1)

The placement is correct and the guard covers what it claims. No P0/P1 found. Evidence below, claim by claim, MEASURED separated from INFERRED.

FALSIFIABILITY — the regression fails without the fix

Head run: 5 pass / 0 fail / 18 expect() calls. With only the rejectOversizedManagedOutputs(managedOutputs) call removed (warnings kept), same file:

2 pass
3 fail
13 expect() calls

Failing arms, verbatim: (fail) ... codex: a render past the managed read bound is refused before anything is written, (fail) ... codewith: ..., (fail) ... the home stays readable after the refusal, each with Expected to contain: "managed read bound" / Received: "DID NOT THROW". Both negative controls (ordinary render, headroom-warn-still-applies) passed in that same run. This matches the reported 2 pass / 3 fail exactly. Fix restored, worktree clean. Neighboring suites session-render.test.ts + session-apply.test.ts + the new file: 76 pass / 0 fail / 318 expect() calls.

CLAIM 1 — write-time refusal would leave a half-written home: CONFIRMED (code read)

applySessionRenderUnlocked (session-apply.ts:230-263) loops for (const file of plan.files) { applyPlannedFile(...) }, then processes deletes, then applies the manifest LAST. Each applyPlannedFile is a per-file atomic replacement. Apply is not all-or-nothing: a mid-loop throw leaves some files new, some old, with the OLD manifest on disk describing neither state. The stated mechanism for choosing plan-time is real. One nuance the rationale omits: writeSessionSnapshot runs before the loop, so a recovery artefact would exist — that softens the cost of a write-time failure but does not change the argument; the home would still be transiently inconsistent. Plan-time zero-side-effect claim measured: every plan in my probes left home-created-by-plan=false.

CLAIM 2 — the either-base matcher: no over-match on the real population (MEASURED)

I planned a render for every adapter and ran every produced relativePath through isSessionManagedOutputRelativePath:

claude: CLAUDE.md true; .hasna/instructions/01-a.md false; manifest true
codex: AGENTS.md true; manifest true
codewith: CODEWITH.md true (via the endsWith arm — the stated subtlety is real); manifest true
opencode: AGENTS.md true; opencode.json false; fragments false; manifest true
cursor: .cursor/rules/01-a.mdc false; manifest true
antigravity: .agents/rules/01-a.md false; manifest true
qwen: QWEN.md false; aicopilot: AICOPILOT.md false

Adversarial probes: foo/CODEWITH.md -> false, hasna/session-render-manifest.json (no leading dot) -> false. The / boundary in managed.endsWith("/" + normalized) holds; a suffix can only match at a path-segment boundary, and the only extra names it admits are session-render-manifest.json, CODEWITH.md, CODEWITH.override.md — all genuinely managed. No false refusal is reachable from the actual adapter outputs. One wording-level over-reach, non-blocking: every tool's manifest matches, including tools whose runtime has no guard reader (see claim 4), so the error text "every later render refuses to read it" is over-stated for those tools — conservative direction, and their manifests measured 1,841 bytes.

CLAIM 3 — the manifest arm is REACHABLE, and I executed it (MEASURED)

The author said the corpus cannot produce an 8 MiB manifest. True for the corpus, but the arm is not dead code: a source with 9 MB of metadata and 5 bytes of content produced —

THREW: Session render output .hasna/session-render-manifest.json is 9439141 bytes and the managed read bound is 8388608. ...
home-created=false

The entrypoint stayed small; the manifest arm alone fired, at plan time, with zero side effects. No synthetic fixture is strictly owed since the arm is now demonstrated; adding one is a cheap non-blocking follow-up (the input shape is: large source metadata).

CLAIM 4 — untested adapters take the same code path, and cannot wedge (MEASURED + code read)

The guard runs inside planSessionRender itself, so all eight tools pass through it. cursor/opencode/antigravity/aicopilot/qwen map to runtime null in projectContextRuntimeForSessionTool (session-render), so observeProjectContextSessionGuard returns null (project-context.ts:719-733) and no bounded read of their outputs ever happens; session-apply reads are unbounded readFileSync. Measured the sharp case: cursor with an oversized source wrote plan content .cursor/rules/01-big.mdc:8389798 bytes, DID NOT THROW, 0 warnings — and nothing wedges, because no reader refuses it. So the coverage claim is exactly right: the guard covers precisely the set the reader refuses (claude/codex/codewith entrypoints + manifests), no more, no less. The unexercised cursor/opencode/antigravity test arms are therefore acceptable. (Antigravity has its own pre-existing 12,000-char plan-time throw, out of scope.)

CLAIM 5 — headroom warning fires and the arithmetic holds (MEASURED)

The warn test passed at head AND in the fix-removed run — the warning is independent of the refusal, so it is not vacuous. Live corpus, read-only, today: /home/hasna/.codex/AGENTS.md = 291,867 bytes; 44 rendered sources; largest single source 26,564 bytes (08-global-interactive-session-awareness-monitors.md) — the exact claimed number. 8388608/298164 = 28.13x. WARN = 4,194,304. At <=26.5 KB per added source, the corpus crosses the warning threshold >=150 renders before the refusal — no silent-to-refused jump is possible.

CLAIM 6 — the inherited bound: DEFENSIBLE (INFERRED)

SESSION_MANAGED_OUTPUT_MAX_BYTES = SESSION_COMPATIBILITY_MANIFEST_MAX_BYTES was set by the READ side on main (0.4.23, commit 340aeca). This PR's invariant is writer == reader; re-deriving a different writer number would recreate the exact disagreement the PR exists to close. The single-constant aliasing is the mechanism that keeps them equal, and the error message names the constant. Whether 8 MiB is the right absolute number is a pre-existing main-side question, not this PR's defect. Non-blocking.

ONE NEW FINDING — P3, non-blocking

manifestFile.content is JSON.stringify(manifest, ...) computed BEFORE warnings.push(...managedOutputHeadroomWarnings(...)) (session-render.ts:1679-1690), and manifest.warnings references the same array that is then mutated. Consequence: the on-disk manifest of a headroom-warned render omits the warning, while plan.warnings and the in-memory plan.manifest.warnings carry it. I checked consumers: only skippedSources is read from plan.manifest in session-apply, and drift compares file hashes — nothing compares the two surfaces, so nothing breaks. Follow-up: push the headroom warnings before building the manifest so the persisted record carries them.

NOT CHECKED

Full repo suite (only the three session suites above); the CLI-level session plan/apply surface (library-level only); the native-codewith adapter arm specifically; Windows \\ normalization (code read only); behavior on an already-wedged home (code read only: observeProjectContextSessionGuard runs before the new refusal, so a pre-existing oversized home still throws PROJECT_CONTEXT_INPUT_TOO_LARGE at plan — the fix prevents creating wedges and does not cure existing ones, which matches the PR's stated scope).

Verdict: GO. The placement argument survives attack, the matcher is correct on the measured population in both directions, the regression demonstrably fails without the fix, and both rejected alternatives (truncate, warn-only) are rightly rejected for the reasons given.

@andrei-hasna
andrei-hasna merged commit 0fcb054 into main Aug 7, 2026
3 checks passed
andrei-hasna added a commit that referenced this pull request Aug 7, 2026
chore(release): prepare instructions 0.4.24

Carries 0fcb054 (#72): planSessionRender refuses at PLAN time to emit a managed output larger than
the 8,388,608-byte bound the reader will later accept, and warns past half of it. 0.4.23 raised the
READ bound and left the WRITE unbounded, so the two sides agreed by headroom rather than by a check.

Diff is package.json 0.4.23 -> 0.4.24 plus a CHANGELOG entry. No logic.

Defect reproduced on the INSTALLED 0.4.23 before bumping, scratch --target-home only, no live
instruction home touched: session apply --tool codex with a 9,090,014-byte source returned rc=0 with
empty stderr and wrote a 9,090,417-byte AGENTS.md; a subsequent plan carrying a 28-byte source — a
render that would have SHRUNK the home back under the bound — returned rc=1
"PROJECT_CONTEXT_INPUT_TOO_LARGE: managed input exceeds 8388608 bytes". --tool claude does not
reproduce it, correctly: claude splits into .hasna/instructions/ fragments and codex is the
flattening adapter.

Release gates on the branch before the tag: 0.4.24 free (rc=1 E404; control @0.4.23 rc=0);
bun install --frozen-lockfile --minimum-release-age 604800 rc=0 over 158 packages with bun.lock
unchanged afterwards; typecheck rc=0; bun run test rc=0 with 632 pass 0 fail; build rc=0; the guard
test file alone 5 pass 0 fail; the refusal string present once in the BUILT dist/cli/index.js with an
impossible-string control at 0; staged secrets scan 0 hits with a 'version' positive control at 3.
bun.lock's root workspace block carries no version key, so the bump cannot desync the frozen install
— that gate runs after the tag, which is the expensive place to find out.

CI verified on this exact sha rather than on the job label: run 31226323097, head_sha 7a8a12e,
event pull_request, both matrix legs success at the STEP level, and the Test step's own log reads
632 pass / 0 fail across 54 files.

Base resolved from the branch, never from the PR object: origin/main 0fcb054, and
refs/pull/73/merge^1 equals it, so CI tested the tree that lands. merge-tree produced 70d6dae,
identical to the branch head tree.

REVIEW DECISION, RECORDED RATHER THAN SILENT. This mechanical release PR was not sent to an
independent adversarial reviewer. The substance was reviewed GO on #72 by Priscus at ab82cbb with
falsifiability demonstrated — refusal call removed gives 2 pass 3 fail with DID NOT THROW on the
three refusal arms while both negative controls still pass; at head 5 pass 0 fail — and the reviewer
fired the manifest arm the author had recorded as unfirable. This PR adds no logic. Whether
mechanical release PRs need their own review pass is an open fleet question nobody has ruled on; a
silent skip and a reasoned one look identical afterwards, so this is the reasoned one.

Residual, unchanged and tracked as OPE15-00068: an already-oversized home stays unrecoverable
through this tool, because planning reads it before it can replace it. This prevents wedges; it does
not cure one.

Row OPE15-00066.

Agent: Silvanus
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant