Minor API behaviour inconsistencies found while documenting every route (#264) #273
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#273
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
Fourteen small API behaviours that the #264 docs pass found and documented as they are, each either an inconsistency between sibling routes or a second evaluation of a rule that already has an owner. Fix every one, add the test it lacks, and make
docs/api.mddescribe the corrected behaviour.Current behavior
All fourteen reproduce on
mainata0c4ce7by reading the handlers; no test covers any of them. Locations are starting hints, not instructions.GET /api/link/resolve(handleLinkResolve,internal/server/linktargets.go) answersexternalandmediavalues beforeopsRootvalidateslang, so an unknownlanggets a200for those kinds and a404for the others.docs/api.mddocuments the quirk.POST /api/element/duplicate(handleElementDuplicate,internal/server/element.go) runsplacementViolationbut neverelemops.SlotElementsMsg; insert, move and transfer run both.GET /api/page/refs(handlePageRefs) on the root_index.mdcollects every permalink in the tree and skips the "exclude the bundle's own directory" step because the directory is., so it lists every file linking to any page. The root page cannot be deleted (pageops.Delete), so the delete warning never asks.GET /api/media/library(handleMediaLibrary,internal/server/media.go) slug-checksfolderwithvalidMediaFolderand refuses a legacy non-slug folder withlibrary.folderInvalid, whileGET /api/media/treelists it and everymediaopsroute addresses it (internal/mediaops/paths.goshape-checks, never slug-checks, on purpose).GET /api/media/folder/delete-plannormalizesfolderwithtrimFolderSlashes(\→/, trims/);POST /api/media/folder/deletepassesreq.Folderraw tomediaops.DeleteFolderand normalizes only the snapshot subject, so a plan can succeed and the delete be refused.POST /api/media/upload(handleMediaUpload) runsmedia.Process(decode and re-encode) before checkingtargetandfolder.pageops.Renameandpageops.RenameTranslationrefuse a blank title but store it untrimmed;pageops.Createstores it trimmed.enabled(elemops.itemDisabled,internal/elemops/save.go) treats any value other than JSONfalseas enabled, a string included; an element's ownenabledgoes throughelement.ReadEnabled, which answers a field error for a non-boolean.handleSavePage(internal/server/server.go) decodes its body inline, a second copy ofdecodeBodyand itsrequest.malformedanswer.POST /api/formwithkind: "create":formRef.Parentis decoded (internal/server/forms.go) and the client'sCreateDialogsends it, but theformCreatebranch ofresolveFormreads onlyTypeandLang.elementL10n(internal/server/translation.go) fillssyncedwith every Appearance param whether or not a source element with that uid and name exists, sodescribe()(internal/server/forms.go) marks the fieldsreadOnlywithnote.followsSourceElementwhen there is nothing to follow.writeStructural(internal/server/translation_ops.go) snapshots withopSave, so element insert, delete, move and duplicate, andPOST /api/element/enabled, all read "save" in the undo catalog althoughopCreate,opDelete,opMove,opEnableandopDisableexist (internal/server/snapshot_ops.go) and the client'sundo.op.<op>catalog translates them.l10n.UnplacedError(internal/l10n/translate.go) carries a plain message (l10n.beforeSlot,l10n.beforeParent,l10n.beforePage) and aparentFirstone; its only caller,handleTranslateElement, swaps toParentFirst()when the uid is untranslated, anddocs/api.mdlists only the…ParentFirstcodes.GET /api/publish/status(handlePublishStatus,internal/server/publish.go) answers{phase: "static", code: "publish.static"}for anycommitthe status map does not hold, with no shape or existence check.Desired behavior
GET /api/link/resolvevalidateslangfirst: an unknownlangis404 editor.unknownLangfor every kind,externalandmediaincluded.POST /api/element/duplicaterefuses a copy the destination slot'selementsallowlist does not admit, with the same code and status insert answers, by callingelemops.SlotElementsMsg.GET /api/page/refson the content root's own page answers400with a new coded messagepage.rootRefs, defined and worded likepage.rootRename,page.rootMoveandpage.rootReorder. Every other page keeps its current answer.GET /api/media/librarylists a legacy non-slug folder exactly asGET /api/media/treeand themediaopsroutes address it. An existing folder is accepted by the shape checkmediaopsalready owns; the slug rule applies only where a folder comes into being (folder create, upload into a folder that does not exist yet) and keeps its current codes there.library.folderInvalidstays for a folder that fails the shape check.POST /api/media/folder/deleteandGET /api/media/folder/delete-plannormalizefolderidentically, through one owner both call, so anyfolderthe plan accepts the delete accepts, and the executed plan equals the dry run for the same input.POST /api/media/uploadrefuses a wrongtargetor an invalidfolderbefore it decodes the file; the codes and statuses do not change.pageopsthat create, rename and translation rename all call. A title that is blank after trimming is still refused withpage.titleEmpty.enabledis present and not a JSON boolean is refused with the same field error an element'senabledgets, by callingelement.ReadEnabled. Absent stays enabled;trueandfalsekeep their meaning.elemopskeeps exactly one place that reads a row's enabled state back.handleSavePagedecodes its body throughdecodeBody; the answer for a malformed body is unchanged.POST /api/formwithkind: "create"usesparent: a parent that does not resolve answers the refusalPOST /api/page/createanswers for it, and atypethe parent's page type does not allow answers the refusal create answers for that, with the same codes and statuses, so the dialog fails before the user fills the form. A valid parent changes nothing in the description.readOnly, no note, nonoteCode, not named in the description'ssyncedlist. When a source element exists, the current behaviour is unchanged.create, deletedelete, movemove, enabledenableordisableby the resulting state. The snapshot subject stays the page.snapshotOpsand the exportedundo_ops.jsondo not change.UnplacedErrorreachingPOST /api/element/translateis always answered with the…ParentFirstcode (or shows a case where it is not). If it is unreachable,UnplacedErrorcarries one message, the three plainl10n.before*codes are deleted frominternal/apimsg, and nothing else references them. If a reachable case turns up, keep both and document the plain codes indocs/api.mdwith the case that produces them.GET /api/publish/status: acommitthat is not a 40-character lowercase hex sha answers400with a new codedrequest.message for an invalid parameter value, carrying the parameter name; a well-formed sha the repository does not contain answers404with a newpublish.code;staticis answered only for a commit the repository has and the server never polled.Every route touched has its
docs/api.mdsection updated to the new behaviour: the error list, the key list and the prose that described the old behaviour (item 1's "answer whatever lang says", item 3's root sentence, item 11's read-only clause, the undo section's op words where relevant). Every new or removed code appears or disappears there.Decisions
opsRootfor the unknown-lang verdict (1);elemops.SlotElementsMsgfor a slot's allowlist (2); the folder shape check inmediaopsfor an existing folder (4); one normalizer for a folder argument that both folder-delete routes call, moved intomediaopsif that is where it fits (5); one title trim inpageops(7);element.ReadEnabledfor anenabledvalue (8);decodeBodyfor a request body (9);elementL10nfor what is synced (11). A fix that adds a second evaluation of any of these is wrong./, so any prefix scoping still matches every link; and the root cannot be deleted, so no caller needs the list.mediaopsalready decided an existing folder is shape-checked and never slug-checked; the library route follows that decision instead of the tree route being bent the other way.parent— the client already sends it, and failing at form time is the same refusal create would give, earlier.undo_ops.jsonstay as they are; a page delete and an element delete share the worddelete, which the subject (the page) makes clear enough.Key interfaces
handleLinkResolve,handleElementDuplicate,handlePageRefs,handleMediaLibrary,handleMediaFolderDelete,handleMediaFolderDeletePlan,handleMediaUpload,handleSavePage,handlePublishStatus,handleElementEnabledininternal/server— the handlers whose order of checks or owner calls change.writeStructural(w, root, full, page, lang, doc)ininternal/server/translation_ops.go— needs to take, or be told, thesnapshotOpof the write.resolveForm/formRefininternal/server/forms.go— theformCreatebranch readsParent.elementL10nandelementForm.describe()ininternal/server—synceddepends onsrcSC.pageops.Rename,pageops.RenameTranslation,pageops.Create— share one title trim.elemops.itemDisabled/elemops.NewItemininternal/elemops/save.go— the one place a row's enabled state is read back; it should answer a field error throughelement.ReadEnabled.validMediaFolderininternal/server/media.goand the folder path check ininternal/mediaops/paths.go— which routes call which, per decision above.l10n.UnplacedError,ParentFirst(),l10n.UntranslatedUIDsininternal/l10n— item 13.internal/apimsg/codes_page.go,codes_server.go,codes_publish.go,codes_l10n.go— new codespage.rootRefs, an invalid-parameterrequest.code, an unknown-commitpublish.code; the three plainl10n.before*codes possibly removed.publishStatusmap and the repository handle ininternal/server/publish.go— the existence check.docs/api.md— one###section per route; the route drift test ininternal/serverkeeps the heading set, the content is on you.Acceptance criteria
mainand passes with the fix (item 13's test may instead prove the plain codes unreachable).internal/apimsgand every removed one has no remaining reference in Go, the client or the docs.docs/api.mddescribes the new behaviour for every touched route, with the error lists updated.snapshotOpsandinternal/server/testdata/undo_ops.jsonare unchanged.FormRefforkind: "create"still sendsparent; nothing inweb/srcneeds to change for items 10 to 12 unless a test asserts the old behaviour.scaffold/site/AGENTS.mdand the scaffolded skills are untouched (nothing they document changes); if a fix turns out to change a documented sidecar or command behaviour, update them in the same change.How to verify
go test ./...andgo vet ./...green, including the drift tests ininternal/server(routes ⇔docs/api.mdheadings,TestSnapshotOpsExport),docs/docs_test.goandinternal/apimsg.cd web && npm testgreen.harness initinto a temp dir, server started asdocs/harness.mddescribes), exercise with curl:link/resolve?url=https://x&lang=zz→ 404;page/refs?path=_index.md→ 400page.rootRefs;media/library?folder=<legacy name>→ 200 listing;media/folder/delete-planandmedia/folder/deletewith a\-spelled folder agree;publish/status?commit=zzz→ 400,commit=<40 hex not in repo>→ 404.create,delete,move,create.Assumptions to check
handleTranslateElementswaps toParentFirst()only whenUntranslatedUIDscontains the uid; verify anUnplacedErrorcan never arise for a uid outside that list before deleting the codes.pageopsexposes a parent-resolution and type-allowlist check callable at form time without creating anything;resolveParentand the type allowlist ininternal/server/pageops.goare the starting points.mediaopsfunction is the folder shape check and that it is importable byinternal/serverwithout a cycle.Out of scope
GET /api/media/tree.docs/architecture.md, ADRs,scaffold/site/AGENTS.md, the scaffolded skills.Blocked by
None - can start immediately.
Original report
Verifying docs/api.md against the handlers for #264 turned up these behaviours that look unintended. The docs describe the code as it is; each needs a decision.
GET /api/link/resolveanswersexternalandmediavalues before validatinglang, so an unknownlanggets a 200 (linktargets.go:180-224).POST /api/element/duplicatere-checks placement but not the slot'selementsallowlist, unlike insert, move and transfer (element.go:294-312).GET /api/page/refson the root_index.mdlists every file linking to any page (linktargets.go:376-409).GET /api/media/libraryrefuses a legacy non-slug folder withlibrary.folderInvalidthatGET /api/media/treelists and mediaops keeps addressable (media.go:220).GET /api/media/folder/delete-planturns\\into/;POST /api/media/folder/deletedoes not, so a plan can succeed and the delete be refused (media.go:778 vs 802).POST /api/media/uploaddecodes and re-encodes the whole file before refusing a wrongtarget/folder(media.go:157-166).enabledis silently enabled (elemops/save.go:335), while an element'senabledgetsfield.bool.handleSavePagedecodes its body itself instead ofdecodeBody(server.go:959) — a second owner of the rule.parentis decoded but never read for kindcreate(forms.go:301-327); the client sends it.readOnlywithnote.followsSourceElementeven when no source element matches by uid (translation.go:376-380, forms.go:582-591).writeStructural) and element enabled label their snapshotssave, soundoOpcannot tell them from a param save althoughdelete,move,enable,disableops exist.l10n.beforeSlot/beforeParent/beforePagecodes never reach the wire.GET /api/publish/statusanswersstaticfor any commit it does not know (publish.go:709-712), a mistyped sha included.Triage Notes
What we've established so far:
All fourteen items reproduce by reading the current
main(a0c4ce7);go testofinternal/server,pageops,elemopsandl10nis green, so none is covered by a test today. Locations as of now (translation_ops.goin the report isinternal/l10n/translate.gofor item 13; the dead branch's caller isinternal/server/translation_ops.gohandleTranslateElement):handleLinkResolveanswersexternalandmediabeforeopsRootvalidateslang.docs/api.mdcurrently documents the quirk ("external and media answer whatever lang says").handleElementDuplicaterunsplacementViolationbut neverelemops.SlotElementsMsg; insert, move and transfer run both.handlePageRefs: the root node collects every permalink, and the "exclude the bundle's own directory" step is skipped fordir == ".", so the root_index.mdlists every file linking to any page. The root permalink is/, so scoping it to "its own permalink" would match everything too. The root page cannot be deleted (pageops.Delete), so the question is only what the route should answer.handleMediaLibraryslug-checksfolderwithvalidMediaFolder;mediaops(paths.go) deliberately shape-checks, never slug-checks, so a legacy folder is listed by/api/media/tree, addressable by every mediaops route, and refused only by/api/media/library.handleMediaFolderDeletePlanpassesfolderthroughtrimFolderSlashes(\→/, trims/);handleMediaFolderDeletepassesreq.Folderraw tomediaops.DeleteFolderand only normalizes for the snapshot subject.handleMediaUploadrunsmedia.Process(decode + re-encode) before checkingtargetandfolder.pageops.RenameandRenameTranslationcheckTrimSpace(title) == ""but storetitleuntrimmed;Createstores the trimmed title.elemops.itemDisabledtreats any non-falsevalue (a string included) as enabled; an element's ownenabledgoes throughelement.ReadEnabled, which answers a field error for a non-boolean.handleSavePageinlines thejson.NewDecoder(...).Decode+request.malformedthatdecodeBodyowns.formRef.Parentis decoded; theformCreatebranch ofresolveFormreads onlyref.Typeandref.Lang. The client'sCreateDialogsends it.elementL10n:syncedis filled with every Appearance param whether or notsrcSCwas found, sodescribe()marks those fieldsreadOnlywithnote.followsSourceElementwhen no source element with that uid (and name) exists.writeStructuralsnapshots withopSave, so insert, delete, move and duplicate of an element, and/api/element/enabled, all show as "save" in the undo catalog;opCreate,opDelete,opMove,opEnable,opDisableexist and the client'sundo.op.<op>catalog already translates them.l10n.UnplacedErrorcarriesmsg(l10n.beforeSlot/beforeParent/beforePage) andparentFirst; the only caller (handleTranslateElement) swaps toParentFirst()when the uid is inUntranslatedUIDs, anddocs/api.mdlists only the…ParentFirstcodes. Whether the plain codes are truly unreachable still needs a test that proves it (see decision H).handlePublishStatusanswers{phase: "static", code: "publish.static"}for anycommitvalue the status map does not hold, with no shape or existence check.Affected surfaces (CLAUDE.md):
docs/api.mdsections for/api/link/resolve,/api/element/duplicate,/api/page/refs,/api/media/library,/api/media/folder/delete,/api/media/upload,/api/page/renameand/api/translation/rename,/api/page/save(items),/api/form,/api/translate/element,/api/publish/status, and the undo/snapshot vocabulary where it is described.scaffold/site/AGENTS.mdand the scaffolded skills: nothing (no sidecar, command, flag or template contract changes). Rule owners the fix must call rather than copy:opsRoot(1),elemops.SlotElementsMsg(2), the folder shape check inmediaops(4),trimFolderSlashesor amediaopsnormalizer both routes share (5), one title-trim inpageops(7),element.ReadEnabled(8),decodeBody(9),elementL10n(11).Recommendation: category
bug(as labelled), stateready-for-agent, as one issue: each item is a few lines plus a test and adocs/api.mdline, and none depends on another. Items 1, 2, 5, 6, 7, 8, 9 need no decision and would be fixed as described. The rest need one of these answers; each comes with the answer I would write into the brief if nobody objects.What we still need from the maintainer:
/api/page/refson the root_index.md— refuse with a 400page.rootRefscoded likepage.rootRename/page.rootMove/page.rootReorder(recommended: the root cannot be deleted, so the delete warning never asks), or answer{pages: []}?/api/media/librarylists a legacy non-slug folder, using mediaops' shape check for an existing folder and keeping the slug rule only where a folder is created (folder create, upload into a new folder) (recommended), or keep refusing and have/api/media/treehide legacy folders too?parenton acreateform ref — use it: the form answers the same refusals/api/page/createwould for a missing parent (page.notFound) or a type the parent's page type does not allow, so the dialog fails before the user fills it in (recommended, since the client already sends it); or dropparentfrom the client'sFormRefandformRefand document that a create form is parent-independent?readOnly, no note, not insynced) (recommended); or keep them read-only with a different note saying the source element is gone?create, delete →delete, move →move, enabled →enable/disable, subject unchanged (recommended: no vocabulary change, no client catalog change); or add element-specific ops (element delete…) so an undo step can tell a page delete from an element delete?/api/publish/status— acommitthat is not a 40-hex sha is400 request.invalidParam, a well-formed sha the repository does not have is404with a new code, andstaticis kept only for a real commit the server never polled (recommended); or shape check only?l10n.before*codes unreachable, delete them and collapseUnplacedErrorto one message (recommended); or keep them as a reachable fallback?