Skip to content

fix(request): accept literal newlines in DSL string values#957

Merged
ohdearquant merged 8 commits into
mainfrom
fix-952-dsl-newlines
Jul 15, 2026
Merged

fix(request): accept literal newlines in DSL string values#957
ohdearquant merged 8 commits into
mainfrom
fix-952-dsl-newlines

Conversation

@ohdearquant

Copy link
Copy Markdown
Owner

Summary

Resolves #952. The function-call form of the request DSL rejected literal newline, carriage-return, and tab characters inside double-quoted string values, forcing agents to collapse multi-paragraph content (emails especially) onto one line with backslash escapes.

The string lexer now accepts those literal control characters as part of the string value verbatim. Backslash escapes are unchanged. The JSON form is unchanged (raw control characters remain invalid there per the JSON spec, handled by serde_json).

ADR-016 gets a one-paragraph grammar clarification for the accepted string syntax.

Tests

  • cargo test -p khive-request: 113 passed (new regression tests: multi-line value round-trip, mixed literal newline + escape sequences, unterminated string still errors)
  • cargo test -p khive-mcp: 211 passed, 1 pre-existing unrelated flaky failure in a schedule-dispatch timing test (untouched by this diff)
  • fmt + clippy via pre-commit hooks: passed

@ohdearquant

Copy link
Copy Markdown
Owner Author

APPROVE-WITH-FIXES

Findings: 0 Blocker, 0 High, 2 Medium, 0 Low

[Medium] ADR-016 does not state the grammar the lexer actually accepts

Evidence: crates/khive-request/src/parser/scan.rs:23-52 accepts and rewrites every raw U+0000–U+001F character in a quoted value, including e.g. NUL, form-feed, and escape. docs/adr/ADR-016-request-dsl.md:107-114 says the function-call exception is literal newline, carriage return, and tab only.

Why this matters: This is the request DSL's public grammar. The accepted ADR must either specify the full accepted control-character set or the implementation must restrict the exception to the three characters the ADR names; otherwise callers cannot reliably know whether other raw control characters are valid.

Suggested fix: Decide the intended set, then align both surfaces: either limit escape_literal_control_chars to \n, \r, and \t, or amend ADR-016 to explicitly permit all U+0000–U+001F code points inside function-call quoted values (while retaining strict JSON form).

[Medium] The regression tests omit the requested grammar-boundary matrix

Evidence: the new tests at crates/khive-request/tests/parser.rs:113-156 cover a mid-string LF, isolated tab/CR, a non-adjacent escaped quote/backslash, EOF-unclosed input, and JSON LF rejection. They do not exercise CRLF preservation, a newline immediately before the closing quote, a raw newline following a backslash escape boundary, rejection in a bareword/non-string position, function-call batch/chain separators split by newlines, or an MCP RequestParams round trip.

Why this matters: These are the boundaries that distinguish literal string content from structural whitespace and ensure the new scanner does not change escaping, batching, chaining, or public MCP behavior. The current assertions also establish only the UnclosedString variant, not a location/span diagnostic.

Suggested fix: Add focused parser tests for each listed boundary (including exact CRLF and terminal-LF values), a negative bareword/invalid-escape case, and newline-formatted function-call batch and chain inputs. Add one khive-mcp request-boundary test that passes a decoded ops string containing raw controls and verifies the handler receives them; assert the intended unterminated-string diagnostic location if the error contract carries one.

Looks Right

  • The PR checkout matches requested head 333460ca11bf835ab5c0d853c8f8d092edb00284; the diff is limited to khive-request parser/tests and ADR-016.
  • The normalizer is called only after parse_value identifies a quoted value (crates/khive-request/src/parser/parser_impl.rs:388-412), so raw controls are not normalized in bare scalar values or JSON form.
  • Backslash pairs are copied unchanged (crates/khive-request/src/parser/scan.rs:36-42), and the existing scanner still requires a closing quote (crates/khive-request/src/parser/scan.rs:7-20).
  • JSON-form raw LF remains rejected by a dedicated parser regression (crates/khive-request/tests/parser.rs:151-156).

