Skip to content

Keep note bodies through metadata updates - #12

Merged
jserv merged 3 commits into
mainfrom
fix
Oct 5, 2026
Merged

jserv merged 3 commits into
mainfrom
fix

Conversation

@jserv

@jserv jserv commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

HackMD is believed to blank a note's body when a PATCH omits content, so a metadata-only update (title, tags, permissions, permalink, folder) would wipe the note. This makes every note PATCH carry a body: a metadata update reads the current body and sends it back, UpdateNoteRequest requires content, and expected_hash is checked against that same read. The premise is unmeasured, and the official CLI sends metadata-only PATCHes, so the destructive suite now measures both halves of the contract (does an omitted content blank the body, and does a content-only PATCH keep the other fields) on a personal and a team note. If the body survives, the resend should be reverted.

Along the way: client.update_note is the only note PATCH and is never retried, since its body was read just before and a retry after backoff would overwrite edits made while waiting; a metadata update's read-back compares what was sent, normalized, instead of accepting a stale read; create's folder fallback prefers the body it created and never sends a blank; a 2xx write reply that cannot be parsed is readback (look, do not retry) rather than upstream; responses parse leniently, so one odd field no longer fails a whole note list.

Client-visible changes: new output fields suggest_edit_permission, pull body_hash, and update folder_placement_confirmed; expected_hash is now accepted on metadata-only updates; a metadata update on a note with no body fails with kind upstream; note PATCHes are no longer retried after a 429 or 5xx. Tool input schemas are unchanged (compared tools/list from HEAD and this branch: same 14 tools and fields, only descriptions differ), and no error kind was renamed.

Verified with make check (280 unit and 11 stdio tests), cargo clippy --all-targets --all-features -- -D warnings, cargo fmt --check, and a release build, all run on the committed tree. Each key behavior was mutation-checked: breaking it on purpose makes a test fail. The live destructive suite was not run, since it needs the dedicated test account.


Summary by cubic

Hedge against HackMD blanking a note's body when a PATCH omits content, and keep unconfirmed writes honest.

Changes

  • Every note PATCH carries a body; metadata-only updates read and resend the current body, expected_hash is checked against that read, and body-less notes are refused with kind upstream.
  • Note PATCHes are never retried, even after a 429, because a retry would overwrite edits made while waiting.
  • Metadata read-backs compare sent values normalized; folder moves are reported via folder_placement_confirmed.
  • Unreadable 2xx write replies are readback except image uploads; responses parse leniently; suggest_edit_permission and pull body_hash are new outputs.
  • The destructive suite now also measures the note PATCH contract, whether name-only folder PATCHes keep extra fields, and the team folder-order route, restoring the original order before assertions.

Verification

  • make check (280 unit and 11 stdio tests), clippy with -D warnings, cargo fmt --check, and a release build pass; each key behavior is mutation-checked; the live destructive suite was not run.

Written for commit 3236f49. Summary will update on new commits.

Review in cubic

HackMD is believed to blank a note's body when a PATCH leaves content
out. That is unmeasured, and the official CLI sends metadata-only
PATCHes, so the destructive suite now measures it. Until it does, a
metadata update reads the body and sends it back, and
UpdateNoteRequest requires content, so no note PATCH can be built
without one. expected_hash is checked against that same read, and is
now accepted on a metadata-only update. A note whose read has no body
is refused rather than blanked.

client.update_note is the only note PATCH; patch edits and pushes
borrow their body through it. It is never retried, even after a 429:
its body was read or checked just before, and a retry after backoff
would send it back over edits made while waiting. The 429 now says to
read the note again first.

A metadata update's read-back compares what was sent, normalized the
way HackMD may store it (tags as a trimmed set, a missing description
as empty, a permalink ignoring case), so a read from before the PATCH
is no longer reported as its result. Title and folder are not compared:
HackMD may derive the title from the body, and a folder read lists
ancestors in unverified order. A folder move reports
folder_placement_confirmed instead.

Create's folder fallback sends the body the create sent, else the one
it read, and with neither skips the PATCH. Its error tells the agent
not to create the note again. A 2xx reply to a write that cannot be
parsed is now kind readback, not upstream, since the write landed;
image upload keeps upstream, because nothing can look for an uploaded
image and a second upload only leaves an unused copy.

Responses parse leniently: an unknown enum value, an odd editor
profile, or a null list costs that one field rather than every note in
a list or every team lookup. Note output gains suggest_edit_permission,
and pull returns body_hash.

The destructive suite reports, without asserting, both halves of the
note PATCH contract on a personal and a team note, whether a
name-only folder PATCH keeps the other fields, and whether the team
folder-order route returns what was written.
cubic-dev-ai[bot]

This comment was marked as resolved.

jserv added 2 commits October 5, 2026 14:59
check_folder_order PUT a seeded order and put the original back only
after its read-back and assertions passed. A failed poll or a lost
parent unwound past the restore, and the outer cleanup removes only
the test's own notes and folders, so a failing run left the account's
folder order changed. The original now goes back before any check can
fail, including a read that panics on an HTTP error.
docs/tools.md said every note PATCH carries a body read or checked just
before. A plain content replacement without expected_hash reads
nothing first. It names the PATCHes that do, and says why the
replacement is not retried either: after a backoff it would still land
over edits made in the meantime.
@jserv
jserv merged commit f753a84 into main Oct 5, 2026
11 checks passed
@jserv
jserv deleted the fix branch October 5, 2026 07:40
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.

1 participant