domain: Micropub category add bypasses the 25-tag cap and bricks the post #51
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
rosa/vernier#51
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?
Found during a code review of the
domaincrate.Location:
crates/domain/src/models/micropub.rs:102(MicropubUpdate::apply_to)Severity: Correctness — permanent data corruption
Problem
apply_tomergesaddcategories with no dedupe and no 25-tag cap.PostEdit,update_post, and the non-transactional Postgres UPDATE do not re-validate, so a post pushed to 26 tags is committed, then failsPost::try_from(TooManyTags) on every subsequent read.Failure scenario
A post already at 25 tags receives a Micropub update adding one category. The UPDATE commits 26 tags, the call returns TooManyTags, and every later
find_post_by_slug/feed load of that row failsPost::new— the post is unreadable and unfixable through the domain.Suggested fix
Enforce the dedupe and cap in
apply_to/PostEditbefore the write (mirroringPost::new), so an over-cap update is rejected before it persists.Verified during triage: reproduced at the domain level. A
Postat 25 tags plus aMicropubUpdatewhoseCategoryUpdate::addholds one new tag produces a 26-tagPostEditfromapply_to, and aPostrebuilt from those tags failsPost::newwithTooManyTags. The write path commits before that failure surfaces, so the row persists and every later read of the post fails.Triage also found the same gap on two more paths, so the scope of this issue is all writes, not just Micropub update:
PostEditfrom comma-separated tag input that validates each tag individually but never counts or dedupes the set.Post, so an over-cap create bricks the post from birth.Agent Brief
Category: bug
Summary: Enforce the tag dedupe + 25-tag cap on every post write path so an over-cap tag set is rejected before it persists
Current behavior:
Post::newdedupes tags and rejects sets larger than 25 withDomainError::TooManyTags, but it only runs when a post is read back (viaPost::try_fromon a database row). The write paths never apply the rule:MicropubUpdate::apply_tomergesCategoryUpdate::addinto the existing tags with no dedupe and no cap.PostEdit.tagsfromTag::parse_comma_separated, which has no set-level validation.create_postpassesPostCreate.tagsstraight to the repository insert.The repository UPDATE/INSERT commits, then the
RETURNINGrow failsPost::try_from. The caller gets an opaque error, the over-cap row is already persisted, and every subsequent load of that post failsTooManyTags— the post is permanently unreadable and cannot be repaired through the domain.Desired behavior:
An edit or create whose deduplicated tag set exceeds 25 is rejected with a domain error before anything is written. Duplicates are collapsed before counting (mirroring
Post::new, where 50 raw tags deduping to 25 distinct is valid). A Micropubaddof a category the post already has is a no-op, not a step toward the cap. Rejections surface as proper validation errors to the client (Micropub error response / form error), not as an opaque internal error. Existing posts and in-range updates behave exactly as before.Key interfaces:
Post::new. Centralize it soPost::new,PostEdit, andPostCreateall share one implementation — e.g. a validated tag-collection value object or a shared constructor/validation function — rather than copying the check into each call site.MicropubUpdate::apply_toalready returnsResult<PostEdit, ApplyUpdateError>; an over-cap merge should become a newApplyUpdateErrorvariant that maps to an appropriateMicropubError(invalid_request) in the service layer.DomainErrorlike the per-tag errors it already returns.update_post/create_postmay keep thePost::try_fromon the returned row as a last-resort integrity check, but it must no longer be the only guard.Acceptance criteria:
addduplicates existing categories (deduped set ≤ 25) succeeds.replacewith more than 25 distinct categories is rejected before the write.Post,PostEdit, andPostCreate.cimise task passes.Out of scope:
Fixed by PR #73 (merged): the tag dedupe + 25-cap now lives in
Tag::dedupe_and_cap(with the cap asTag::MAX_PER_POST) and is enforced before the write on every path — Micropub update (addandreplace), Micropub create, and the web editor's new/edit forms. Over-cap writes are rejected with a validation error (Micropubinvalid_request/ form 400) instead of committing a row that bricks the post. Integration tests cover the original failure scenario and assert the stored post is untouched after a rejected update.