fix(readers): bound the record reads over agent-writable trees - #7651
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Root-cause fix at the right layer: one shared bounded reader with an explicit per-site skip/abort judgement, made regression-proof by an executable audit. Suggestions
[DESIGN-REVIEWED] 2e1e80e |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All counts verified. The change is a derived fix (issue #6345, prior PRs #6312/#7297) with two declared deferrals; the greps confirm First-Principles-Verdict: CONCERNS
What this change shipsIntent: stop one crafted newline-free line in an agent-writable log from OOMing the gateway — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 2e1e80e |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI traced the framing state machine ( No findings. [OPUS-REVIEWED] 2e1e80e Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/members.py:529 -- invalid UTF-8 triggers False positive or not applicable? A repository writer can comment: |
|
Round 1 dispositions, all four findings ACCEPTED and fixed in BLOCKING -- I did not take the suggested remedy of reverting the conversion, because that leaves the unbounded allocation in place; reverting trades a data-loss bug for the OOM bug the PR exists to close. Instead the site now uses the same checked-reader shape already used for Regression test FINDING -- FINDING -- FINDING -- FINDING -- Also rebased onto |
b04c306 to
a970469
Compare
|
Round 2 dispositions: both BLOCKING findings ACCEPTED and fixed in They turned out to share one root cause, so they are fixed at the chokepoint rather than patched separately. The strict readers had inherited their decode and boundary semantics from the skip reader, and those are only safe for a caller that counts or displays. An abort caller PERSISTS what it read, so any silent transformation of a record is written back:
The fix gives the strict readers one guarantee -- a record they yield is exactly the record on disk -- and makes every other outcome stop the read:
Verification. Six new tests. Two are site-level and cover exactly the consequences named above:
235 targeted tests pass; flake8, isort, black and mypy clean on the changed files; One related thing left deliberately unchanged, stated so it is not mistaken for an oversight: |
a970469 to
01b6b08
Compare
01b6b08 to
edce829
Compare
|
Round 3: all five reviewer lanes are non-blocking on First Principles Watch -- the scanner claims more than it detects: ACCEPTED, fixed. Correct, and it was my overstatement. The comment said a new unbounded reader "cannot land unnoticed" when the scanner only matches GPT --
At those nine,
The alternative I considered and did not take: a fifth reader (skip posture, strict decode, skip the record it cannot decode) routed at those nine sites. That is better than both Recorded locally as a backlog item either way, together with the five whole-file readers above. |
edce829 to
0db87be
Compare
0db87be to
41ed302
Compare
|
Round 4: the BLOCKING finding is ACCEPTED and fixed in
I want to be explicit that this is NOT the same finding twice, because it touches the same theme as round 2. Round 2 offered two remedies -- "preserve bare-carriage-return boundaries OR fail closed when a chunk contains one". I took fail-closed. This round shows what that choice costs at a site whose caller treats the abort as a no-op and continues. That is legitimate narrowing after seeing the consequence, not a demand to undo a remedy the same lane previously required, so it gets fixed rather than escalated. Fix: preserve the boundaries in the shared core, which is the option I should have taken first. Three properties worth naming, each with a test:
The change SUBTRACTS surface: Verification. 236 targeted tests pass, including the 32 pre-existing
flake8, isort, black and mypy clean on all changed files; Separately: the red |
4a0021e to
4e50c68
Compare
4e50c68 to
cc745f3
Compare
|
Round 9 is fixed in Why the structure changed at round 9 rather than round 8I argued last round that rounds 6, 7 and 8 were "one property approached three times" and that replacing the mechanism would end it. Round 9 falsified that, and it belongs on the record as falsified rather than replaced with a fresh argument for patching:
Rounds 7, 8 and 9 are not three properties, and they are not one property either. They are three consequences of ONE design decision. A trailing carriage return must be HELD rather than split, because its line feed may be in the next read -- and the old reader made cap and drop decisions by reading that same raw buffer. Framing state and policy state were entangled in one loop, so every framing subtlety surfaced as a policy bug, in a different direction each time. A fourth patch would have been the fourth direction, not the last one. What the separation is
None of rounds 7, 8 or 9 is expressible in that shape, because the only things crossing the boundary are a whole record and a marker. There is no buffer for policy to miscount, no carried tail for it to miss, and no buffer for it to clear. Two things worth flagging in the design: The
TestsAll three findings are pinned so the new structure demonstrably preserves the old fixes rather than being assumed to:
Each layout is minimal and every part of it is load-bearing; the docstrings say which part does what, because these shapes are not obvious and a future edit that "simplifies" one silently stops testing anything. Round 9's needs a leading carriage return (so the first full read splits and a tail is carried), a tail of exactly cap (so it passes the unterminated-tail check), and a following full read ending on a held carriage return (so the buffer is over-cap AND ends on the terminator being erased). 210 tests pass across the affected files including Bare carriage return support is retained. I offered dropping it as the aggressive option and argued the contract for it is weaker than this description claims. That was not taken: the description's argument stands unless a maintainer overrules it, so universal-newline boundaries are unchanged and a record still begins and ends exactly where the text-mode iteration this replaces put it. My line stands, and the span it applies to is now the new one: a blocking finding inside |
cc745f3 to
f4d0e77
Compare
|
Two reds on My perf test was the flake, and my earlier reasoning for it was backwards
When I introduced that ceiling I explicitly rejected a growth ratio, on the grounds that "at these absolute times runner noise can move a ratio". That was backwards. An absolute ceiling encodes the speed of the machine that measured it, so it is the environment-sensitive choice; a ratio cancels the machine out, because both measurements pay the same constant factor. I had the robustness argument exactly inverted, and CI billed me for it. Rewritten as a ratio, with each piece there for a stated reason:
Re-verified by mutation, because a perf test that cannot fail is decoration: re-slicing per record fails it at 6.63x, and the subtler separate- The other failure is a test-isolation defect, and it is the second of its kind
What I can state with evidence: this diff touches neither The mechanism is consistent with that reading: What I have NOT done is identify the polluting test, because reproducing it needs that shard's particular 20,000-test composition and I could not narrow it by inspection. Rather than keep guessing, the perf fix above produces a fresh full run over the same shard composition, which settles whether it is deterministic. If it recurs on Not patching it here either way: it is not this diff's defect, and guessing at a fix for a failure I cannot reproduce would be worse than leaving it visible. 210 targeted tests pass. flake8, isort, mypy, black and sync-io gates all clean before push -- gated on an explicit flag this time, after I pushed a head earlier tonight whose lint had actually failed because my |
|
Round 10: the finding is REAL, I measured it, and I am NOT patching it -- it is inside Measured, not relayedI measured peak allocation with
Three things follow, and two of them contradict things I had believed: 1. It is a real regression, +1x cap over main. I first assumed the peak was inherited, since the shape looked the same. It is not: main reads ONE line per 2. My docstring's "roughly twice the cap" was never true -- not even of main. I wrote that claim and repeated it in the description. Measured, main is 3x and this branch is 4x. That is a documentation defect regardless of which option below is chosen. 3. The suggested remedy restores parity with main exactly, but it is not a one-liner. That I also tested my own idea before recommending it -- switching the buffer to a Why I am escalating rather than applying it
I will not decide this one either, but I will say plainly what I think, since the count is now the argument: this does NOT look like another direction of the framing-versus-policy entanglement that rounds 7, 8 and 9 were. That was about WHICH records get emitted. This is a memory constant factor in how much is read, it is present in every revision of this code including the first, and it has a bounded fix in one expression. On that reading it is a genuine new finding rather than the class recurring -- but I said I would stop, so the call is yours. OptionsA. Take the remedy and relax both spy assertions to B. Correct the documented bound to the measured ~4x and change no code. Zero risk, and it fixes the defect this PR actually introduced -- the false claim. Cost: keeps the +1x regression over main, roughly 516 MB transient at the 128 MiB cap. C. Get to a true 2x. Neither main nor this branch has ever been 2x, so this is new work, not a restoration -- most likely Not an option, so nobody spends time on it: lowering Whatever you choose, the docstring and the pull request description both need the measured number, and I will fold in the non-blocking wording finding at Everything else on this head: 40 checks pass, no other failures, no unresolved review threads. |
`for line in handle` asks for bytes up to the next newline, so one crafted newline-free line is a single allocation the size of the whole file. PR #6312 closed this for the trash manifest and PR #7297 for session_digest; this converts 15 of the 16 remaining sibling readers over agent-writable trees. Promotes the two postures into jsonl_util, which already owns the matching bound on the write side: bounded_records skips an over-cap record (read-only, degradable) and strict_records aborts (output feeds a rewrite or a durable decision). session_digest's three readers move onto the shared implementation rather than keeping a private copy of it. Skip was already the contract at every skip site -- each one already wrapped `json.loads` in `except ValueError: continue`, so a malformed record was always discarded. The abort sites are the ones that needed judgement, and they are exactly the readers whose output feeds a durable write: members.read_activity feeds an append/suppress probe, so an unreadable log now declines to append rather than risk a duplicate, and subagent_cost's sample read feeds compact_cost_log, which os.replace()s the log with what it parsed -- a skipped record there is a permanently deleted one. One site is deliberately NOT converted. snapshot's notification merge copies records into a second durable file, which makes it the only site here where reading faithfully is not the whole contract: the bytes must also be valid for the DESTINATION. Four review rounds on this PR each found one more property of that unwritten contract (boundary, key type, framing, encoding), so it is being specified up front in its own change instead. It is filed as the bug it is -- an invalid-UTF-8 record empties live notification history -- and the scanner records it as a known deferral rather than going quiet: #7771. A scanner test makes the audit executable: every remaining handle iteration must be converted or listed with a reason (kernel /proc pseudo-files, sel.py's audit log which security._CREW_SECRET_LEAVES fences from agent file tools, and the deferred merge above). An at-cap record ending CRLF is now accepted rather than refused by one byte. That reverses a pin PR #7297 made deliberately, whose stated cost was that buying the byte back would cost the reader its single invariant, that a return shorter than cap+1 is a whole record. The shared reader has no such invariant to lose: it already defers a trailing carriage return across reads, since it must not split a CRLF whose line feed has not arrived. Left refused it suppressed a real participation entry in members.read_activity. Refs #6345
f4d0e77 to
2e1e80e
Compare
|
Round 10 is fixed in The fix. Each read is now bounded by what the carried record has left -- Both spy assertions are relaxed to Three false claims removed. The A verification result worth reporting, because it changes what one test is worth. After the fix I re-ran the mutation check on the per-piece body cap, and The guard itself is not lost -- disabling the per-piece check still fails I would rather report that than let a test sit there implying coverage it no longer provides. Also folded in, since it had been raised twice and the ruling asked for it: 210 targeted tests pass. flake8, isort, mypy, black and sync-io all clean before the push, gated on an explicit flag. |
bolichen97
left a comment
There was a problem hiding this comment.
Reviewed via parallel subagent audit: diff matches description, CI fully green, no blocking findings, no unresolved threads.
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
Problem / Motivation
for line in handleasks the handle for bytes up to the next newline. It carries nolength bound, so a single line with no newline in it is materialised in one allocation
the size of the whole file. That is only a bug when the writer is not trusted -- and for
these readers it is not: every tree involved sits under KiroCrew's data home, the kiro
home, or the crew home, and
security.is_sensitive_pathfences only the enumeratedleaves in
_CREW_SECRET_LEAVES, so an agent's own file tools can write the rest.PR #6312 closed this for the trash manifest readers and PR #7297 for
session_digest'sthree transcript scanners. #6345 is the audit of the rest, and it asks for a specific
thing: not a sweep, but a shared bounded reader plus a per-call-site judgement of
whether an over-cap record may be skipped or must abort the read.
This PR delivers that audit. Enumerating the sites turned up two that the issue's own
grep (
for line in (fh|f|handle)) had missed, because they spell the loop variabledifferently:
history_projection.py(for raw in handle) andsel.py:2817(
for raw_line in src_f). The full set is 27 sites, not 26.Of those, 10 are deliberately left alone with evidence (kernel
/procpseudo-files andthe fenced audit log), 15 are converted here, and one is deliberately deferred to
#7771 -- the snapshot notification merge, for reasons set out below.
Why it matters
The reachability is mundane. Opening the usage dashboard reads the token shards.
Expanding a storage row reads a transcript. Restoring a snapshot reads the live
notifications file. A planted or corrupt multi-GB newline-free line turns any of those
into a file-sized allocation inside the gateway process -- not a degraded panel, an OOM
that takes every session on the host with it.
Two of the converted sites are worse than a crash, because their read decides a durable
write:
members.read_activityfeeds thededupe_sessionprobe inrecord_activity, whichdecides whether to append a participation entry or suppress it. A skipped record
reads as "no prior entry", so the log gets a duplicate, and the participation counts
that drive trigger generation and
select_crewrouting are inflated.subagent_cost._read_sampleshas two consumers, and the second one is the dangerousone:
compact_cost_logparses the log and thenos.replaces it with what it parsed.A skipped record there is not a lost reading, it is a permanently deleted one.
What changed (motivation -> approach -> change)
Symptom: one hostile line, one file-sized allocation. Root cause: the reader trusts the
writer to insert newlines. Change:
jsonl_util-- which already owns the matching boundon the WRITE side (
rotate_jsonl_at) -- gains the read side, andsession_digest'sprivate
_bounded_linesis promoted into it so there is one implementation rather thantwo copies.
A file's rotation cap does not bound one RECORD, which is why the write bound was not
already enough: rotation only fires when the writer next appends, so a crafted line
lands whole before any rotation sees it.
Four functions, two postures by two forms:
bounded_recordsSKIPS an over-cap record, drains its tail, and logs one aggregateddebug line. For a read that is read-only and degradable.
strict_recordsABORTS by raisingOversizedRecord, and deliberately does NOT drain:the caller is abandoning the read, so walking a multi-GB line to its end would hand
the hostile file the cost the cap exists to deny it.
bounded_raw_records/strict_raw_recordsare the undecoded twins. The bounded twinhas one caller:
history_projectionalready iterated a binary handle and prefilters onbytes (
b'"file_changes"' not in raw) before parsing, so routing it through thedecoding form would make it decode every record to run a filter that rejects most of
them. The strict twin has no external caller today -- it is what
strict_recordsisbuilt on, and the site that wanted raw strict bytes directly is the deferred merge.
The module docstring says so, rather than implying a caller that is not there.
The cap is in BYTES and the handles are opened binary for that reason. A character cap
is not a memory bound: one astral code point is four bytes of
strunder PEP 393, so a128 MiB character cap admits half a gibibyte of resident text. The reverse holds too --
UTF-8 spends at least as many bytes per code point as CPython's widest string
representation -- so an N-byte read caps the decoded
strat N bytes.One cap serves every caller, sized for the largest record shape any of them reads (a
transcript record carrying a whole conversation turn):
MAX_IMAGE_BYTES_PER_MESSAGEis64 MiB, which base64-expands to ~85 MB, and the largest record measured on a live
install is 77,920,032 bytes. 128 MiB is the smallest round value clearing that. Every
other shape here -- token rows, telemetry export cycles, ~150-byte member activity
entries -- is orders of magnitude smaller, so the shared cap never undercounts them. A
per-format cap would bound each tighter, but the memory that matters is the peak of ONE
record, and a cap below a format's real ceiling buys nothing while risking a silent
undercount. This is also why it is not
_MANIFEST_RECORD_CAP(8 MiB): that wouldtruncate the biggest real sessions.
Peak memory, measured rather than reasoned about
An earlier revision of this branch documented the reader's peak as "roughly twice the
cap". That was wrong, and it was wrong about main too. Measured with
tracemallocata 4 MiB cap, on an input that forces a carried tail plus a full read:
_bounded_lines(PR #7297)The 4.03x was a real regression of +1x cap over main, and it was mine. Main reads one
line per
readlineand keeps no carried buffer; this reader must accumulate across reads,because a
\r\ncan straddle a read boundary and a bare\ris a boundary. Each readthen asked for a full
capregardless of what the tail already held, so a nearly-fullbuffer and a full-size read added up.
Fixed by bounding each read to what the carried record has left:
readline(max(2, cap + 2 - len(buf))). That restores exact parity with main.cap + 2is a floor, not a tidy constant. A legal at-cap record ending in a CRLF iscap + 2bytes, so a buffer that could only ever holdcap + 1could not assemble oneand would refuse it -- which is a bug this PR already fixed once. Two spy assertions that
pinned
limit <= cap + 1are relaxed tocap + 2for that reason, one of them intest_session_digest.py, which is main's. What those tests exist to pin is that no readis UNBOUNDED, and that is unchanged; the exact constant was incidental to it, and both
assertions now carry the derivation in a comment so the number does not read as arbitrary.
For the record, one fix that looks obvious does not work: making the buffer a
bytearrayso
+=extends in place instead of allocating the concatenation. Measured at 4.03x,unchanged. The dominant terms are the buffer and the chunk, not the concatenation.
The site audit, and what each site got
usage.pyx7,telemetry.py,backfill.pyx2,history_search.py,history_projection.py,stub.pybounded_records(13 sites)members.py,subagent_cost.pystrict_records(2 sites)snapshot.pyx2notifications.jsonlhandlers_system.pyx3,platform_compat.pyx3,acp/runtime.py,sandbox.py/proc/meminfo,/proc/net/dev,/proc/locks,/proc/<pid>/status,/proc/<pid>/mountinfosel.pyx2security_events.jsonl/.d_CREW_SECRET_LEAVES-> unchanged (2 sites)Every skip-safe site already did
try: json.loads(line) except ValueError: continue, soskip is not a new posture there -- it is the posture the site already had, now reachable
for length as well as for syntax.
One site moved from skip to abort during review, and the reason is worth recording.
The first revision classified
subagent_cost.pyskip-safe by tracing_read_samplestoread_learned_cost, a read-only p90 aggregation. It has a SECOND consumer,compact_cost_log, described above. GPT 5.6 review caught it. The lesson is that "doesthis feed a rewrite" has to be answered by enumerating ALL consumers of the reader, not
the first one; a single-consumer trace is how a skip/abort call goes wrong.
Why the notification merge is NOT in this PR
It was converted here, and then backed out on the conductor's ruling after four
successive review rounds each found one more defect in it. That history is the argument,
so it is on the record rather than summarised away:
json.loads(raw).get("ts")raisedAttributeErroron non-object JSONtsraisedTypeErroron set insertEvery other site in this audit is a CONSUMER: it reads, counts, displays, or decides,
and "hand back exactly what is on disk, or refuse" is a complete contract that the
reader alone can satisfy. The merge is a PRODUCER -- what it reads becomes the content
of a second durable file -- so the bytes must also be valid FOR THE DESTINATION. That is
a second contract, and nobody wrote it down, which is why it was being discovered one
review round at a time.
Four rounds is one missing specification found four times, not four separate mistakes,
and patching a fifth property would have traded another review round per undiscovered
invariant. So the merge is filed as the bug it actually is -- an invalid-UTF-8 record
empties live notification history -- with the write-side contract spelled out up front:
#7771.
snapshot.pyis unchanged from main here, so this is not a regression, it is thepre-existing state.
The audit scanner lists it in
_DEFERRED_READERSwith that reasoning, so the deferral isrecorded in an executable place rather than only in this description.
Two smaller things the conversions forced
backfill.py's no-follow open is split into a shared_no_follow_fdguard with text andbinary wrappers, so the O_NOFOLLOW rationale is stated once and cannot drift.
An at-cap record ending CRLF is now accepted rather than refused by one byte. This
reverses a pin PR #7297 made deliberately, and the maintainer should see it as a
choice rather than a cleanup. That pin's stated cost was that buying the byte back
"would cost the reader its single invariant (a return shorter than cap+1 is a whole
record)" -- true of the old reader, which decided over-cap straight from one
readline(cap + 1)length. The shared reader has no such invariant left to lose: italready defers a trailing carriage return across reads, because it must not split a
CRLF whose line feed has not arrived. So the byte is free, and the record is legal by
the cap's own definition -- its body IS cap bytes. Left refused, it suppressed a real
participation entry in
members.read_activity. One commit to revert if you disagree.What the abort site does instead of skipping
read_activitykeeps its public signature and its documented non-raising contract -- 25existing tests depend on it -- and becomes a thin wrapper over a new
_read_activity_checkedthat also returns whether the read was complete. Only the onecaller that WRITES honours the flag, and it fails closed by declining to append. That is
the same
Falsethe function's existing blanket handler already returns for any otherread failure, so no caller learns a new failure mode.
subagent_costuses the sameshape: the percentile consumer keeps degrading, and
compact_cost_logdeclines tocompact on an incomplete read.
Tests
161 tests pass across the four affected files, 40 of them added by this PR
(
test_jsonl_util.py+29 including a 3-test scanner class,test_members.py+6,test_subagent_cost.py+4,test_session_digest.py+1 rewritten).Two are behaviourally red against pristine base source, verified by copying the new test
files into a clean
origin/mainworktree and running them there (the cap fixtures useraising=False, the same idiom astest_session_digest'screate=True, so the filesRUN on base and fail on behaviour rather than erroring on a missing name):
test_over_cap_record_refuses_to_append-- base skips the junk line, finds no match,and appends.
AssertionError: must not append when the log could not be read in full.test_compaction_refuses_to_run_on_an_incomplete_read-- base rewrites the log from apartial read.
AssertionError: compaction rewrote the log from a partial read, permanently deleting the record the reader refused. This is the regression test forGPT's finding.
test_every_handle_iteration_is_bounded_or_excusedreports 0 unexcused sites on thisbranch. It cannot be run against base source to produce a matching count, because the
test module's imports (
RECORD_CAPand the reader names) do not exist there andcollection fails -- so that comparison was done by running the scanner logic standalone,
and no base number is claimed from the test itself.
That scanner is the piece I would keep if I could keep only one: it makes the audit
executable instead of a claim in this description. It scans the package for the shape and
requires every instance to be converted, excused with a reason, or listed as a tracked
deferral, so a new unbounded reader cannot land unnoticed and a converted one cannot
regress. A companion test guards the guard -- it asserts the detector still finds the
listed sites, so a scanner that silently stopped matching anything would fail rather than
pass. Detection is by the handle-name vocabulary rather than a fixed window after the
with, because a distance-based match missedsel.py:2522, where the handle is boundseveral lines earlier.
The scanner's claim is deliberately no broader than the shape it detects. A whole-file
read spelled
path.read_text().splitlines()has the same harm and is invisible to it;learn.py, the auto_improvement ledger, and the meetings store each hold one, andlearn.py's feeds a_write_allrewrite, which is the abort-required pattern. Those area different reader shape than #6345 enumerates and are left for a follow-up rather than
swept in here.
Refactoring
session_digestonto the shared reader is covered by its existing tests,which pass unchanged -- including the spy test asserting every read carries a limit and
the handle is never iterated. That is the evidence the promotion is behaviour-preserving
rather than a rewrite.
Mutation-verified on the current reader core, 4/4 killed:
readline(cap)instead ofcap + 1, the boundary terminator check dropped, the drain loop removed, and the strictvariant made to drain its hostile tail. The performance guard is mutation-checked too --
re-slicing the buffer per record makes
test_many_records_in_one_read_scale_linearlyfail with6.722s for 200000 recordsagainst its 1.0s ceiling.
That test's threshold is measured, not guessed, because the first version of it was
WRONG: it compared 4,000 records against 8,000 with a 6x growth bound and passed the
quadratic mutant, since files that small never let the quadratic term show. Re-derived at
200,000 records: 0.10s for the shipped reader, 1.84s with a separate
findperterminator, 8.11s re-slicing per record. An absolute ceiling rather than a growth ratio,
because at these absolute times runner noise can move a ratio.
flake8,isortandmypyare clean on all 14 files. The repo's own gates pass withreal diff scope:
check_black_formatting.py,check_sync_io_in_async.py.stub.pyisunformatted under black, but it is in
.github/black-baseline.txtand the complaint ispre-existing argparse help-string indentation this diff never touches, so it is left
alone rather than graduated out of the baseline.
Manual verification
The cap's floor is the one input unit tests cannot supply, and it is inherited rather
than re-derived: PR #7297 measured it with
wc -Lacross both transcript trees on a liveinstall (30,351 kiro-cli logs, 27 GB) to find the longest record of any kind. Those are
the numbers quoted above. This PR adds a test asserting the constant still clears both
that measurement and the base64-expanded
MAX_IMAGE_BYTES_PER_MESSAGEceiling, so thederivation is checked rather than trusted to a comment.
Everything else is asserted directly: the allocation bound by the spy-handle test, the
boundary by the at-cap / one-over / unterminated-at-cap tests, the byte-versus-code-point
distinction by a 240-byte 60-character record refused by a 200-byte cap, and the drain by
a file that is one line whose tail forges a complete record. A run against a real
multi-gigabyte planted file would add no evidence those do not already give.
Related Issues
Refs #6345 -- the audit it asks for is complete, but one enumerated site is deferred to
#7771 rather than converted, so this does not close it.
Refs #7771 (the deferred notification merge, filed as a data-loss bug)
Refs #6312, #7297 (the two earlier instances this generalises from)
Pattern harvest
Rule candidate: review-prompt
Pattern:
for line in <handle>over a tree any agent can write is an unboundedallocation -- one record's length is attacker-controlled. The reader must pass a limit to
readline, and then the per-call-site question is what an over-cap record does: SKIPwhere the read is read-only and degradable, ABORT where its output feeds a rewrite or any
other durable decision. Four corollaries learned here. The limit must be in BYTES, which
means reading binary, because a character limit is not a memory bound. A file-size
rotation cap does not bound one record, because rotation only fires on the next append.
When a fix claims to close a defect CLASS, the enumeration itself is the risky step: a
grep keyed on the loop VARIABLE name misses instances that spell it differently, so the
audit belongs in a test that fails when a new instance appears, not in a table in a pull
request description. And a reader whose output is WRITTEN BACK to a durable file is a
different problem from one that is merely consumed -- it needs a stated write-side
contract (boundary, key type, framing, encoding) before any code, because otherwise each
property gets discovered one review round at a time.