domain: deleting the published property on a live post leaves it Published but invisible #87

Closed
opened 2026-07-04 06:04:45 +00:00 by rosa · 2 comments
Owner

Found during triage of #53.

Location: crates/domain/src/models/micropub.rs (MicropubUpdate::apply_to, the published branch)
Severity: Correctness — silent invisibility

Problem

A Micropub update with delete: ["published"] on a Published post resolves published_at to None while the status stays Published. Post::is_live requires published_at.is_some_and(|p| p <= now), so the post silently drops out of public view, listings, and webmention dispatch while still reporting itself as Published. The current resolution behavior is codified by the clear_published_unsets_it unit test.

Why #53 doesn't cover this

The #53 fix is deliberately transition-based: it defaults published_at only when a status change to Published leaves it unset, and never counteracts a delete operation the client explicitly specified (Micropub update semantics define what the specified operations do; re-adding a property the client just deleted would contradict the request).

So this case needs its own semantics decision. Options:

  1. Reject — a Published post must carry a publish date; a delete of published without also leaving the published state is an invalid request.
  2. Honor literally — the post goes dark (today's behavior). If chosen, it should at least be a documented, intentional outcome rather than an accident.
  3. Reinterpret — e.g. treat it as reverting the post to draft. Probably the most surprising option.

Failure scenario

Publish a post via Micropub (published_at set), then send an update deleting the published property with no other changes. The post remains Published but is_live is false forever; only the author sees it, and no un-publish webmentions go out.

Found during triage of #53. **Location:** `crates/domain/src/models/micropub.rs` (`MicropubUpdate::apply_to`, the `published` branch) **Severity:** Correctness — silent invisibility ## Problem A Micropub update with `delete: ["published"]` on a Published post resolves `published_at` to `None` while the status stays `Published`. `Post::is_live` requires `published_at.is_some_and(|p| p <= now)`, so the post silently drops out of public view, listings, and webmention dispatch while still reporting itself as Published. The current resolution behavior is codified by the `clear_published_unsets_it` unit test. ## Why #53 doesn't cover this The #53 fix is deliberately transition-based: it defaults `published_at` only when a status change to Published leaves it unset, and never counteracts a `delete` operation the client explicitly specified (Micropub update semantics define what the specified operations do; re-adding a property the client just deleted would contradict the request). So this case needs its own semantics decision. Options: 1. **Reject** — a Published post must carry a publish date; a `delete` of `published` without also leaving the published state is an invalid request. 2. **Honor literally** — the post goes dark (today's behavior). If chosen, it should at least be a documented, intentional outcome rather than an accident. 3. **Reinterpret** — e.g. treat it as reverting the post to draft. Probably the most surprising option. ## Failure scenario Publish a post via Micropub (`published_at` set), then send an update deleting the `published` property with no other changes. The post remains `Published` but `is_live` is false forever; only the author sees it, and no un-publish webmentions go out.
Author
Owner

Triage

Confirmed against the code and the domain docs. This is the exact hole #53's
brief deferred ("the adjacent hole where an explicit delete of published on a
live post leaves it Published-but-invisible — separate semantics question,
reject vs. honor"), so the reproduction and location hold.

Labeling ready-for-human rather than ready-for-agent: the problem is fully
specified, but the fix is an undecided semantics decision among three options
with materially different behavior. An agent shouldn't pick between reject /
honor / reinterpret on its own — that's a maintainer call. Once the option is
chosen, the implementation itself is small and agent-sized.

Signal from the domain docs (toward Option 1, Reject)

The glossary defines Published as "a Post that has been released, carrying a
publication instant
." A Published post with no published_at is therefore a
state the ubiquitous language says cannot exist. ADR-0004 reinforces this: every
derived state that makes a Published post observable (live, Scheduled, the
viewability and listing rules) is defined in terms of published_at, so a
Published post without one falls through every rule to permanent author-only
invisibility.

That makes Option 2 (honor literally) an out-of-glossary state and Option 3
(reinterpret as draft)
a silent contradiction of the client's explicit
operation. Option 1 (Reject) — a delete of published that would leave the
post Published is an invalid request — is the only option consistent with the
domain model. A new ApplyUpdateError variant alongside the existing
ContentRequired / NonPublicVisibility would be the natural shape, and
clear_published_unsets_it would be re-scoped to the draft case (where clearing
is harmless).

Note this is a recommendation, not a decision — flagging it for the maintainer.

## Triage Confirmed against the code and the domain docs. This is the exact hole #53's brief deferred ("the adjacent hole where an explicit delete of `published` on a live post leaves it Published-but-invisible — separate semantics question, reject vs. honor"), so the reproduction and location hold. Labeling **ready-for-human** rather than ready-for-agent: the *problem* is fully specified, but the *fix* is an undecided semantics decision among three options with materially different behavior. An agent shouldn't pick between reject / honor / reinterpret on its own — that's a maintainer call. Once the option is chosen, the implementation itself is small and agent-sized. ### Signal from the domain docs (toward Option 1, Reject) The glossary defines **Published** as "a Post that has been released, *carrying a publication instant*." A Published post with no `published_at` is therefore a state the ubiquitous language says cannot exist. ADR-0004 reinforces this: every derived state that makes a Published post observable (live, Scheduled, the viewability and listing rules) is defined in terms of `published_at`, so a Published post without one falls through every rule to permanent author-only invisibility. That makes **Option 2 (honor literally)** an out-of-glossary state and **Option 3 (reinterpret as draft)** a silent contradiction of the client's explicit operation. **Option 1 (Reject)** — a `delete` of `published` that would leave the post Published is an invalid request — is the only option consistent with the domain model. A new `ApplyUpdateError` variant alongside the existing `ContentRequired` / `NonPublicVisibility` would be the natural shape, and `clear_published_unsets_it` would be re-scoped to the draft case (where clearing is harmless). Note this is a recommendation, not a decision — flagging it for the maintainer.
Author
Owner

Decision: Option 1 (Reject)

A delete of published that would leave the post Published is an invalid
request. Chosen because the glossary defines Published as "carrying a
publication instant," so Published-without-published_at is a state the domain
language says cannot exist (see the triage comment above).

Agent Brief

Category: bug
Location: crates/domain/src/models/micropub.rs (MicropubUpdate::apply_to)

Summary: Reject any Micropub update whose resolved edit would be
status = Published with published_at = None, with a dedicated error.

Current behavior:
apply_to resolves status and published_at independently and can produce an
edit with status = Published, published_at = None — e.g. a Clear of
published on a live post. The resulting post stays Published but is_live is
false forever (silently invisible; no un-publish webmentions).

Desired behavior:
Make "a Published post carries a publication instant" an enforced invariant of
resolution. After resolving status and published_at, if
status.is_published() && published_at.is_none(), return a new error variant
instead of an Ok(PostEdit). The check is resulting-state-based, so it
covers every path into that state, not just the delete-on-live case.

Do the check after the existing #53 draft→publish defaulting so a plain
draft→publish (no published operation) still defaults to now and passes.
It only fires when the client's own operations resolve to Published + no date.

Key interfaces:

  • MicropubUpdate::apply_to (crates/domain/src/models/micropub.rs:67) — add
    the guard just before constructing PostEdit.
  • ApplyUpdateError (crates/domain/src/errors.rs:179) — add a variant,
    e.g. PublishedRequiresDate ("a published post requires a publication date"),
    parallel to ContentRequired / NonPublicVisibility.
  • MicropubError (crates/domain/src/errors.rs:91) — add the matching wire
    variant, and map it at the boundary in
    crates/domain/src/services.rs:896 (the apply_to(...).map_err(...) arm),
    alongside the existing three arms.

Behavior changes to existing tests (intended — call out, don't silently keep):

  • clear_published_unsets_it (micropub.rs:281) runs on a Published post,
    so clearing published now rejects. Re-scope it to a draft post (clearing
    published on a draft is harmless: stays Draft + None) so it still
    demonstrates that a bare Clear unsets the date where that is legal.
  • publishing_a_draft_that_also_clears_published_stays_unset (micropub.rs:324)
    currently asserts (Published, None). That is exactly the now-invalid state —
    flip it to assert the new PublishedRequiresDate error.

Acceptance criteria:

  • A live/Published post + a Clear of published (no other changes)
    resolves to Err(PublishedRequiresDate).
  • A draft + status→Published + Clear of published resolves to the same
    error (resulting-state rule, not path-specific).
  • A plain draft→Published transition with no published operation still
    defaults published_at to now and succeeds (#53 behavior preserved).
  • Clearing published on a post that stays a Draft still unsets it and
    succeeds.
  • The error surfaces through micropub_update as the new MicropubError
    variant.
  • format, ci, and clippy mise tasks pass.

Out of scope:

  • The web editor form path (a separate surface).
  • is_live / Scheduled semantics — unchanged; they are the observers, not the
    bug.
## Decision: Option 1 (Reject) A `delete` of `published` that would leave the post Published is an invalid request. Chosen because the glossary defines **Published** as "carrying a publication instant," so Published-without-`published_at` is a state the domain language says cannot exist (see the triage comment above). ## Agent Brief **Category:** bug **Location:** `crates/domain/src/models/micropub.rs` (`MicropubUpdate::apply_to`) **Summary:** Reject any Micropub update whose *resolved* edit would be `status = Published` with `published_at = None`, with a dedicated error. **Current behavior:** `apply_to` resolves `status` and `published_at` independently and can produce an edit with `status = Published, published_at = None` — e.g. a `Clear` of `published` on a live post. The resulting post stays Published but `is_live` is false forever (silently invisible; no un-publish webmentions). **Desired behavior:** Make "a Published post carries a publication instant" an enforced invariant of resolution. After resolving `status` and `published_at`, if `status.is_published() && published_at.is_none()`, return a new error variant instead of an `Ok(PostEdit)`. The check is **resulting-state-based**, so it covers every path into that state, not just the `delete`-on-live case. Do the check *after* the existing #53 draft→publish defaulting so a plain draft→publish (no `published` operation) still defaults to `now` and passes. It only fires when the client's own operations resolve to Published + no date. **Key interfaces:** - `MicropubUpdate::apply_to` (`crates/domain/src/models/micropub.rs:67`) — add the guard just before constructing `PostEdit`. - `ApplyUpdateError` (`crates/domain/src/errors.rs:179`) — add a variant, e.g. `PublishedRequiresDate` ("a published post requires a publication date"), parallel to `ContentRequired` / `NonPublicVisibility`. - `MicropubError` (`crates/domain/src/errors.rs:91`) — add the matching wire variant, and map it at the boundary in `crates/domain/src/services.rs:896` (the `apply_to(...).map_err(...)` arm), alongside the existing three arms. **Behavior changes to existing tests (intended — call out, don't silently keep):** - `clear_published_unsets_it` (`micropub.rs:281`) runs on a **Published** post, so clearing `published` now rejects. Re-scope it to a **draft** post (clearing `published` on a draft is harmless: stays Draft + `None`) so it still demonstrates that a bare `Clear` unsets the date where that is legal. - `publishing_a_draft_that_also_clears_published_stays_unset` (`micropub.rs:324`) currently asserts `(Published, None)`. That is exactly the now-invalid state — flip it to assert the new `PublishedRequiresDate` error. **Acceptance criteria:** - [ ] A live/Published post + a `Clear` of `published` (no other changes) resolves to `Err(PublishedRequiresDate)`. - [ ] A draft + status→Published + `Clear` of `published` resolves to the same error (resulting-state rule, not path-specific). - [ ] A plain draft→Published transition with no `published` operation still defaults `published_at` to `now` and succeeds (#53 behavior preserved). - [ ] Clearing `published` on a post that stays a **Draft** still unsets it and succeeds. - [ ] The error surfaces through `micropub_update` as the new `MicropubError` variant. - [ ] `format`, `ci`, and `clippy` mise tasks pass. **Out of scope:** - The web editor form path (a separate surface). - `is_live` / Scheduled semantics — unchanged; they are the observers, not the bug.
rosa closed this issue 2026-07-26 17:59:40 +00:00
Sign in to join this conversation.
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#87
No description provided.