feat(sessions): compact report envelopes + read_report tool - #8
Conversation
roughcoder
left a comment
There was a problem hiding this comment.
REQUEST-CHANGES
-
[blocker]
apps/server/src/mcp/toolkits/sessions/handlers.ts:510-558—read_reportresolves the report/list before checking whether the caller may access its thread. That makes unknown/no-report targets returnInvalidInputwhile an existing foreign report returnsreport_not_accessible; the reportId+threadId mismatch has a third distinct response. This contradicts the stated “archived/unknown answer identically to foreign” non-disclosure guarantee and lets callers probe report existence. Authorize the target before report-specific errors where possible, and use one non-disclosing denial for missing/inaccessible report IDs and no-report targets. -
[major]
apps/server/src/mcp/toolkits/sessions/handlers.ts:80-88— pagination calls UTF-16String.slicewhile the API documents offsets/maxChars as characters. A boundary inside an emoji returns unpaired surrogate halves, corrupting the body. Define the units as UTF-16 code units or paginate code points/graphemes; add a Unicode-boundary test. -
[minor]
apps/server/src/mcp/toolkits/sessions/handlers.test.ts:23-142— tests cover only the pure predicate and happy/past-end slicing. Add handler-level coverage for foreign/archived/unknown/no-report indistinguishable denials, reportId/threadId mismatch, andmaxChars0/negative decode rejection. -
[minor]
packages/contracts/src/sessionOrchestration.ts:168— the fallback abstract can cut inside a Markdown code fence. Non-blocking context-quality issue; consider a plain-text-safe fallback or fence-aware truncation.
[nit] apps/server/src/persistence/Migrations/043_ProjectionThreadReportAbstract.ts:1 is a clean migration, but PR #6 and another queued slice also use migration 043. Integration owner will need to renumber/rebase it; no author action needed now.
Verified: null parents are explicitly excluded from sibling reads; result has totalChars/hasMore; tool structs are typed/non-empty. read_session has no other MCP consumer; web/mobile read full reports from projections, and small-report reactor text/error paths remain unchanged. No tests/typecheck were run per review constraints.
|
Thanks — all four findings addressed in the follow-up commit. 1. [blocker] Authorize-first + constant-shape denial — 2. [major] Surrogate-safe pagination — units are now explicitly documented as UTF-16 code units (matching 3. [minor] Tests — added: a sequential-walk test over emoji content asserting no lone surrogates on any page and lossless reconstruction; arbitrary mid-pair offset adjustment; the one-unit-page progress case; 4. [minor] Fence-safe fallback abstract — the truncation helper ( 5. Migration 043 — left as-is per your note; renumbering deferred to merge sequencing. Validation under the shared-machine constraints: |
roughcoder
left a comment
There was a problem hiding this comment.
Delta re-review — 96eca7227
Verdict: APPROVE-WITH-NITS. All four findings are genuinely resolved. I traced every unreadable path and every surrogate boundary case rather than taking the summary on trust; one doc-vs-code mismatch and a few nits remain, none blocking.
Delta only (b2fa963b0..96eca7227). Read + reason, no runs.
1. [blocker] Authorize-first + constant denial — RESOLVED
Six unreadable paths, all routing through the single reportNotAccessible() factory with the same reason and the same id-free REPORT_NOT_ACCESSIBLE_MESSAGE (handlers.ts:81): unknown reportId, reportId/threadId mismatch, foreign thread via reportId, unauthorized named threadId, thread with no reports, and archived/unknown thread. No id-bearing error survives anywhere in readReport — I read the whole function, not just the diff. Ordering is right: when threadId is named, mayReadThread runs before findByReportId, so an unauthorized thread never touches a report row.
Archived is genuinely covered: getShell → getThreadShellById, whose threads query filters archived_at IS NULL, so archived returns None and collapses into the same denial as unknown. The one remaining InvalidInputError (both ids omitted) leaks nothing — it depends on no id existing. requireShell(scope.threadId) does interpolate an id, but that's the caller's own thread.
Residual, acceptable [nit]: the reportId-only path is still resolve-then-authorize, so an existing-but-foreign reportId costs two queries where a nonexistent one costs one — a timing difference, though the response is byte-identical. This is practically unexploitable because reportIds are randomUUID (handlers.ts:510): you cannot probe an id you don't already hold, and holding one implies you saw an envelope. Worth a one-line comment recording that reasoning, since it's the load-bearing assumption.
2. [major] Surrogate-safe pagination — RESOLVED
I checked the edges the fix is most likely to get wrong:
- offset 0 —
start > 0short-circuits, no spurious back-up. Correct: offset 0 cannot be mid-pair. - offset === totalChars —
start < totalCharsshort-circuits; empty body,hasMore: false. No off-by-one. - offset on a high surrogate (pair start) —
isLowSurrogate(cc(start))is false, no back-up. Correct. - end === totalChars —
end < totalCharsshort-circuits; nothing after to split. - extend bound — extension only fires when
end === start + 1, and the guard already establishedend < totalChars, soend + 1 <= totalChars. No overrun. - progress —
maxChars >= 1is schema-enforced, soend >= start + 1wheneverstart < totalChars; body is never empty mid-document.
Pair detection correctly requires both halves (cc(start) low and cc(start-1) high), so a lone surrogate in malformed input doesn't trigger a bogus adjustment.
(c) metadata consistency — verified. offset: start and hasMore: end < totalChars both use the adjusted values, and offset + body.length === end exactly, so the documented "use returned offset + body.length" recipe lands on the next page with no gap or overlap. There is no nextOffset field; making it explicit would be harder to misuse, but the current contract is self-consistent.
[minor] The extend case breaks the documented maxChars contract. handlers.ts:117 can return a page one unit longer than requested (maxChars: 1 over a pair yields 2 units), but the schema comment at sessionOrchestration.ts:217-218 only promises the page "may end one unit short of this." A caller sizing a buffer to exactly maxChars is misled. The behaviour is right — progress must be guaranteed — so this is a comment fix, not a code fix: document that a page may be one unit over when a single unit cannot hold a whole character.
3. [minor] Tests — MOSTLY RESOLVED
The pagination tests assert real invariants, not smoke: the emoji walk checks every page for lone surrogates via code-point iteration and asserts lossless reconstruction, which would fail on both splitting and gap/overlap bugs; the mid-pair test pins the adjusted offset (the easy thing to forget); the one-unit test pins progress. Decode rejections for maxChars 0/-1/16385 and negative offset are real boundary coverage.
The denial-constancy test is weaker than it looks. handlers.test.ts regexes a hardcoded string literal for report-|thread-|${ — patterns a literal could never contain — so it locks in essentially nothing and would not notice a future path constructing its own error. What actually holds the property is the structural argument (one factory, one constant), which is sound today. The PR states this gap honestly, which I'd rather have than an overclaim; when a service-level harness exists, the test worth writing is "two distinct unreadable inputs produce identical errors."
Untested edges: offset 0 and offset === totalChars.
4. [minor] Fence-safe fallback abstract — RESOLVED
truncatedAbstractFromSummary (sessionOrchestration.ts:156) strips a trailing high surrogate before fence handling, then balances fences: drop back to the last opener when one exists mid-body, or append a closer when the summary opens with a fence (so the ellipsis lands outside the block). lastFence > 0 vs >= 0 is correct — index 0 routes to the close-it branch, and -1 is unreachable when the count is odd. Author-provided abstracts pass through untouched, and they're already capped at 1024 upstream.
[nit] Fence detection is a split("```") heuristic, so 4-backtick fences or a ``` inside an inline span can miscount. Best-effort is the right bar for a digest; just noting it isn't a parser.
Other nits
sliceReportBodyhas no guard formaxChars <= 0(handlers.ts:95). The schema rejects it at the MCP boundary and that's tested, but the function is exported and pure; called directly with0it returns an empty body withhasMore: true, which is an infinite loop for a blind pager. AMath.max(1, ...)would make the invariant local.input.threadId as ThreadId(handlers.ts:595) is sound given the early return, but it's a cast rather than narrowing — if that guard is ever moved,undefinedreaches SQL silently.
Migration 043 left as-is per the earlier note; renumbering stays with merge sequencing. Also still true: this branch's handlers.test.ts is part of the five-way add/add cluster (#4, #5, #7, #8, #9), and the #8/#9 semantic conflicts I flagged on PR #9 — the envelope carrying none of #9's structured fields, and findByReportId's SELECT omitting structured_json — are unaffected by this commit.
…ly on bad JSON Addresses PR #9 review feedback: - findings/validation.performed/validation.gaps arrays were unbounded while scalar fields were capped, letting a huge findings array store and return wholesale into a parent's context via read_session. Cap findings to 100 entries, performed/gaps to 50 each, and add a 32KB combined encoded-size cap across findings/validation/recommendation/completionPercent as defense in depth. - A malformed structured_json blob previously failed the whole DB row via strict schema decoding, which would have blocked read_session and thread detail hydration. Decode it leniently instead (new decodeStructuredReportFields helper): parse/schema failures log a warning and are treated as absent, never blocking report access. - Services/ProjectionThreadReports.ts's completionPercent was a plain Schema.Int with no 0-100 bound; it now reuses SessionReportStructured's fields directly so persistence-layer bounds can't drift from the contract. - encodeStructured now writes SQL NULL instead of "{}" when there's nothing to store, keeping "no structured data" and "empty structured data" distinguishable in the column. - Renamed handlers.test.ts to tools.test.ts (it only exercised ReadSessionInput/PostReportInput schema decoding, not any handler), and added a focused decodeStructuredReportFields.test.ts plus size/array-cap cases to tools.test.ts. Migration numbering and #8 untouched, per review instructions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… documented limits (#9) * feat(sessions): document messageLimit bounds, add structured report fields, fix stop_session null result - read_session/post_report tool descriptions now state messageLimit's 0-20 range, 5 default, and 16,384-char per-message truncation (the schema already enforced min/max; only the description was missing it). - post_report accepts optional findings/validation/recommendation/ completionPercent fields alongside the markdown summary, threaded through the command, event, and a new JSON column (migration 043) so read_session can surface them. All additive, no breaking changes. - stop_session now returns { threadId, status } instead of null, which was failing MCP client schema validation ("expected record, received null") even though the stop succeeded server-side. - spawn_session's description documents the SESSION_SPAWN_MAX_CHILDREN cap, and the spawn-limit error message now correctly says archiving (not stopping) is what frees a slot. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(sessions): bound structured report payload size, degrade gracefully on bad JSON Addresses PR #9 review feedback: - findings/validation.performed/validation.gaps arrays were unbounded while scalar fields were capped, letting a huge findings array store and return wholesale into a parent's context via read_session. Cap findings to 100 entries, performed/gaps to 50 each, and add a 32KB combined encoded-size cap across findings/validation/recommendation/completionPercent as defense in depth. - A malformed structured_json blob previously failed the whole DB row via strict schema decoding, which would have blocked read_session and thread detail hydration. Decode it leniently instead (new decodeStructuredReportFields helper): parse/schema failures log a warning and are treated as absent, never blocking report access. - Services/ProjectionThreadReports.ts's completionPercent was a plain Schema.Int with no 0-100 bound; it now reuses SessionReportStructured's fields directly so persistence-layer bounds can't drift from the contract. - encodeStructured now writes SQL NULL instead of "{}" when there's nothing to store, keeping "no structured data" and "empty structured data" distinguishable in the column. - Renamed handlers.test.ts to tools.test.ts (it only exercised ReadSessionInput/PostReportInput schema decoding, not any handler), and added a focused decodeStructuredReportFields.test.ts plus size/array-cap cases to tools.test.ts. Migration numbering and #8 untouched, per review instructions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
96eca72 to
451a28c
Compare
#5/#6/#9/#10 Rebased onto main after #4/#5/#6/#9/#10 merged; carries the reconciliation: - Envelope delivery applies to agent AND system-origin synthetic reports (same report-posted path); formatReportMessage keeps #10's origin-aware lead and delivers >1KB summaries as an envelope with a read_report hint. - SessionReportEnvelope carries #9's structured data compactly: recommendation and completionPercent whole, findings/validation as counts; and #10's origin so a synthesized epitaph is visible at a glance. read_report returns the full findings/validation arrays with every page (bounded by the 32KB structured cap) plus origin. - findByReportId now selects abstract, structured_json, and origin and maps through the shared mapReportRow, so no column can be silently dropped by one read path; the ProjectionSnapshotQuery Struct.pick allowlist gained abstract. - Migration renumbered 043 -> 046 (043 structured, 044 stop audit, 045 origin landed first). - read_report docs record UTF-16 code-unit paging (pages can run one unit short or long at surrogate boundaries) and the unguessable-UUID timing assumption behind the reportId-only lookup path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
451a28c to
be16dd1
Compare
feat: compact report envelopes + read_report tool
Design
Delivery contract. A child session's
post_reportno longer floods the parent's context.SessionSpawnReactornow delivers:SESSION_REPORT_INLINE_MAX_CHARS) — inlined whole, exactly asbefore. No reason to force a round-trip for a paragraph.
reportId, the childthreadId, total summary size, and a hint thatread_reportfetches the rest. The full text isalready persisted in
projection_thread_reports; the waste was purely in delivery.The abstract is the author-provided optional
abstractfield onpost_report(new, ≤ 1024 chars,persisted via migration 043), falling back to the first ~500 chars of the summary. The spawned-
session report instructions now ask children to include one for long reports.
Capability rules for
read_report. A session may read reports of:spawned_by_thread_id= caller), consistent with every other toolin the toolkit;
spawned_by_thread_idparent) — the minimal peer primitive: a reviewerchild can read a worker child's report without the parent relaying up to 16KB of markdown;
Two top-level threads (both
spawned_by_thread_id = NULL) are not siblings — the null case isexplicitly excluded and unit-tested. Parents' reports are not readable by children. Archived or
unknown threads answer with the same
report_not_accessibledenial as foreign threads, so the toolcannot be used to probe for other threads' existence. This stays deliberately short of general
inter-session messaging; broader peer messaging is noted as follow-up.
read_sessionchange (behavioral, called out for compat).read_sessionnow returns itslatest report as the same compact envelope (
reportId,title,status,abstract,summaryChars,truncated, artifacts) instead of the fullSessionReport. Consumers thatpreviously read
report.summarymust fetch the body viaread_report. This only affects the MCPtool result shape — the user-facing report card and stored projections are untouched, and the
SessionReportcontract change (optionalabstract) is additive, so pre-feature payloads andclients keep decoding.
Rejected alternatives.
read_reportround-trip for every one-linereport; the ≤1KB inline path keeps the previous UX where it was already fine.
post_reportfield) — cheaper (no migration) but the first 500chars of a code review are usually preamble; an author abstract is strictly better and the
plumbing is additive throughout.
read_reportvia a new snapshot-query method — the existingProjectionThreadReportRepositoryis already in the server layer graph (provided viaProjectionPipeline), so the handler uses it directly; no new snapshot-query surface.access is the smallest primitive that unlocks peer review workflows.
What / why
packages/contracts: optionalabstractonSessionReport/thread.report.post/PostReportInput; newSessionReportEnvelope+toSessionReportEnvelope(single source of theenvelope rule); new
ReadReportInput/ReadReportResult(offset/maxChars pagination, page cap16384); new
report_not_accessibledenial reason. All tool schemas keep typed properties (noempty structs — the known Claude MCP schema gotcha).
apps/serverpersistence: migration043_ProjectionThreadReportAbstract(nullableabstractcolumn); repository gains
findByReportId; projection pipeline + snapshot query round-trip thenew column.
apps/serverorchestration:SessionSpawnReactormessage formatting extracted to puresessionReportMessages.tsand made envelope-aware.apps/serverMCP sessions toolkit: newread_reporttool (pagination + child/sibling/selfcapability);
read_sessionreturns the envelope;post_reportaccepts/persistsabstract;descriptions updated so agents discover the flow.
docs/internals/session-orchestration.mdupdated (delivery contract, sibling readexception).
Validation
npm run typecheckclean from repo root.pure-helper test pattern:
apps/server/src/mcp/toolkits/sessions/handlers.test.ts— pagination (paging, clamping,default page size), capability matrix (child ok, sibling ok, self ok, unrelated denied, parent
denied, null-parent not siblings), envelope construction (inline, author abstract, fallback
truncation).
apps/server/src/orchestration/sessionReportMessages.test.ts— reactor delivery text: smallreport inlined, large report enveloped with
read_reporthint, artifacts preserved inenvelopes.
Gaps / follow-ups
read_reportneeds the sibling'sthreadIdorreportId; a parent must still pass oneof these into the peer's prompt. General peer messaging/discovery is intentionally out of scope.
since settled-but-active threads remain readable; revisit if archive timing bites.
read_reporton a thread with several reports returns the latest when onlythreadIdis given;there is no listing tool (the envelope always carries the exact
reportId).🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.