fix: Enforce the tag dedupe and 25-cap on every post write path #73

Merged
rosa merged 2 commits from fix/tag-cap-on-writes into main 2026-07-03 17:16:42 +00:00
Owner

Fixes #51.

Problem

Post::new dedupes tags and rejects sets larger than 25, but the rule only ran when a post was read back from a row. Every write path skipped it, so an over-cap tag set committed, then failed Post::try_from on the RETURNING row — the post persisted in a state every subsequent read rejects with TooManyTags, permanently unreadable.

Fix

The dedupe + cap rule now lives in exactly one place, Tag::dedupe_and_cap (with the cap as Tag::MAX_PER_POST), applied at every construction of a Post/PostEdit/PostCreate tag list:

  • Post::new delegates to it (read-side behavior unchanged).
  • MicropubUpdate::apply_to dedupes and caps the resolved category set: an add of a category the post already has is a no-op, and an over-cap merge or replace is the new ApplyUpdateError::TooManyTags, mapped to a Micropub invalid_request in the service layer.
  • micropub_create validates categories before building its PostCreate.
  • The web new/edit form extractors reject an over-cap comma-separated set with DomainError::TooManyTags, surfacing as a 400 like their per-tag errors — not a 500.

The repository's Post::try_from on the returned row stays as a last-resort integrity check, but is no longer the only guard. No limits changed, no migration, no persistence restructuring.

Tests

  • Domain: over-cap add/replace rejected, duplicate add is a no-op at the cap, replace dedupes before counting, dedupe_and_cap unit tests.
  • Web: over-cap new/edit form rejected as bad request; forms dedupe.
  • Integration: Micropub create with 26 categories → 400 with nothing inserted; add beyond the cap on a 25-tag post → 400 with the post untouched and still readable; duplicate add → 204; web edit form with 26 tags → 400, post still readable.

The ci mise task passes.

Fixes #51. ## Problem `Post::new` dedupes tags and rejects sets larger than 25, but the rule only ran when a post was read back from a row. Every write path skipped it, so an over-cap tag set committed, then failed `Post::try_from` on the RETURNING row — the post persisted in a state every subsequent read rejects with `TooManyTags`, permanently unreadable. ## Fix The dedupe + cap rule now lives in exactly one place, `Tag::dedupe_and_cap` (with the cap as `Tag::MAX_PER_POST`), applied at every construction of a `Post`/`PostEdit`/`PostCreate` tag list: - `Post::new` delegates to it (read-side behavior unchanged). - `MicropubUpdate::apply_to` dedupes and caps the resolved category set: an `add` of a category the post already has is a no-op, and an over-cap merge or `replace` is the new `ApplyUpdateError::TooManyTags`, mapped to a Micropub `invalid_request` in the service layer. - `micropub_create` validates categories before building its `PostCreate`. - The web new/edit form extractors reject an over-cap comma-separated set with `DomainError::TooManyTags`, surfacing as a 400 like their per-tag errors — not a 500. The repository's `Post::try_from` on the returned row stays as a last-resort integrity check, but is no longer the only guard. No limits changed, no migration, no persistence restructuring. ## Tests - Domain: over-cap `add`/`replace` rejected, duplicate `add` is a no-op at the cap, `replace` dedupes before counting, `dedupe_and_cap` unit tests. - Web: over-cap new/edit form rejected as bad request; forms dedupe. - Integration: Micropub create with 26 categories → 400 with nothing inserted; `add` beyond the cap on a 25-tag post → 400 with the post untouched and still readable; duplicate `add` → 204; web edit form with 26 tags → 400, post still readable. The `ci` mise task passes.
fix: Enforce the tag dedupe and 25-cap on every post write path
All checks were successful
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
d7e6874bd2
Post::new deduped tags and rejected sets larger than 25, but that rule
only ran when a post was read back from a database row. The write paths
never applied it: MicropubUpdate::apply_to merged category adds with no
dedupe or cap, the web editor forms passed Tag::parse_comma_separated
output straight through, and micropub_create handed categories to the
repository insert untouched. An over-cap write committed, then failed
Post::try_from on the RETURNING row, leaving the post permanently
unreadable.

Centralize the rule in Tag::dedupe_and_cap and apply it at every
construction of a Post, PostEdit, or PostCreate tag list:

- Post::new delegates to it (behavior unchanged).
- apply_to dedupes and caps the resolved category set, so adding a
  category the post already has is a no-op rather than a step toward
  the cap; an over-cap merge is the new ApplyUpdateError::TooManyTags,
  mapped to a Micropub invalid_request in the service layer.
- The web new/edit form extractors reject an over-cap set with
  DomainError::TooManyTags, like the per-tag errors they already return.
- micropub_create validates categories before building the PostCreate.

The repository's Post::try_from on the returned row remains as a
last-resort integrity check, but is no longer the only guard.

Fixes #51
refactor: Address review feedback on the tag-cap guard
All checks were successful
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ee9a87ed7f
- Give MicropubError its own TooManyTags variant (rendered as
  invalid_request, like its siblings) and map to it directly from
  ApplyUpdateError::TooManyTags and micropub_create, instead of
  round-tripping through DomainError.
- Derive the number in every too-many-tags message from
  Tag::MAX_PER_POST so the constant is the single encoding of the cap.
- Use Tag::MAX_PER_POST in the new unit tests instead of bare 25/26
  literals.
- Add end-to-end coverage: a Micropub replace with an over-cap category
  set is rejected with the post untouched, and the new-post web form
  rejects an over-cap tag set without inserting anything.
- Assert the rejected category is absent after an over-cap Micropub
  add, directly checking the stored tags are unchanged.
rosa merged commit 823c9fe7bf into main 2026-07-03 17:16:42 +00:00
rosa deleted branch fix/tag-cap-on-writes 2026-07-03 17:16:42 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
rosa/vernier!73
No description provided.