same-page-json-shape-split-camelcase-list-vs-snakecase-write-response
Wrote the solution to ~/same-page-json-shape-split-fix.md.
Root cause: The API returns two shapes for the same entity — camelCase on reads (GET /trees/{id}/nodes → treeId/nodeType/parentId) and snake_case on writes (POST /trees/{id}/merge → tree_id/node_type). The merge handler did res.json() and inserted the result into the camelCase-typed list directly (raw as TreeNode). res.json() is any, so TypeScript never flagged the mismatch; at runtime node.treeId is undefined.
Fix: A single named normaliser owning the translation:
- Explicit TreeNode (view) and MergeResponseWire (wire) types.
- normalizeMergeResponse(raw: unknown): TreeNode with runtime validation (throws MergeResponseShapeError on missing/invalid fields, maps null parent correctly).
- mergeNodes() returns TreeNode, staying unknown until normalisation; the component just appends it.
- normalizeMergeResponseList() for array payloads.
- Lint rule banning as TreeNode casts.
Verification: vitest unit tests (mapping, null parent, no snake_case leakage, throws on bad shape), an MSW + React Testing Library regression test asserting no undefined cells after insert, and a CI grep guard that fails if tree_id/node_type/parent_id appear outside lib/merge.ts.
The document is self-contained and directly usable as-is.
# Evidence - Problem class: same-page-json-shape-split-camelcase-list-vs-snakecase-write-response - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-18T08:31:54.683Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "In a client that renders a server list (camelCase DTO) and also consumes a write response for the same entity (snake_case DTO per an HTTP boundary rule), inserting the write response into the list without a translation layer silently produces undefined fields; the fix is a named, tested normaliser at the boundary and never a hand-read response. Observed in hermes-canopy: GET /trees/{id}/nodes returns treeId/nodeType/parentId while POST /trees/{id}/merge returns tree_id/node_type; lib/merge.ts normalizeMergeResponse owns the translation.", "environment": "hermes-canopy frontend (React 19 + TS) against Go/chi API", "language": "typescript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "same-page-json-shape-split-camelcase-list-vs-snakecase-write-response", "provider": "openrouter", "solved_at": "2026-09-18T08:31:54.684Z", "version": ""}Wrote the solution to ~/same-page-json-shape-split-fix.md.
Root cause: The API returns two shapes for the same entity — camelCase on reads (GET /trees/{id}/nodes → treeId/nodeType/parentId) and snake_case on writes (POST /trees/{id}/merge → tree_id/node_type). The merge handler did res.json() and inserted the result into the camelCase-typed list directly (raw as TreeNode). res.json() is any, so TypeScript never flagged the mismatch; at runtime node.treeId is undefined.
Fix: A single named normaliser owning the translation:
- Explicit TreeNode (view) and MergeResponseWire (wire) types.
- normalizeMergeResponse(raw: unknown): TreeNode with runtime validation (throws MergeResponseShapeError on missing/invalid fields, maps null parent correctly).
- mergeNodes() returns TreeNode, staying unknown until normalisation; the component just appends it.
- normalizeMergeResponseList() for array payloads.
- Lint rule banning as TreeNode casts.
Verification: vitest unit tests (mapping, null parent, no snake_case leakage, throws on bad shape), an MSW + React Testing Library regression test asserting no undefined cells after insert, and a CI grep guard that fails if tree_id/node_type/parent_id appear outside lib/merge.ts.
The document is self-contained and directly usable as-is.
# Evidence - Problem class: same-page-json-shape-split-camelcase-list-vs-snakecase-write-response - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-18T08:31:54.683Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "In a client that renders a server list (camelCase DTO) and also consumes a write response for the same entity (snake_case DTO per an HTTP boundary rule), inserting the write response into the list without a translation layer silently produces undefined fields; the fix is a named, tested normaliser at the boundary and never a hand-read response. Observed in hermes-canopy: GET /trees/{id}/nodes returns treeId/nodeType/parentId while POST /trees/{id}/merge returns tree_id/node_type; lib/merge.ts normalizeMergeResponse owns the translation.", "environment": "hermes-canopy frontend (React 19 + TS) against Go/chi API", "language": "typescript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "same-page-json-shape-split-camelcase-list-vs-snakecase-write-response", "provider": "openrouter", "solved_at": "2026-09-18T08:31:54.684Z", "version": ""}