One front door for post writes: enforce post-state invariants at a single write seam #139
Labels
No labels
kind
bug
kind
enhancement
wayfinder
grilling
wayfinder
map
wayfinder
prototype
wayfinder
research
wayfinder
task
workflow
needs-info
workflow
needs-triage
workflow
ready-for-agent
workflow
ready-for-human
workflow
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
rosa/vernier#139
Loading…
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?
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.
now(#53)TryFrom<&NewPostForm> for PostCreate(crates/web/src/extractors/forms.rs:122)TryFrom<&EditPostForm> for PostEdit(crates/web/src/extractors/forms.rs:154)micropub_createinline (crates/domain/src/services.rs:1173)MicropubUpdate::apply_to(crates/domain/src/models/micropub.rs:91, pure, ~30 tests)The service methods
create_post(services.rs:1030) andupdate_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 onlyTag::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_liverequirespublished_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/PostEditfields 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:
PostCreate.content/PostEdit.contentare non-optionalContent; already enforcedPostCreate::new/PostEdit::new) — pure on resolved fieldsnow(#53)now, not pure on resolved stateShape
models/post.rs:PostCreate::new(...) -> Result<_, DomainError>andPostEdit::new(...) -> Result<_, DomainError>, enforcing publish-requires-date + tag-cap. Private fields.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 byapply_to(update) and both create edges. This is the real create/update DRY win.MicropubUpdate::apply_to(post, now)keeps non-public + content-required-on-clear rejections, callsresolve_publish_instant, and ends by callingPostEdit::new.micropub_createkeeps scope gate, response-property + non-public rejection, and slug derivation (mp-slug/ derive / generate); callsresolve_publish_instant(prev = None, …)thenPostCreate::new.TryFrom<&NewPostForm> for PostCreate/TryFrom<&EditPostForm> for PostEditwith explicit producer fnsresolve_new_post(&form, photo, now)/resolve_edit(&form, photo, now).TryFromis single-argument and can't threadnow; 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-resolvedOption<PhotoRef>is passed in (photo resolution needs the repo, ADR-0019, and already happens before the intent is built in the handler).create_post/update_poststay thin writers — they now receive an already-validated intent and trust the type.apply_tokeeps itsApplyUpdateErrorshape for producer-only rejections (content-required-on-clear, non-public) and maps the constructor'sDomainError(publish-requires-date, tag-cap) into the matching variants, somicropub_update's existingmatchand the webFromimpls 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:
now(matches Micropub #53) instead of writing a silently-invisible post.Implementation phases (tiny commits, each compiles +
mise run cigreen)check_publish_state,resolve_publish_instant,PostCreate::new,PostEdit::newinmodels/post.rs, with pure unit tests. Fields staypubfor now.apply_tothroughresolve_publish_instant+PostEdit::new; mapDomainError→ApplyUpdateError(8-i). Move the canonical #87 / tag-cap tests down to the constructor; keep one surfacing test each inapply_to.micropub_createthroughresolve_publish_instant+PostCreate::new. Thin the service-level micropub tests to edge-wiring regressions.TryFroms withresolve_new_post/resolve_edit, threadingnowfrom the handlers and passing the resolved photo in. Migrate theforms.rstests to the new producers; add a web-producer test proving blank-date + Published resolves tonow.PostCreate/PostEditfields private now that every construction routes through::new. Fix any remaining direct field access in fixtures.Test migration
PostCreate::new/PostEdit::new(publish-requires-date, tag-cap) and onresolve_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).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.apply_toget their canonical home on the constructor;apply_tokeeps one surfacing test each.resolve_new_post/resolve_edittest with a fixednowproving the blank-date-on-publish fix.micropub_createservice 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
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.
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.