domain: publishing a draft via Micropub update leaves published_at unset, post never goes live #53

Closed
opened 2026-07-03 01:41:44 +00:00 by rosa · 2 comments
Owner

Found during a code review of the domain crate.

Location: crates/domain/src/models/micropub.rs:76 (MicropubUpdate::apply_to)
Severity: Correctness — silent invisibility

Problem

An update that replaces post-status with published sets status=Published but carries published_at=None when the update does not also set a published property. Post::is_live requires published_at.is_some_and(|p| p <= now), so the post is never viewable, listed, or webmention-dispatched. The create path defaults published_at to Utc::now, but apply_to has no counterpart.

Failure scenario

Create a draft via Micropub (published_at=None), then send an update flipping post-status to published with no published property. The post is marked Published but is_live stays false forever; only the author sees it.

Suggested fix

In apply_to, when a status transition to Published leaves published_at unset, default it to now (matching the create path).

Found during a code review of the `domain` crate. **Location:** `crates/domain/src/models/micropub.rs:76` (`MicropubUpdate::apply_to`) **Severity:** Correctness — silent invisibility ## Problem An update that replaces post-status with `published` sets `status=Published` but carries `published_at=None` when the update does not also set a `published` property. `Post::is_live` requires `published_at.is_some_and(|p| p <= now)`, so the post is never viewable, listed, or webmention-dispatched. The create path defaults `published_at` to `Utc::now`, but `apply_to` has no counterpart. ## Failure scenario Create a draft via Micropub (published_at=None), then send an update flipping post-status to published with no `published` property. The post is marked Published but `is_live` stays false forever; only the author sees it. ## Suggested fix In `apply_to`, when a status transition to Published leaves `published_at` unset, default it to `now` (matching the create path).
Author
Owner

Agent Brief

Category: bug
Summary: Publishing a draft via Micropub update must default published_at to the current instant, so the post actually goes live.

Current behavior:
MicropubUpdate::apply_to resolves an update against the existing post. When the update replaces post-status with published but does not include the published property, the resulting PostEdit carries the post's existing published_at — which is None for a draft. Post::is_live requires a published status and a published_at at or before now, so the post is marked Published but never becomes viewable, publicly listed, or webmention-dispatched; only the author can see it. Confirmed by a reproduction test during triage.

The Micropub create path already handles this: a non-draft create defaults published_at to the current instant when the client omits published. The update path has no counterpart.

Desired behavior:
When an update transitions a post's status to Published, does not specify the published property, and the post has no existing published_at, the resolved edit's published_at defaults to the current instant — mirroring the create path. The post is live immediately after such an update.

The trigger is deliberately transition-based, not resulting-state-based:

  • If the update supplies an explicit published value alongside the status change, the client's value wins (already the case today).
  • If the update explicitly deletes published, that client-specified operation must not be counteracted by re-defaulting — the existing clear-unsets-it behavior stays, even in the same request as a status change.
  • An update to an already-published post that doesn't touch published keeps the existing published_at unchanged.

Spec note (checked during triage): post-status is an IndieWeb extension, not part of the core Micropub spec, and the spec's update semantics constrain only the properties the client specified — it is silent on the server defaulting an unmentioned published. Defaulting on the draft→publish transition matches the create-path behavior and ecosystem practice (e.g. the WordPress Micropub plugin sets the publish date at the moment of publishing).

Key interfaces:

  • MicropubUpdate::apply_to(&self, post: &Post) — the resolution point. It is currently pure with no clock; take the current instant as a parameter (e.g. now: DateTime<Utc>) supplied by the caller rather than calling Utc::now() inside, so the function stays deterministic in tests. The Micropub update service method that calls it already deals in the current time.
  • PostEdit.published_at — where the defaulted value lands. No shape change needed.
  • The create-path defaulting in the Micropub create service method (published.unwrap_or_else(Utc::now) for non-draft status) is the behavior to mirror.
  • Post::is_live — unchanged; it is the observer that makes the bug visible.

Acceptance criteria:

  • A draft with no published_at, updated with only a post-status replace to published, resolves to an edit with status = Published and published_at = now; the post is live immediately after the update persists.
  • The same transition with an explicit published value in the update uses the client's value, not now.
  • The same transition combined with an explicit delete of published does not get a defaulted date.
  • An update to an already-published post that leaves published untouched keeps the existing published_at.
  • Clearing published without a status change still unsets it (existing test behavior preserved).
  • The ci and clippy mise tasks pass.

