items owners: one canonical row layout owned by a single split/compose, so load, save and translation hash cannot drift #232
Labels
No labels
bug
enhancement
needs-triage
ready-for-agent
ready-for-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/deckle#232
Loading…
Add table
Add a link
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
An items owner's children (its item rows plus its body region) get one canonical layout, body first and then one row per line, owned by a single split/compose pair that every reader and writer of those children goes through. Today every editor save of a hand-authored
text-media/figureselement rewrites it into glued rows on one line with the body moved behind them, the layout the sidecar comments and AGENTS.md teach is one the engine never writes, and the "merge the text nodes a removal exposes" rule exists four times.Current behavior
Confirmed by reproduction on
main(ea11f78), afigureselement hand-authored in the documented shape:GET /api/element/bodyserves"Body.\n\n": the two separator newlines leak into the body the editor sees.The form always sends the items collection when the element owns one, even for an Appearance-only edit. The save (
elemops.ApplyEdit) composesrows ++ restwith no separator, so the first save of any hand-authored items owner rewrites the file toRows glued, body moved behind the rows, a structure-mangling diff for a change that touched neither. Adding, removing or reordering an image afterwards is a diff on one long line. Rendering is unaffected (
imgpushes onto the parent's Scratch; the body comes from Inner).The translation hash (
l10n.Hash, viabodyContentininternal/l10n/hash.go) hashes the separator whitespace with the body: the served"Body.\n\n"and the RTE's normalized"Body."hash differently, so a touch-and-undo body edit flags every connected translation as "source changed". (An untouched body is not re-sent by the form, so a save that never opened the body keeps the hash; the leak needs an actual body edit.)All three layouts, body-first pretty-printed, rows-first pretty-printed and glued, round-trip byte-identical through
content.Parse/content.Serialize, soharness checkcalls every one canonical: "canonical" today means "round-trips", not "what the editor writes". AGENTS.md "Editing content by hand" promises that saving an element without changing anything leaves its file byte-identical; for items owners that is false.The item/body split has one owner (
elemops.PartitionItems), but compose is a hand-writtenswitchinApplyEditthat is not its inverse, and "is this child an item of its parent" is evaluated again inl10n.isItemandl10n.holdsElement(uid-gated inbodyContent/elementChildren)."Merge the adjacent text nodes a removal exposes" is implemented four times:
content.removeAt(internal/content/mutate.go),elemops.coalesceText(internal/elemops/save.go),l10n.bodyContent(internal/l10n/hash.go),l10n.spliceNodes(internal/l10n/l10n.go). CLAUDE.md namesinternal/contentas the one owner of that rule.Desired behavior
One grammar. The canonical layout of an items owner's children is: the body region, then a newline, then the item rows, one per line, in order. An owner with no body (
body: none, or an empty rte body) holds the rows alone, one per line; an owner with no rows holds the body region alone. The documented shape above is therefore already canonical: the editor's save of it is byte-identical.One owner. A split/compose pair in
elemopsreplaces the rawPartitionItems, and they are exact inverses on canonical input:GET /api/element/body(internal/server/body.go), the save composition andWouldClobberBody(internal/elemops/save.go), the form's row list (internal/server/forms.go), the translated-items refusal (internal/server/translation.go), the hash's body content (internal/l10n/hash.go), and the translation structure repair that adds, removes or reorders rows in a connected translation (internal/l10n/structure.go:spliceNodes/insertNode/placeIndexon an items owner's rows). A row inserted into a translation by repair lands in canonical layout too.l10n.isItemand the items branch ofl10n.holdsElementcall it.One text merge.
internal/contentexports one helper that merges adjacent text nodes (fresh copies, inputs never mutated);removeAt, the split,bodyContentandspliceNodesall use it.internal/removecmd/strip.gois intentionally different (it repairs whitespace seams and re-verifies the round trip) and stays as it is.Hash. The owner's translation hash is computed over the split's body region, so the layout's separators never enter it: a body edit that ends in the same text leaves every connected translation untouched.
Migration. A file the editor already wrote in the glued form is reformatted into the canonical layout once, on its next save of that element (one row per line, body first).
harness checkstays silent about a non-canonical row layout. The pull request says so in its description, as a release note.Docs and skills (CLAUDE.md rules): the layout is documented once and the skills point at it.
Decisions
text-media.yaml/image.yamlcomments, AGENTS.md and hand authors already use. The current "rows lead" order inApplyEditwas never documented and is dropped.content.Serializekeeps refusing adjacent text nodes — the invariant is what the tests and the merge helpers rely on, and relaxing it would not fix the layout anyway. Rejected deliberately (second time, see the original report).harness checkdoes not warn on a non-canonical row layout — every layout round-trips, and a warning would fire on every existing editor-written site until each element is resaved.elemops—l10nandserveralready import it, and it is wherePartitionItemslives today; the text merge lives incontent, the owner CLAUDE.md already names.Key interfaces
elemops.PartitionItems(children, itemElement)— becomes the split half of an exact split/compose pair taking the owner's schema; the compose half is new. Starting hint:internal/elemops/save.go.elemops.ApplyEdit— the children-compositionswitchbecomes "split, replace the axes the request sent, compose". The absent-versus-present semantics ofitemsandbody(table in its comment) are unchanged.content— one exported adjacent-text merge helper;removeAtuses it. Starting hint:internal/content/mutate.go.l10n.bodyContent,l10n.spliceNodes,l10n.insertNode,l10n.isItem,l10n.holdsElement— call the owners instead of re-deriving. Starting hints:internal/l10n/hash.go,internal/l10n/l10n.go,internal/l10n/structure.go.handleElementBody— serves the split's body region. Starting hint:internal/server/body.go.scaffold/site/AGENTS.md"Editing content by hand" (the "Bodied elements use paired tags … Items rows follow the same rule" passage and the byte-identical-save promise) and the "Item collections" paragraph;scaffold/editor/elements/text-media.yamlandimage.yamlcomments (the stored shape);scaffold/site/.claude/skills/edit-content/SKILL.md(the "Items children" bullet: where rows go and one per line, pointing at AGENTS.md),migrate-site/SKILL.md(whatharness checkreports and what the editor canonicalises on first save);redo-theme/references/showcase-page.mdalready places rows one per line — confirm it parses as canonical layout; the package comments ininternal/elemops/save.goandinternal/server/body.gothat currently describe rows leading.Acceptance criteria
imgrow per line) with its rows and body unchanged leaves the file byte-identical.GET /api/element/bodyon that element serves"Body."(no separator newlines), and the same after a save.harness checkaccepts the result.{{< img … />}}{{< img … />}}Body.) is rewritten to the canonical layout on its next save of that element, and never touched by a save of another element on the page.WouldClobberBody, the form's row list, the translated-items refusal and the hash's body content behave as before on canonical input (existing tests), now via the split.internal/content;coalesceText, the merge inbodyContentand the merge inspliceNodesare gone;removecmd/strip.gois untouched.l10n.isItemandl10n.holdsElementcall it.scaffold/site/AGENTS.md, the two sidecar comments and the skills named above describe the canonical layout once and point at it, and the drift tests (internal/element/agentsdoc_test.go,scaffold/skills_test.go) pass./>}}{{<ininternal/server/items_test.go,element_test.go,l10n/*_test.goand others, 27 occurrences) are updated to the canonical layout, not deleted.How to verify
go test ./...andmake check(or the repo's lint target) green.figuresfile above into anitemsSitetest site,GET /api/element/body, thenPOST /api/page/savewith the two rows (uids echoed) and no body; before the change the file comes back glued and the body served is"Body.\n\n".cd web && npm teststill green: the client's body handling does not change, only what the server serves.Assumptions to check
web/src/rte/markdown.tsbefore relying on the hash criterion.imgpushes onto the parent's Scratch and the body comes from Inner, so order should not matter); confirm inthemes/decklebefore changing the order.placeIndexininternal/l10n/structure.goorders by source position among all elements of a parent; once rows are a separate list, confirm reordering a translation's rows still follows the source order.Out of scope
content.Serializeto merge adjacent text.harness checkwarning or aharnessreformat command for non-canonical row layouts.internal/removecmd/strip.go.Blocked by
None - can start immediately
Original report
Follow-up to #230 / PR #231, which only made the body GET tolerate the row layout the save never produced. The underlying asymmetry is still there.
Problem
An items owner's children are read and written by code that each knows the children grammar on its own:
GET /api/element/body(internal/server/body.go) strips the rows and serves the rest.elemops.ApplyEditcomposesrows ++ bodywith no separator between rows, so every save writes{{< img … />}}{{< img … />}}Body.on one line and moves the body behind the rows. Rendering is unaffected (img pushes onto the parent's Scratch; the body comes from Inner), but:internal/l10n/hash.gobodyContenthashesBody.\n\n, the saved form hashesBody.), so the first no-op editor save of a hand-authored items owner flags every connected translation as "source changed";text-media.yamlcomment and AGENTS.md teach (one row per line) is not the shape the engine writes, so docs and engine disagree about what canonical looks like.elemops.WouldClobberBody,internal/server/forms.go(row list) andinternal/server/translation.go(item sync) each call the split for one half.Proposal
PartitionItemswith a split/compose pair inelemopsthat are exact inverses: split returns(rows, body)with the separator newlines removed, compose writes them back. GET body, ApplyEdit, WouldClobberBody, the forms row list, the translation item sync andl10n.bodyContentall go through it. Nobody slices children by hand.harness checkaccepts the result.content/mutate.go(removeAt),elemops/save.go(coalesceText),l10n/hash.go(bodyContent),l10n/l10n.go(spliceNodes). One exported helper incontent, used by all four. (removecmd/strip.gois intentionally different: it repairs whitespace seams and re-verifies the round trip; leave it.)Migration
Every element the editor already wrote in the glued form gets a one-time reformat diff on its next save. Acceptable, but say so in the release notes. Consider whether
harness checkshould report non-canonical row layout, or stay silent since parse/serialize round-trips either way.Surface to update (CLAUDE.md rules)
scaffold/site/AGENTS.md: "Bodied elements use paired tags … Items rows follow the same rule" passage and the content canonicalization rules; the item collections paragraph.scaffold/editor/elements/image.yaml,text-media.yamlcomments (the stored shape).edit-content-by-hand(how to write rows by hand),migrate-site(what the engine canonicalizes),redo-theme/references/showcase-page.mdif it shows rows.internal/serverpackage README (frozen save contract) if it spells out child composition.Open questions for triage
content.Serializemerge adjacent text instead of refusing it (the dangerous merge, one forming a shortcode opener, is still caught bycheckTextRaw). It kills the class of bug but gives up an invariant tests rely on. Rejected in the first pass; worth a deliberate decision.