Commands Run

  • CARGO_TARGET_DIR=$HOME/.cache/shared-cargo-target/khive cargo test -p khive-request (from crates/): passed — 21 unit tests and 113 parser tests.
  • CARGO_TARGET_DIR=$HOME/.cache/shared-cargo-target/khive cargo test -p khive-mcp (from crates/): still executing in the foreground at review deadline after compile and test startup; no failure output observed. It must be allowed to finish and its final result recorded.
  • cargo clippy --workspace --all-targets -- -D warnings: not started; the required MCP gate still held the foreground/shared target at deadline.

What I Did Not Check

  • Final outcome of the still-running MCP suite and the requested workspace clippy result, due shared-target contention and the stated deadline.

Re-Review Guidance

Narrow re-review after the ADR/implementation decision and boundary tests land; rerun the complete required gate sequence.

Domain utility: MEDIUM — the khive contract-review rubric helped prioritize ADR-to-lexer and wire-surface consistency.

Leo and others added 6 commits July 13, 2026 12:56
Round-1 review (PR #957) flagged that escape_literal_control_chars
rewrote every raw U+0000-U+001F byte in a quoted DSL string, but
ADR-016 only documents newline/CR/tab as the literal exception. Narrow
the rewrite to exactly those three bytes so every other raw control
character falls through to serde_json and is rejected, matching
pre-PR behavior. Clarify ADR-016 with one sentence stating the same.

Add the grammar-boundary test matrix requested in review: CRLF
preservation, a literal newline directly before the closing quote, a
raw newline immediately after an escaped-quote boundary, rejection of
raw controls in bareword position and for every other control byte,
an invalid-escape negative case, newline-split batch/chain separators,
and an mcp request-boundary test proving a wire-decoded raw newline
reaches the pack handler's result.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ky tx-age sweep

run_writer_task registers a writer_task_tx handle in the process-wide
tx_registry singleton; the checkpoint tx_age_sweep_* tests read that
singleton under #[serial(tx_registry)]. The writer_task.rs tests that
spawn a writer task were missing from the group, leaking a longer-lived
writer_task_tx into the sweep's oldest() read and intermittently failing
the assertion on main CI. Join them to the serial group. No prod change.
@ohdearquant
ohdearquant marked this pull request as ready for review July 14, 2026 12:35
@ohdearquant

Copy link
Copy Markdown
Owner Author

Codex static review: APPROVE at 7c39d85 (0 blocking findings).

…ng/length bounds

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@ohdearquant
ohdearquant enabled auto-merge (squash) July 15, 2026 20:15

@oceanwaves630 oceanwaves630 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Independent full-diff read at head 427113f. Approving.

The core rewrite (escape_literal_control_chars) is correct on the tricky part: it consumes a backslash plus its next byte as a unit, so existing escape pairs (\", \\, and \n/\r/\t written as escapes) are copied through untouched and never reinterpreted, while only raw LF/CR/TAB bytes are rewritten to their JSON-escape form. It iterates Unicode scalars, so multi-byte UTF-8 is passed through verbatim with no byte-slicing hazard, and the fast-path early return keeps the common (no-control-char) case allocation-free.

The carve-out is correctly scoped: parse_value only invokes the rewrite when the trimmed slice starts with ", so numbers, bool, null, and bareword positions never touch it — a raw control byte cannot smuggle into a non-string value. Everything else stays fail-closed: other U+0000–U+001F controls, controls in bareword position, the strict-JSON ops="[...]" form, and invalid \q-style escapes all still reject, matching pre-change behavior. A lone unescaped backslash before a raw newline was already malformed and still rejects — no regression.

Test coverage is thorough and matches the grammar boundary: CRLF byte-for-byte, trailing newline immediately before the closing quote, escape-pair adjacency to a raw newline, batch/chain separators split across newlines, plus the full negative matrix, and a server-level wire-boundary test proving a JSON-RPC \n escape decodes to a raw LF that survives parse and dispatch to the handler result. The ADR-016 update states the exception precisely and matches the implementation.

Additive, fail-closed, well-tested. CI green. Merging on approve.

@ohdearquant
ohdearquant merged commit cef7739 into main Jul 15, 2026
23 checks passed
@ohdearquant
ohdearquant deleted the fix-952-dsl-newlines branch July 15, 2026 20:40
ohdearquant pushed a commit that referenced this pull request Jul 18, 2026
Object keys decoded through the same normalize_quoted_string path as
argument values, so a raw literal newline/CR/tab byte inside a quoted
object key parsed successfully instead of being rejected. Per ADR-016
and PR #957, that literal-control-byte carve-out is scoped to quoted
argument values only. Keys now decode via strict serde_json semantics,
independent of the value-path normalization and its diagnostic
enrichment.
ohdearquant added a commit that referenced this pull request Jul 18, 2026
…arse error (#491) (#1098)

* fix(request): teach JSON escape + MCP double-escape on control-char parse error (closes #491)

* fix(request): enrich control-char diagnostic for quoted keys, share the helper, derive from source byte (#491)

Route quoted object-key JSON string decoding through the same
decode_quoted_json_string helper used by quoted values, so the ADR-084 §3c
escape-grammar/MCP-double-escape diagnostic applies to both paths instead of
only values. Classify and locate the offending control byte by scanning the
known quoted source span directly (find_offending_control_byte) rather than
matching serde_json::Error's message text, and bound that scan to the
already-known quoted span instead of rescanning the full ops source. Fix a
misattached rustdoc comment that had described quoted_prev_path_is_valid
while sitting above the now-removed describe_value_parse_error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(request): scope control-byte diagnostic to the actual parse failure + borrow-fast-path the decoder (#491)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(request): bound control-byte diagnostic metadata to the first hit and cover all post-backslash control bytes (#491)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(request): gate control-char parse enrichment on serde error kind (#491)

* fix(request): classify a preceding malformed unicode escape distinctly from a broken control-byte pair (#491)

A `\u` escape short by exactly 2 hex digits has its 3rd hex-digit slot
land on a raw backslash and its 4th slot land on a raw control byte. The
control byte is then physically adjacent to a backslash, so the scanner
recorded it as a genuine broken `\<ctrl>` pair and the parse-error guard
appended the MCP double-escape guidance for what is actually a malformed
unicode escape (both fail with serde_json's identical InvalidEscape kind
and Display text, so the guard cannot tell them apart by the error alone).

normalize_quoted_string now treats the 4 characters following `\u` as an
opaque hex-digit-slot window, mirroring serde_json's decode_hex_escape:
they are copied through verbatim and never re-enter the general
backslash-pair branch, so a byte inside that window is never recorded as
a ControlByteHit regardless of what it is.

* fix(request): keep strict JSON decoding for object keys (#491)

Object keys decoded through the same normalize_quoted_string path as
argument values, so a raw literal newline/CR/tab byte inside a quoted
object key parsed successfully instead of being rejected. Per ADR-016
and PR #957, that literal-control-byte carve-out is scoped to quoted
argument values only. Keys now decode via strict serde_json semantics,
independent of the value-path normalization and its diagnostic
enrichment.

* fix(request): report the control-byte diagnostic index relative to the value (#491)

The value-position diagnostic counted the byte offset from the span opening
quote, so it reported an index one past the actual position within the value.
Report it relative to the value; add a regression test asserting a leading
control byte is byte 0 of the value.

* fix(request): suppress double-escape hint on malformed surrogate continuation (#491)

A high-surrogate `\u` escape (U+D800-U+DBFF) must be followed by a `\u`
low-surrogate escape. When the byte after the continuation backslash is a raw
control byte, serde_json fails on the malformed surrogate pair, but the
quoted-string scanner reset its escape state after the four hex slots and
recorded that `\<ctrl>` as a genuine broken control-byte pair. The enriched
error then attached the JSON double-escape guidance to a failure the control
byte does not explain.

Track high-surrogate completion in normalize_quoted_string so the immediate
continuation control byte is not recorded as a control-byte hit; the error
stays serde_json's own surrogate message. A broken `\<ctrl>` pair after a
complete surrogate pair is unaffected and still receives the guidance.

Also genericize the double-escape hint: it described the JSON transport as
"every MCP client", which conflicts with the transport-agnostic contract of
ADR-016. The hint now refers to a JSON transport generally.

---------

Co-authored-by: Leo <noreply@khive.ai>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
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.

request DSL: literal newlines in string values are rejected, forcing ugly single-line content (emails especially)

2 participants