transcode::body_to_json renders RowDelta rows as a byte array, not structural JSON #193
Labels
No labels
priority/P0
priority/P1
priority/P2
release/v0.1.0
status/blocked
status/planned
type/bug
type/design
type/test-gap
type/tracker
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
natsukium/felis#193
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
felis_grid::transcode::body_to_jsonis documented as the shared engine thatre-exposes a daemon session "as JSON" for the satellite consumers
(
felis-web-gateway,felis-fcast serve, the CLI's stdio bridge). For aGridMsg::RowDeltait no longer produces readable JSON: the row lands as aJSON array of byte values, not as the structural row object the module
doc and its tests describe.
The bytes are correct and round-trip cleanly — this is a fidelity, size, and
readability regression in the JSON view, not a corruption bug.
Where it comes from
body_to_jsonrecodes the rows and then serializes the whole message(
crates/felis-grid/src/transcode.rs:40-46):rows_to_jsonfills eachRowPayloadwithencode_row_jsonoutput, which isJSON text as bytes (
crates/felis-grid/src/wire.rs:642,serde_json::to_vec).serde_json::to_valuethen serializesRowPayload, and sincecrates/felis-protocol/src/row.rsdeclaresthe JSON text is re-encoded as
[123, 34, 82, …].Before the wire-baseline reset,
RowPayload's serde impl was encoding-aware:under a human-readable serializer it spliced the row text inline as a real
JSON value via
serde_json::value::RawValue, so the row read as{"Rle":{…}}. That branch's docs flagged its own future as an open question("Whether this branch keeps earning its place now that no live wire path
drives it is an open decision"). It was dropped — but
body_to_json'shuman-readable consumers were exactly the path that drove it, so the JSON
view lost its readability along with it.
Reproduction
Any consumer of the JSON view shows it. Using felis-fcast, whose
.fcastframes are
serde_json::to_valueof the sameGridMsgvalues (felis-fcast onfollow-felis-105b0899, felis pinned at105b089979369fd310757375af3df7900ae7e523):Decoding that byte array as UTF-8 gives what the JSON view is supposed to
show directly:
Size cost for that one 48-column row: 3271 chars as stored, 948 chars as the
text it encodes — 3.5x.
Why the tests do not catch it
transcode::tests::row_delta_payload_survives_json_protobuf_json(
crates/felis-grid/src/transcode.rs:195) assertsbody_to_json(json_to_body(json)) == jsonand comments "row payload must comeback as structural JSON". Round-trip equality holds for the byte-array form,
so the test passes while the property its name claims does not hold. Nothing
asserts the shape of the produced value.
Relatedly,
row::tests::the_serde_form_matches_a_plain_byte_vec(
crates/felis-protocol/src/row.rs) carries the comment "so stored.fcastfiles and the JSON row view are unaffected by the newtype" — but it exercises
postcard (binary) only. Both are affected; the comment reasons about the
branch the test covers and generalizes past it.
Suggested direction
Not prescribing a fix, but the options as they look from downstream:
RowPayload(RawValueunderis_human_readable()), which fixes every JSON consumer at once and needsno downstream change.
body_to_jsonsplice the row itself afterrows_to_json, keepingRowPayloadtransparent.test comments, and the "structural JSON" language in
docs/explanation/architecture/ipc.md:405to match.Option 3 is a real choice — but the doc and test comments should not keep
describing option 1's behavior either way.
Why this is out of scope for the felis-fcast follow-up
felis-fcast cannot fix it downstream. Its format rule is that a frame's
msgis the verbatim serde-JSON of a
felis-protocolenum and that repo neverdefines the
msgschema; splicing the row inline there would fork the schema,so a producer writing the spliced form and a consumer reading the transparent
one would disagree about a file both call valid. That PR documents the current
shape and ships a jq recipe to decode a row; if felis restores the readable
form, recordings pick it up with no format change.
Triage plan (2026-09-07)
Verdict: accepted,
priority/P2by the rule (no wire byte and no CLI output contract changes: the bridge never emits aGridframe,capture_row_jsontranscodesRegion::Rowwhich carries noRowPayload) — but landing before the tag, because every.fcastrecorded against v0.1.0 would otherwise carry the byte-array shape andjson_to_bodyplus every satellite reader would have to dual-accept two row shapes forever. Fixing now costs the satellites nothing (felis-fcast's rule is verbatim serde-JSON of the felis enum).Verified:
RowPayloadis#[serde(transparent)] Vec<u8>(crates/felis-protocol/src/row.rs), serde_json has no bytes fast path, andbody_to_json's Grid arm (crates/felis-grid/src/transcode.rs) fills the payload withencode_row_jsonbytes and thento_values the message, so the JSON text is re-encoded as[123,34,…].85cc1badremoved theis_human_readable()splice on the argument that no live path reached it;body_to_json(added later) is exactly such a path. The existing round-trip test builds its expected value with the sameto_value, so it asserts the broken shape.Approach (option 2 — splice in
felis-grid::transcode,RowPayloadstays transparent):body_to_json: afterrows_to_json+to_value, walkvalue["RowDelta"]["rows"][i][1]and replace each byte array withserde_json::from_slice::<Value>; error (do not skip) if the path is missing so aGridMsgrename fails loudly.json_to_body: inverse (to_vecintoRowPayloadbeforerows_to_wire); reject a byte-array input (no legacy recordings exist pre-tag).felis-protocol(undoes85cc1bad's valid point); do not change the docs to bless the byte array (contradictsipc.md,overview.md, and the module's reason to exist).row_delta_payload_survives_json_protobuf_jsonwith a non-empty row and assert shape (["RowDelta"]["rows"][0][1]["Rle"]["graphemes"]is an array, noNumberelements at that position) plus round-trip equality. Drop "and the JSON row view" from therow.rstest comment (that test is postcard-only).CHANGELOG.mdentry (.fcast-affecting, precedent exists);ipc.md/overview.mdalready describe the fixed behavior. No schema, noskills/felischange. Size ≈ +45/−10.Downstream: the felis-fcast follow PR documents the byte-array shape with a jq
imploderecipe; that becomes stale when this lands — coordinate the felis bump there.