Out of scope:

  • The adjacent hole where an explicit delete of published on a live post leaves it Published-but-invisible — separate semantics question (reject vs. honor), separate issue if pursued.
  • The web editor form, which can also produce Published with an empty publish date — separate surface.
  • Scheduled-post behavior (published in the future) and is_live semantics — unchanged.
## Agent Brief **Category:** bug **Summary:** Publishing a draft via Micropub update must default `published_at` to the current instant, so the post actually goes live. **Current behavior:** `MicropubUpdate::apply_to` resolves an update against the existing post. When the update replaces `post-status` with `published` but does not include the `published` property, the resulting `PostEdit` carries the post's existing `published_at` — which is `None` for a draft. `Post::is_live` requires a published status **and** a `published_at` at or before now, so the post is marked Published but never becomes viewable, publicly listed, or webmention-dispatched; only the author can see it. Confirmed by a reproduction test during triage. The Micropub create path already handles this: a non-draft create defaults `published_at` to the current instant when the client omits `published`. The update path has no counterpart. **Desired behavior:** When an update transitions a post's status to Published, does not specify the `published` property, and the post has no existing `published_at`, the resolved edit's `published_at` defaults to the current instant — mirroring the create path. The post is live immediately after such an update. The trigger is deliberately **transition-based**, not resulting-state-based: - If the update supplies an explicit `published` value alongside the status change, the client's value wins (already the case today). - If the update explicitly deletes `published`, that client-specified operation must not be counteracted by re-defaulting — the existing clear-unsets-it behavior stays, even in the same request as a status change. - An update to an already-published post that doesn't touch `published` keeps the existing `published_at` unchanged. Spec note (checked during triage): `post-status` is an IndieWeb extension, not part of the core Micropub spec, and the spec's update semantics constrain only the properties the client specified — it is silent on the server defaulting an unmentioned `published`. Defaulting on the draft→publish transition matches the create-path behavior and ecosystem practice (e.g. the WordPress Micropub plugin sets the publish date at the moment of publishing). **Key interfaces:** - `MicropubUpdate::apply_to(&self, post: &Post)` — the resolution point. It is currently pure with no clock; take the current instant as a parameter (e.g. `now: DateTime<Utc>`) supplied by the caller rather than calling `Utc::now()` inside, so the function stays deterministic in tests. The Micropub update service method that calls it already deals in the current time. - `PostEdit.published_at` — where the defaulted value lands. No shape change needed. - The create-path defaulting in the Micropub create service method (`published.unwrap_or_else(Utc::now)` for non-draft status) is the behavior to mirror. - `Post::is_live` — unchanged; it is the observer that makes the bug visible. **Acceptance criteria:** - [ ] A draft with no `published_at`, updated with only a `post-status` replace to `published`, resolves to an edit with `status = Published` and `published_at = now`; the post is live immediately after the update persists. - [ ] The same transition with an explicit `published` value in the update uses the client's value, not now. - [ ] The same transition combined with an explicit delete of `published` does not get a defaulted date. - [ ] An update to an already-published post that leaves `published` untouched keeps the existing `published_at`. - [ ] Clearing `published` without a status change still unsets it (existing test behavior preserved). - [ ] The `ci` and `clippy` mise tasks pass. **Out of scope:** - The adjacent hole where an explicit delete of `published` on a live post leaves it Published-but-invisible — separate semantics question (reject vs. honor), separate issue if pursued. - The web editor form, which can also produce Published with an empty publish date — separate surface. - Scheduled-post behavior (`published` in the future) and `is_live` semantics — unchanged.
Author
Owner

Fixed in d3aaaa7. MicropubUpdate::apply_to now takes the current instant and, on a draft→publish transition with no client-supplied published, defaults published_at to now — mirroring the create path — so the post goes live immediately. Explicit published (replace or delete) still wins, and an already-published post keeps its date. Covered by new tests; ci and clippy pass.

Fixed in d3aaaa7. `MicropubUpdate::apply_to` now takes the current instant and, on a draft→publish transition with no client-supplied `published`, defaults `published_at` to now — mirroring the create path — so the post goes live immediately. Explicit `published` (replace or delete) still wins, and an already-published post keeps its date. Covered by new tests; ci and clippy pass.
rosa closed this issue 2026-07-04 16:22:38 +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#53
No description provided.