One front door for post writes: enforce post-state invariants at a single write seam #139

Closed
opened 2026-08-07 23:38:00 +00:00 by rosa · 1 comment
Owner

Problem

The universal post-state invariants are expressed in up to four separate resolvers and enforced inconsistently. The same domain rule is caught for Micropub but slips through the web editor.

Path Resolver publish-requires-date (#87) draft→publish defaults date=now (#53)
Web create TryFrom<&NewPostForm> for PostCreate (crates/web/src/extractors/forms.rs:122)
Web update TryFrom<&EditPostForm> for PostEdit (crates/web/src/extractors/forms.rs:154)
Micropub create micropub_create inline (crates/domain/src/services.rs:1173) (defaults)
Micropub update MicropubUpdate::apply_to (crates/domain/src/models/micropub.rs:91, pure, ~30 tests)

The service methods create_post (services.rs:1030) and update_post (services.rs:1691) are pass-throughs — ownership check + repo write + webmention dispatch — and enforce no state invariants. Post::new (crates/domain/src/models/post.rs:379) is the reconstruction constructor and enforces only Tag::dedupe_and_cap; it must stay total to rebuild whatever the DB holds, so it cannot be the write seam.

Concrete latent bug: a web edit that sets status=Published but leaves the date blank writes (Published, None). Post::is_live requires published_at.is_some(), so the post is silently invisible — the exact state #87 rejects on the Micropub side.

The deepening

One deep write seam in the domain that owns the universal invariants once, so every edge (web create, web update, micropub create, micropub update) funnels its intent through it. The seam is the intent type itself: make PostCreate / PostEdit fields private and construction fallible, so an invalid intent is unconstructable and the compiler forces all four edges through the same door.

Some rules are not universal and stay at their edge — Micropub is public-only (ADR-0007) and slug-frozen (ADR-0004); the web editor legitimately allows Unlisted/Private and slug rename. The classification:

Rule Home
content-required typePostCreate.content / PostEdit.content are non-optional Content; already enforced
publish-requires-date (#87) constructor (PostCreate::new / PostEdit::new) — pure on resolved fields
tag dedupe+cap (ADR-0005) constructor — pure on resolved fields
draft→publish defaults date=now (#53) producer — needs prior status + now, not pure on resolved state
Micropub public-only (ADR-0007), slug-frozen (ADR-0004) edge — Micropub producers keep the non-public / response-property rejections
web allows Unlisted/Private, slug rename edge — web producers

Shape

  • Two validating constructors in models/post.rs: PostCreate::new(...) -> Result<_, DomainError> and PostEdit::new(...) -> Result<_, DomainError>, enforcing publish-requires-date + tag-cap. Private fields.
  • Two shared pure helpers in models/post.rs, no forced single resolver (create is total, update is partial-against-existing — unifying them would leak the prior-post dependency into the create path):
    • check_publish_state(status, published_at) — the invariant predicate both constructors call.
    • resolve_publish_instant(prev: Option<&Status>, next: &Status, given: Option<OffsetDateTime>, now) — the #53 defaulting, called by apply_to (update) and both create edges. This is the real create/update DRY win.
  • Producers stay per-edge and keep their genuinely surface-specific derivation:
    • MicropubUpdate::apply_to(post, now) keeps non-public + content-required-on-clear rejections, calls resolve_publish_instant, and ends by calling PostEdit::new.
    • micropub_create keeps scope gate, response-property + non-public rejection, and slug derivation (mp-slug / derive / generate); calls resolve_publish_instant(prev = None, …) then PostCreate::new.
    • Web: replace TryFrom<&NewPostForm> for PostCreate / TryFrom<&EditPostForm> for PostEdit with explicit producer fns resolve_new_post(&form, photo, now) / resolve_edit(&form, photo, now). TryFrom is single-argument and can't thread now; deleting it also removes a "plain conversion" that today disguises a resolver enforcing nothing. Slug handling stays as-is (web-create derives from title, web-edit takes the form slug). The already-resolved Option<PhotoRef> is passed in (photo resolution needs the repo, ADR-0019, and already happens before the intent is built in the handler).
  • Service methods create_post / update_post stay thin writers — they now receive an already-validated intent and trust the type.
  • Error plumbing (8-i): apply_to keeps its ApplyUpdateError shape for producer-only rejections (content-required-on-clear, non-public) and maps the constructor's DomainError (publish-requires-date, tag-cap) into the matching variants, so micropub_update's existing match and the web From impls are untouched. This keeps the change off the separate error-taxonomy consolidation.

Behavior change (intended)

Under correctness-unification, the web create/update paths start rejecting/normalizing inputs they used to accept silently:

  • Blank date + Published on the web editor now resolves to now (matches Micropub #53) instead of writing a silently-invisible post.
  • An explicit Published-with-no-date that isn't a defaulting transition is rejected (#87), on the web side too.

Implementation phases (tiny commits, each compiles + mise run ci green)

  1. Add helpers + constructors alongside the existing public fields (non-breaking). check_publish_state, resolve_publish_instant, PostCreate::new, PostEdit::new in models/post.rs, with pure unit tests. Fields stay pub for now.
  2. Route apply_to through resolve_publish_instant + PostEdit::new; map DomainErrorApplyUpdateError (8-i). Move the canonical #87 / tag-cap tests down to the constructor; keep one surfacing test each in apply_to.
  3. Route micropub_create through resolve_publish_instant + PostCreate::new. Thin the service-level micropub tests to edge-wiring regressions.
  4. Replace the web TryFroms with resolve_new_post / resolve_edit, threading now from the handlers and passing the resolved photo in. Migrate the forms.rs tests to the new producers; add a web-producer test proving blank-date + Published resolves to now.
  5. Seal the seam: make PostCreate / PostEdit fields private now that every construction routes through ::new. Fix any remaining direct field access in fixtures.

Test migration

  • New, authoritative at the seam: pure unit tests on PostCreate::new / PostEdit::new (publish-requires-date, tag-cap) and on resolve_publish_instant (defaulting matrix: prev None/Draft/Published × date given/absent). No repo, so plain unit tests — the domain-crate conformance rule doesn't apply (nothing touches a mock).
  • Stay on apply_to: producer-behavior tests — carry-through, tag add/remove merge, clear-keeps-current, non-public rejection, content-required-on-clear, the #53 transition defaulting.
  • Move down: the two pure hard-invariant tests currently asserted through apply_to get their canonical home on the constructor; apply_to keeps one surfacing test each.
  • New in the web crate: a resolve_new_post / resolve_edit test with a fixed now proving the blank-date-on-publish fix.
  • Thin, don't gut: micropub_create service tests stay as edge-wiring regressions but stop being the only proof of any invariant.

Principle: each invariant proven once at the seam, each producer proven to feed the seam, no invariant provable only through a service method.

  • Extends the shape ADR-0019 already blessed for media ("one domain op behind multiple adapters") to post writes.
  • Surfaced by the architecture review as candidate 2 ("one front door for post writes"). Related but out of scope: the error-taxonomy consolidation (kept separate by decision 8-i).
## Problem The universal post-state invariants are expressed in up to four separate resolvers and enforced **inconsistently**. The same domain rule is caught for Micropub but slips through the web editor. | Path | Resolver | publish-requires-date (#87) | draft→publish defaults date=`now` (#53) | | --- | --- | --- | --- | | Web create | `TryFrom<&NewPostForm> for PostCreate` (`crates/web/src/extractors/forms.rs:122`) | ❌ | ❌ | | Web update | `TryFrom<&EditPostForm> for PostEdit` (`crates/web/src/extractors/forms.rs:154`) | ❌ | ❌ | | Micropub create | `micropub_create` inline (`crates/domain/src/services.rs:1173`) | (defaults) | ✅ | | Micropub update | `MicropubUpdate::apply_to` (`crates/domain/src/models/micropub.rs:91`, pure, ~30 tests) | ✅ | ✅ | The service methods `create_post` (`services.rs:1030`) and `update_post` (`services.rs:1691`) are pass-throughs — ownership check + repo write + webmention dispatch — and enforce **no** state invariants. `Post::new` (`crates/domain/src/models/post.rs:379`) is the reconstruction constructor and enforces only `Tag::dedupe_and_cap`; it must stay total to rebuild whatever the DB holds, so it cannot be the write seam. **Concrete latent bug:** a web edit that sets status=Published but leaves the date blank writes `(Published, None)`. `Post::is_live` requires `published_at.is_some()`, so the post is **silently invisible** — the exact state #87 rejects on the Micropub side. ## The deepening One deep write seam in the domain that owns the universal invariants once, so every edge (web create, web update, micropub create, micropub update) funnels its intent through it. The seam is the **intent type itself**: make `PostCreate` / `PostEdit` fields private and construction fallible, so an invalid intent is unconstructable and the compiler forces all four edges through the same door. Some rules are **not** universal and stay at their edge — Micropub is public-only (ADR-0007) and slug-frozen (ADR-0004); the web editor legitimately allows Unlisted/Private and slug rename. The classification: | Rule | Home | | --- | --- | | content-required | **type** — `PostCreate.content` / `PostEdit.content` are non-optional `Content`; already enforced | | publish-requires-date (#87) | **constructor** (`PostCreate::new` / `PostEdit::new`) — pure on resolved fields | | tag dedupe+cap (ADR-0005) | **constructor** — pure on resolved fields | | draft→publish defaults date=`now` (#53) | **producer** — needs prior status + `now`, not pure on resolved state | | Micropub public-only (ADR-0007), slug-frozen (ADR-0004) | **edge** — Micropub producers keep the non-public / response-property rejections | | web allows Unlisted/Private, slug rename | **edge** — web producers | ### Shape - **Two validating constructors** in `models/post.rs`: `PostCreate::new(...) -> Result<_, DomainError>` and `PostEdit::new(...) -> Result<_, DomainError>`, enforcing publish-requires-date + tag-cap. Private fields. - **Two shared pure helpers** in `models/post.rs`, no forced single resolver (create is total, update is partial-against-existing — unifying them would leak the prior-post dependency into the create path): - `check_publish_state(status, published_at)` — the invariant predicate both constructors call. - `resolve_publish_instant(prev: Option<&Status>, next: &Status, given: Option<OffsetDateTime>, now)` — the #53 defaulting, called by `apply_to` (update) **and** both create edges. This is the real create/update DRY win. - **Producers** stay per-edge and keep their genuinely surface-specific derivation: - `MicropubUpdate::apply_to(post, now)` keeps non-public + content-required-on-clear rejections, calls `resolve_publish_instant`, and ends by calling `PostEdit::new`. - `micropub_create` keeps scope gate, response-property + non-public rejection, and slug derivation (`mp-slug` / derive / generate); calls `resolve_publish_instant(prev = None, …)` then `PostCreate::new`. - Web: **replace** `TryFrom<&NewPostForm> for PostCreate` / `TryFrom<&EditPostForm> for PostEdit` with explicit producer fns `resolve_new_post(&form, photo, now)` / `resolve_edit(&form, photo, now)`. `TryFrom` is single-argument and can't thread `now`; deleting it also removes a "plain conversion" that today disguises a resolver enforcing nothing. Slug handling stays as-is (web-create derives from title, web-edit takes the form slug). The already-resolved `Option<PhotoRef>` is passed in (photo resolution needs the repo, ADR-0019, and already happens before the intent is built in the handler). - **Service methods** `create_post` / `update_post` stay thin writers — they now receive an already-validated intent and trust the type. - **Error plumbing (8-i):** `apply_to` keeps its `ApplyUpdateError` shape for producer-only rejections (content-required-on-clear, non-public) and **maps** the constructor's `DomainError` (publish-requires-date, tag-cap) into the matching variants, so `micropub_update`'s existing `match` and the web `From` impls are untouched. This keeps the change off the separate error-taxonomy consolidation. ## Behavior change (intended) Under correctness-unification, the web create/update paths **start rejecting/normalizing** inputs they used to accept silently: - Blank date + Published on the web editor now **resolves to `now`** (matches Micropub #53) instead of writing a silently-invisible post. - An explicit Published-with-no-date that isn't a defaulting transition is **rejected** (#87), on the web side too. ## Implementation phases (tiny commits, each compiles + `mise run ci` green) 1. **Add helpers + constructors alongside the existing public fields** (non-breaking). `check_publish_state`, `resolve_publish_instant`, `PostCreate::new`, `PostEdit::new` in `models/post.rs`, with pure unit tests. Fields stay `pub` for now. 2. **Route `apply_to` through `resolve_publish_instant` + `PostEdit::new`**; map `DomainError` → `ApplyUpdateError` (8-i). Move the canonical #87 / tag-cap tests down to the constructor; keep one surfacing test each in `apply_to`. 3. **Route `micropub_create` through `resolve_publish_instant` + `PostCreate::new`.** Thin the service-level micropub tests to edge-wiring regressions. 4. **Replace the web `TryFrom`s with `resolve_new_post` / `resolve_edit`**, threading `now` from the handlers and passing the resolved photo in. Migrate the `forms.rs` tests to the new producers; add a web-producer test proving blank-date + Published resolves to `now`. 5. **Seal the seam:** make `PostCreate` / `PostEdit` fields private now that every construction routes through `::new`. Fix any remaining direct field access in fixtures. ## Test migration - **New, authoritative at the seam:** pure unit tests on `PostCreate::new` / `PostEdit::new` (publish-requires-date, tag-cap) and on `resolve_publish_instant` (defaulting matrix: prev None/Draft/Published × date given/absent). No repo, so plain unit tests — the domain-crate conformance rule doesn't apply (nothing touches a mock). - **Stay on `apply_to`:** producer-behavior tests — carry-through, tag add/remove merge, clear-keeps-current, non-public rejection, content-required-on-clear, the #53 transition defaulting. - **Move down:** the two pure hard-invariant tests currently asserted through `apply_to` get their canonical home on the constructor; `apply_to` keeps one surfacing test each. - **New in the web crate:** a `resolve_new_post` / `resolve_edit` test with a fixed `now` proving the blank-date-on-publish fix. - **Thin, don't gut:** `micropub_create` service tests stay as edge-wiring regressions but stop being the only proof of any invariant. Principle: each invariant proven once at the seam, each producer proven to feed the seam, no invariant provable *only* through a service method. ## Related - Extends the shape ADR-0019 already blessed for media ("one domain op behind multiple adapters") to post writes. - Surfaced by the architecture review as candidate 2 ("one front door for post writes"). Related but out of scope: the error-taxonomy consolidation (kept separate by decision 8-i).
Author
Owner

Implemented on main (fafadb0), pushed to origin.

One deep write seam now owns the universal post-state invariants: PostCreate::new / PostEdit::new are fallible with private fields, so an invalid write intent is unconstructable and all four edges (web create/update, micropub create/update) funnel through the same door.

  • check_publish_state (#87) and resolve_publish_instant (#53) added as shared pure helpers
  • apply_to and micropub_create routed through the seam; DomainError mapped back to the producer-facing variants (8-i), leaving micropub_update's match untouched
  • web TryFroms replaced with resolve_new_post / resolve_edit, threading now + the resolved photo
  • fields sealed private with getters + PostEdit::set_photo for the post-apply_to photo override

Behavior fix: a web create/edit landing on Published with a blank date now defaults to now on a draft->publish transition and is rejected otherwise (#87), instead of writing a silently-invisible post. Full mise run ci green.

Implemented on main (fafadb0), pushed to origin. One deep write seam now owns the universal post-state invariants: PostCreate::new / PostEdit::new are fallible with private fields, so an invalid write intent is unconstructable and all four edges (web create/update, micropub create/update) funnel through the same door. - check_publish_state (#87) and resolve_publish_instant (#53) added as shared pure helpers - apply_to and micropub_create routed through the seam; DomainError mapped back to the producer-facing variants (8-i), leaving micropub_update's match untouched - web TryFroms replaced with resolve_new_post / resolve_edit, threading now + the resolved photo - fields sealed private with getters + PostEdit::set_photo for the post-apply_to photo override Behavior fix: a web create/edit landing on Published with a blank date now defaults to now on a draft->publish transition and is rejected otherwise (#87), instead of writing a silently-invisible post. Full mise run ci green.
rosa closed this issue 2026-08-08 16:54:59 +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#139
No description provided.