Related Links
- Part 1 — Setup Project. Dependencies Direction. CRUD
- Part 2 — Cognitive Layer. LLM Integration
- Part 3 — External Integration. Users Engagement
- Repository —
mainbranch (Part 1) - Pull Request #1 —
iteration-02→main(Part 2) - Pull Request #2 —
iteration-03→iteration-02(Part 3) - Pull Request #3 —
iteration-04→iteration-03(this part)
0. Disclaimer
There is no authentication and no persisted users module in this PoC.
Every endpoint is public. Role attribution lives in the domain
only — the lifecycle table declares which role owns which transition
— and each use case hardcodes the role it represents. In a real
system, the role would come from auth middleware (request.user.role)
and the rest of the chain would be identical. The point of this part
is to show the lifecycle and the dependency direction, not to build
an auth flow.
1. Feature Request
In Part 1 publishing an article was one step: POST /articles, the
draft lands in the database, done. That is fine for a one-author blog.
It is not fine for an editorial process where:
- a contributor needs to save edits without exposing them
- a supervisor approves or rejects before anything goes public
- a rejection has to be actionable — the author needs to know why
- the author should be able to pull a piece back mid-flow, or delay publication after approval
The Part 1 → Part 3 lifecycle is one state. The new lifecycle has seven, with role ownership baked into each transition. Five of the mechanical checks from Part 3 become the gate for the author-side submission step; two cognitive checks (reused from Part 2) become an advisory editorial review for the supervisor.
This is the mirror case of Parts 2 and 3. The capability is internal — editorial process is something the team decides — so the domain has to grow on purpose. The dependency direction does not move; the layer that absorbs the work does.
1.1 The Publication Lifecycle
USER SUPERVISOR USER
DRAFT ──submit─► SUBMITTED ──review─► IN_REVIEW ──approve─► APPROVED ──(auto)─► HIDDEN
▲ │ ▲ │ │ │
│ retract │ │ │ │ reject │
└───────────────┘ │ │ ▼ │ publish
USER │ │ REJECTED ────revise (USER)──► DRAFT │
│ │ ▼
└──────────────────┘ PUBLISHED
The seven states:
DRAFT— author writes; CRUD operates hereSUBMITTED— author pushed it past the mechanical gate; awaits a supervisor pickup. First bridge state.IN_REVIEW— supervisor picked it up; advisory cognitive review was generated; supervisor decides nextAPPROVED— supervisor approval; collapses intoHIDDENinside the same transactionHIDDEN— author owns it again; chooses when to publishREJECTED— supervisor rejected with a message; author needs to revise. Second bridge state.PUBLISHED— terminal; visible on the site
Role ownership
| Role | States it "owns" (initiates transitions from) |
|---|---|
| USER | DRAFT, SUBMITTED (retract), HIDDEN, REJECTED (revise) |
| SUPERVISOR | SUBMITTED (pickup), IN_REVIEW, APPROVED (auto-collapse) |
SUBMITTED, HIDDEN, and REJECTED are the bridge states — the
ones where ownership changes hands. Naming them as first-class states
is the whole point of the lifecycle table.
Why HIDDEN is technically optional
APPROVED could move directly to PUBLISHED and the system would
still function. I keep HIDDEN because:
- The supervisor's act (approve) and the author's act (publish) are conceptually different. Collapsing them into one transition hides that.
- In every real editorial system I have worked with, "approved but not yet visible" eventually becomes a feature on its own — scheduling, A/B variants, pre-launch coordination. Adding the state later is more painful than carrying it from the start.
- A single extra enum value costs one row in the transition table. The benefit — the transparent business contract you can read top to bottom and explain in one sentence — is worth orders of magnitude more than that.
A state that costs nothing and clarifies a lot is a state worth keeping.
1.2 API Contract
Seven new endpoints — one per verb. The CRUD surface from Part 1
stays (it now writes drafts in DRAFT status).
| Endpoint | Role | Transition | Body | Notes |
|---|---|---|---|---|
POST /articles/{slug}/submit |
USER | DRAFT → SUBMITTED |
{} |
Runs submission pipeline (5 mechanical checks). 400 carries violations list |
POST /articles/{slug}/retract |
USER | SUBMITTED → DRAFT |
{} |
Author pulls back |
POST /articles/{slug}/review |
SUPERVISOR | SUBMITTED → IN_REVIEW |
{} |
Runs editorial pipeline (2 cognitive checks). Findings returned as editorial_notes — advisory, never blocking |
POST /articles/{slug}/approve |
SUPERVISOR | IN_REVIEW → APPROVED → HIDDEN |
{} |
Two transitions, one transaction. Resting state is HIDDEN |
POST /articles/{slug}/reject |
SUPERVISOR | IN_REVIEW → REJECTED |
{ reject_message } |
reject_message is required |
POST /articles/{slug}/revise |
USER | REJECTED → DRAFT |
{} |
Clears reject_message |
POST /articles/{slug}/publish |
USER | HIDDEN → PUBLISHED |
{} |
Terminal |
- All endpoints return
409 ArticleInvalidTransitionfor wrong-source or wrong-role attempts (the error body includesrole,from_status,to_status) /submitreturns400 ArticlePublicationRejectedwithviolationswhen the mechanical pipeline finds anything- request body name is always
body(carried over from Part 1)
1.3 Why Endpoints Are Verbs, Not "Actions"
In Part 2 I introduced a single /articles/{slug}/actions dispatcher
for AI assistance. It made sense in isolation. It does not survive
this growth — there are now seven other things an author or
supervisor can do with an article that are also "actions" in plain
English. A name that means "everything you can do to X" collides with
everything you can do to X.
I keep the Part 2 endpoint as-is (so the Part 2 → Part 4 contrast is visible in the repo) but every new endpoint follows the verb-named convention. The lifecycle has seven verbs; the dispatcher has seven endpoints. The mapping is one-to-one.
1.4 Business Rules (Stated in the Code)
The lifecycle table is the source of truth. The rules below are what that table says, in prose:
- Only
USERoperates onDRAFT. The lifecycle entry forDRAFTonly contains a USER-owned edge out. - From
SUBMITTEDeither the user pulls back or the supervisor picks up. Two edges, one per role. - Once
IN_REVIEW, the user cannot pull back toDRAFT— the supervisor owns the decision. The lifecycle entry forIN_REVIEWhas no USER-owned edges. APPROVEDautomatically collapses toHIDDEN. The/approveuse case performs both transitions inside one transaction; the resting state in the database is alwaysHIDDEN.
Each rule lives in policies.py as data; the use cases delegate to
assert_transition(current, target, role). A future rule (e.g.,
"only the contributor who created the article can submit it") is a
code change in the same file. The business rules are not scattered
in prose comments or HTTP guards — they sit in one table.
1.5 Why the Domain Has to Grow
Parts 2 and 3 absorbed external capabilities. The work moved into infrastructure because the interesting logic lived on the other side of a boundary — pydantic-ai's model call, Medium's RSS feed.
The publication lifecycle has no such boundary:
- the state machine is something the team decides
- the role attribution (who owns each transition) is an internal access policy
- the submission rules are editorial standards
- the bridge states are the contract between roles
All of this belongs in the domain. Infrastructure runs the persistence
(articles table); the cognitive layer (Part 2's binding) gets called
inline; nothing else changes outside the domain.
1.6 Persistence Shape vs HTTP Contract Shape
A small but load-bearing note: reject_message lives directly on the
articles table as a plain Text column, but the ArticlePublic
HTTP contract masks it unless the article is currently in
REJECTED status. Two shapes, one field.
The two shapes optimise for different things:
- The database optimises for storage efficiency, query patterns,
and clear invariants. The repository enforces
reject_message is not None ↔ status is REJECTED— any transition to a non-REJECTEDstate clears the column. - The HTTP contract optimises for client communication and
cognitive load. A
reject_message: nullon aDRAFTarticle would raise the question "why is this field here?" every time a developer saw the response. We answer that question by not emitting it.
This is the same data, two shapes, and the translation point is the
contract layer — exactly where it should be. Pydantic's
@model_serializer(mode="wrap") does the mask in three lines.
In a real project, every state change would be logged to a dedicated audit table (
article_status_changes: id, article_id, from, to, by, message, at). We deliberately do not build that here — the inlinereject_messagekeeps the example focused on the persistence-vs-contract idea. When the inline column starts feeling wrong (it will, the first time someone asks "who rejected this last week"), the audit table is the natural next step.
2. Implementation & Examples
Outside-in. Seven HTTP endpoints decide the use-case signatures. The application layer composes the pipelines. Then the domain pieces: roles, lifecycle, the two pipelines, the supervisor's decision shape. Finally the infrastructure extensions and tests.
2.1 HTTP Contracts
# src/http/contracts/review.py
from pydantic import Field
from src.http._base import PublicModel
class RejectRequest(PublicModel):
reject_message: str = Field(min_length=1)
class CheckViolationPublic(PublicModel):
code: str
field: str | None
reason: str
class ReviewPickupPublic(PublicModel):
article: "ArticlePublic"
editorial_notes: list[CheckViolationPublic]
# src/http/contracts/articles.py (the masking part)
from pydantic import model_serializer
class ArticlePublic(ArticleSummaryPublic):
body: str
reject_message: str | None = None
@model_serializer(mode="wrap")
def _mask_reject_message(self, handler):
data = handler(self)
if self.status != ArticleStatus.REJECTED:
data.pop("reject_message", None)
return data
Three things to notice:
RejectRequestcarries onlyreject_message. There is nosupervisor_id— without auth, including one would be theatre.ReviewPickupPublicis a wrapper, not a subclass, because the/reviewresponse is conceptually(article, advisory_notes)— two distinct things, not a derived article shape.- The
@model_serializerdoes the persistence-vs-contract mask in three lines. No service layer needed.
2.2 HTTP Resources
Seven new routes. Each is @transactional — state changes happen
inside the route's transaction, and the supervisor's /review
pickup runs the cognitive pipeline before the transition lands.
# src/http/resources/articles.py (publication routes shown)
@router.post("/{slug}/submit", status_code=status.HTTP_200_OK)
@transactional
async def article_submit(slug: str) -> ArticlePublic:
"""USER: DRAFT → SUBMITTED. Submission pipeline gates the transition."""
repository = SqlAlchemyArticlesRepository()
cognitive = PydanticAICognitiveLayer()
article = await articles.submit_article(
repository=repository, cognitive=cognitive, slug=slug,
)
return ArticlePublic.model_validate(article)
@router.post("/{slug}/review", status_code=status.HTTP_200_OK)
@transactional
async def article_review(slug: str) -> ReviewPickupPublic:
"""SUPERVISOR: SUBMITTED → IN_REVIEW. Editorial pipeline runs advisory."""
repository = SqlAlchemyArticlesRepository()
cognitive = PydanticAICognitiveLayer()
article, review_result = await articles.pick_up_for_review(
repository=repository, cognitive=cognitive, slug=slug,
)
return ReviewPickupPublic(
article=ArticlePublic.model_validate(article),
editorial_notes=[
CheckViolationPublic.model_validate(v, from_attributes=True)
for v in review_result.violations
],
)
@router.post("/{slug}/approve", status_code=status.HTTP_200_OK)
@transactional
async def article_approve(slug: str) -> ArticlePublic:
"""SUPERVISOR: IN_REVIEW → APPROVED → HIDDEN (two transitions, one tx)."""
repository = SqlAlchemyArticlesRepository()
article = await articles.approve_article(repository=repository, slug=slug)
return ArticlePublic.model_validate(article)
@router.post("/{slug}/reject", status_code=status.HTTP_200_OK)
@transactional
async def article_reject(slug: str, body: RejectRequest) -> ArticlePublic:
"""SUPERVISOR: IN_REVIEW → REJECTED. `reject_message` required."""
repository = SqlAlchemyArticlesRepository()
article = await articles.reject_article(
repository=repository, slug=slug, reject_message=body.reject_message,
)
return ArticlePublic.model_validate(article)
/retract, /revise, /publish are the same shape — slug in, use
case out, ArticlePublic back. They are omitted here for brevity.
2.3 Application Layer
Seven new use cases. Each one hardcodes the role it represents (no
auth, so no role parameter on the public signature) and calls
assert_transition from the domain layer directly — there is no
application-layer wrapper. The rule and its enforcement live in
policies.py; the use case just asks.
# src/application/articles.py (publication use cases shown)
from src.domain.articles import ArticleStatus, assert_transition
from src.domain.articles.publication import (
ArticlePublicationRejected,
PipelineResult,
PublicationContext,
editorial_pipeline,
submission_pipeline,
)
from src.domain.users import UserRole
async def submit_article(
repository: BookshelfRepository,
cognitive: CognitiveLayer,
slug: str,
) -> Article:
"""USER: DRAFT → SUBMITTED. Runs the submission pipeline."""
article = await repository.article(slug)
assert_transition(article.status, ArticleStatus.SUBMITTED, UserRole.USER)
pipeline = submission_pipeline()
ctx = PublicationContext(repository=repository, cognitive=cognitive)
result = await pipeline.run(article, ctx)
if not result.passed:
raise ArticlePublicationRejected(
violations=[v.model_dump() for v in result.violations],
)
return await repository.transition(slug, ArticleStatus.SUBMITTED)
async def pick_up_for_review(
repository: BookshelfRepository,
cognitive: CognitiveLayer,
slug: str,
) -> tuple[Article, PipelineResult]:
"""SUPERVISOR: SUBMITTED → IN_REVIEW. Editorial pipeline is advisory."""
article = await repository.article(slug)
assert_transition(article.status, ArticleStatus.IN_REVIEW, UserRole.SUPERVISOR)
pipeline = editorial_pipeline()
ctx = PublicationContext(repository=repository, cognitive=cognitive)
review_result = await pipeline.run(article, ctx)
article = await repository.transition(slug, ArticleStatus.IN_REVIEW)
return article, review_result
async def approve_article(
repository: BookshelfRepository,
slug: str,
) -> Article:
"""SUPERVISOR: IN_REVIEW → APPROVED → HIDDEN (one transaction)."""
article = await repository.article(slug)
assert_transition(article.status, ArticleStatus.APPROVED, UserRole.SUPERVISOR)
await repository.transition(slug, ArticleStatus.APPROVED)
assert_transition(
ArticleStatus.APPROVED, ArticleStatus.HIDDEN, UserRole.SUPERVISOR
)
return await repository.transition(slug, ArticleStatus.HIDDEN)
async def reject_article(
repository: BookshelfRepository,
slug: str,
reject_message: str,
) -> Article:
"""SUPERVISOR: IN_REVIEW → REJECTED. Reason required."""
article = await repository.article(slug)
assert_transition(article.status, ArticleStatus.REJECTED, UserRole.SUPERVISOR)
return await repository.transition(
slug, ArticleStatus.REJECTED, reject_message=reject_message,
)
retract_article, revise_article, and publish_article are the
same one-screen shape: fetch, assert transition, call transition.
They are omitted here.
Three things to notice:
- the use case never decides the rule and never owns its enforcement
— it calls
assert_transitionfrom the domain directly - the cognitive layer is passed in, not constructed; the application layer is a wirer
approve_articleperforms two transitions inside one transaction. Both go through the same domain assertion, so the policy table validates the whole chain
2.4 Domain Layer. Users Module
The smallest possible module — one enum.
# src/domain/users/roles.py
from enum import StrEnum
class UserRole(StrEnum):
USER = "user"
SUPERVISOR = "supervisor"
That is the entire users module. No entities, no repository, no auth
— this PoC has none of that. The enum exists so the article lifecycle
can express role ownership at the domain level. In a real system this
file would sit next to a User entity, a users table, and an auth
binding that injects the acting user's role into every use-case call.
2.5 Domain Layer. Article Policies (Content + Lifecycle)
Content rules and the state machine sit in the same file because both answer the same question from the use case's point of view: "is this move editorially valid?" One file, two sections.
# src/domain/articles/policies.py (lifecycle section shown)
from enum import StrEnum
from src.domain.errors import ArticleInvalidTransition
from src.domain.users import UserRole
# ────────────────────────────────────────────────────────────
# Content Policies
# ────────────────────────────────────────────────────────────
STOP_WORDS: frozenset[str] = frozenset({"spam", "clickbait", "scam"})
def find_stop_word(text: str) -> str | None:
"""Return the first stop word found in `text`, or None if clean."""
lowered = text.lower()
for word in STOP_WORDS:
if word in lowered:
return word
return None
# ────────────────────────────────────────────────────────────
# Lifecycle Policies
# ────────────────────────────────────────────────────────────
class ArticleStatus(StrEnum):
DRAFT = "draft"
SUBMITTED = "submitted"
IN_REVIEW = "in_review"
APPROVED = "approved"
HIDDEN = "hidden"
REJECTED = "rejected"
PUBLISHED = "published"
Transition = tuple[ArticleStatus, UserRole]
ALLOWED_TRANSITIONS: dict[ArticleStatus, frozenset[Transition]] = {
ArticleStatus.DRAFT: frozenset({
(ArticleStatus.SUBMITTED, UserRole.USER),
}),
ArticleStatus.SUBMITTED: frozenset({
(ArticleStatus.DRAFT, UserRole.USER),
(ArticleStatus.IN_REVIEW, UserRole.SUPERVISOR),
}),
ArticleStatus.IN_REVIEW: frozenset({
(ArticleStatus.APPROVED, UserRole.SUPERVISOR),
(ArticleStatus.REJECTED, UserRole.SUPERVISOR),
}),
ArticleStatus.APPROVED: frozenset({
(ArticleStatus.HIDDEN, UserRole.SUPERVISOR),
}),
ArticleStatus.HIDDEN: frozenset({
(ArticleStatus.PUBLISHED, UserRole.USER),
}),
ArticleStatus.REJECTED: frozenset({
(ArticleStatus.DRAFT, UserRole.USER),
}),
ArticleStatus.PUBLISHED: frozenset(),
}
def can_transition(
current: ArticleStatus,
target: ArticleStatus,
role: UserRole,
) -> bool:
"""True if `role` may move an article from `current` to `target`."""
return (target, role) in ALLOWED_TRANSITIONS[current]
def assert_transition(
current: ArticleStatus,
target: ArticleStatus,
role: UserRole,
) -> None:
"""Raise `ArticleInvalidTransition` if the transition is not allowed."""
if not can_transition(current, target, role):
raise ArticleInvalidTransition(
from_status=current.value,
to_status=target.value,
role=role.value,
)
The whole lifecycle is one dict and two functions.
can_transitionanswers a question;assert_transitionraises on a violation — both are called directly by the use cases. No application-layer wrapper.
The article entities pick up the status field and the inline
reject_message:
# src/domain/articles/entities.py (extension)
from .policies import ArticleStatus
class Article(ArticleDraft):
id: int
status: ArticleStatus = ArticleStatus.DRAFT
reject_message: str | None = None
reject_message is an article attribute because we deliberately
skipped the audit table — see §1.6.
2.6 Domain Layer. Submission Pipeline (Mechanical)
The submission pipeline composes five mechanical checks. It is the
gate on /submit and blocks on any violation.
# src/domain/articles/publication/pipeline.py (excerpt)
from .mechanical_checks import (
AuthorityCheck,
CitationCheck,
DuplicateTitleCheck,
StopWordsCheck,
StructureCheck,
)
def submission_pipeline() -> PublicationPipeline:
"""5 mechanical checks — gate for `/submit`."""
return PublicationPipeline(
checks=(
AuthorityCheck(),
StopWordsCheck(),
StructureCheck(),
CitationCheck(),
DuplicateTitleCheck(),
),
)
Imports at the module top, factory is a one-liner. The five checks
themselves carry over from the earlier Part 4 draft: authority
(author allowed to publish), stop words, structure (length ranges),
citation (external link required), duplicate title. They are pure
functions over Article, so each one is one short class.
2.7 Domain Layer. Editorial Pipeline (Cognitive, Advisory)
The editorial pipeline composes two cognitive checks. It runs on
/review and is never blocking — findings are returned to the
supervisor as editorial_notes so they can read the LLM's grammar
and consistency comments before deciding.
# src/domain/articles/publication/pipeline.py (excerpt)
from .cognitive_checks import ConsistencyReviewCheck, GrammarReviewCheck
def editorial_pipeline() -> PublicationPipeline:
"""2 cognitive checks — advisory input for `/review`."""
return PublicationPipeline(
checks=(
GrammarReviewCheck(),
ConsistencyReviewCheck(),
),
)
The two cognitive checks delegate to Part 2's CognitiveLayer ABC
via two new AssistanceKind values and two new prompts. The ABC and
its binding are unchanged — that is the payoff of keeping the
abstraction narrow.
# src/domain/cognitive_layer/entities.py (additions)
class AssistanceKind(StrEnum):
SUMMARIZE = "summarize"
IMPROVE_GRAMMAR = "improve_grammar"
SUGGEST_TITLE = "suggest_title"
REVIEW_GRAMMAR = "review_grammar"
REVIEW_CONSISTENCY = "review_consistency"
The model is asked to return CLEAN or a - bulleted list of issues;
the cognitive check parses each line into a CheckViolation. The
supervisor sees the parsed list inline with the article.
The split matches role ownership precisely. The author owns mechanical violations (they can fix them). The supervisor owns the editorial call (the LLM advises; the human decides). One pipeline class, two factories, two responsibilities.
2.8 Domain Layer. Errors
ArticleInvalidTransition now carries the role too — every illegal
transition has three reasons it could be illegal, and the response
should say which.
# src/domain/errors/__init__.py (extension)
class ArticleInvalidTransition(DomainError):
def __init__(self, *, from_status: str, to_status: str, role: str) -> None:
super().__init__(
f"Role {role!r} cannot transition article from "
f"{from_status!r} to {to_status!r}."
)
self.from_status = from_status
self.to_status = to_status
self.role = role
Mapped to 409 with a body that includes from_status, to_status,
role. A client reading the response can tell whether they were
blocked by state, target, or role.
2.9 Repository Extension
The BookshelfRepository contract gains one method — transition —
that atomically updates the status and the reject_message. The
repository enforces the invariant
reject_message is not None ↔ status is REJECTED:
# src/domain/articles/repository.py (excerpt)
@abc.abstractmethod
async def transition(
self,
slug: str,
status: ArticleStatus,
reject_message: str | None = None,
) -> Article:
"""Atomically update the article's status.
``reject_message`` is meaningful only when ``status`` is
``REJECTED``; for any other target the implementation clears
the column. The invariant ``reject_message is not None ↔
status is REJECTED`` is enforced by the repository, not
scattered across use cases.
"""
# src/infrastructure/database/repositories/articles.py (SqlAlchemy impl)
async def transition(
self,
slug: str,
status: ArticleStatus,
reject_message: str | None = None,
) -> Article:
row = await self._fetch_row_by_slug(slug)
row.status = status.value
row.reject_message = (
reject_message if status == ArticleStatus.REJECTED else None
)
await self.flush()
return self._to_entity(row)
One conditional, one invariant — the use case can stop thinking about
when to clear the column. /revise calling
repository.transition(slug, DRAFT) is enough; the repository wipes
reject_message automatically.
2.10 Persistence Shape
The articles table picks up two columns: status and
reject_message. There is no last_review JSON column, no
separate reviews table, no audit log.
# src/infrastructure/database/tables.py
class ArticlesTable(Base):
__tablename__ = "articles"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
author: Mapped[str] = mapped_column(String(120), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
summary: Mapped[str] = mapped_column(String(500), nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
published_on: Mapped[Date] = mapped_column(Date, nullable=False)
status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft")
reject_message: Mapped[str | None] = mapped_column(
Text, nullable=True, default=None
)
A real editorial system would log every state change to a separate table. We deliberately do not — keeping the inline column lets the example focus on persistence-vs-contract, and the migration to a proper audit log is straightforward when it eventually shows up.
2.11 Error Mapping
One entry changes (ArticleInvalidTransition gains a role field
on the response); the rest carry over from Part 3.
# src/infrastructure/application/error_handlers.py (additions only)
async def article_invalid_transition_handler(
_: Request, exc: ArticleInvalidTransition
) -> JSONResponse:
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={
"detail": str(exc),
"from_status": exc.from_status,
"to_status": exc.to_status,
"role": exc.role,
},
)
2.12 Testing
Most of the new code is pure functions over domain types (the five mechanical checks), one parser (the cognitive check), and a small transition table. Tests stay short.
# src/tests/unit/test_publication.py (excerpts)
@pytest.mark.asyncio
async def test_submit_transitions_clean_draft_to_submitted(
publishable_article, repository, cognitive_clean
) -> None:
await repository.add_article(publishable_article)
result = await articles_use_cases.submit_article(
repository=repository, cognitive=cognitive_clean,
slug=publishable_article.slug,
)
assert result.status == ArticleStatus.SUBMITTED
@pytest.mark.asyncio
async def test_review_does_not_block_on_cognitive_findings(
publishable_article, repository, cognitive_clean
) -> None:
"""Editorial pipeline output is advisory — transition happens regardless."""
# ...setup...
noisy = FakeCognitiveLayer(suggestion="- many grammar issues found")
article, review_result = await articles_use_cases.pick_up_for_review(
repository=repository, cognitive=noisy, slug=publishable_article.slug,
)
assert article.status == ArticleStatus.IN_REVIEW
assert review_result.violations # non-empty, but transition still happened
@pytest.mark.asyncio
async def test_revise_clears_reject_message(...) -> None:
"""After /reject sets the message, /revise wipes it."""
# ...flow through submit → review → reject (with message) → revise...
result = await articles_use_cases.revise_article(repository=repository, slug=slug)
assert result.status == ArticleStatus.DRAFT
assert result.reject_message is None
@pytest.mark.parametrize(
"current, target, role, allowed",
[
(ArticleStatus.DRAFT, ArticleStatus.SUBMITTED, UserRole.USER, True),
(ArticleStatus.DRAFT, ArticleStatus.SUBMITTED, UserRole.SUPERVISOR, False),
(ArticleStatus.IN_REVIEW, ArticleStatus.APPROVED, UserRole.USER, False),
(ArticleStatus.HIDDEN, ArticleStatus.PUBLISHED, UserRole.USER, True),
# ...full table covered in the repo...
],
)
def test_transition_table_enforces_role_ownership(
current, target, role, allowed
) -> None:
assert can_transition(current, target, role) is allowed
The full file covers each mechanical check individually, both
pipelines, all seven use cases, the reject_message-clearing
invariant on /revise, and the parametrised role-ownership table.
3. End of the Series
Four parts, one rule. The trajectory in one view:
Part 1 Part 2 Part 3 Part 4
(foundation) (external tool) (external API) (internal feature)
────────────── ───────────────────── ────────────────────── ──────────────────────
http/ full surface +1 dispatcher route +1 dispatcher route +7 verb-named routes
application/ 5 use cases +3 editorial funcs +1 import func +7 lifecycle funcs
domain/ articles/ +cognitive_layer/ +articles/inbound.py +users/ (one enum)
(1 aggregate) (1 small folder) (1 file) +articles/policies.py
(lifecycle merged in)
+articles/authors.py
+articles/publication/
(extends the same aggregate)
infrastructure/ database/ + +pydantic_bindings.py +integrations/ (no new infra packages —
application/ (medium.py, reddit.py) Part 2's binding gains
2 prompts)
For Parts 2 and 3 the diff sat almost entirely in infrastructure/
and at the edges of application/. The domain row gained one folder
and one file across both. Part 4 flips it — the domain is the largest
growing layer, infrastructure barely moves, and Part 2's cognitive
layer gets reused with two new prompts and no other changes.
A few moves are worth naming:
- Verb endpoints over
/actions. Part 2's single dispatcher made sense in isolation; Part 4's seven lifecycle verbs make it collide. The new endpoints are named for what they do. - Role attribution as a domain concept. Even without auth, the transition table declares which role owns which edge. When auth lands, nothing in the lifecycle changes — only the source of the role on each call.
- Bridge states.
SUBMITTED,HIDDEN, andREJECTEDare named because they are exactly where ownership changes hands. A state machine without bridge states forces every hand-off into a hard-to-spot implicit moment. - Persistence shape vs HTTP contract shape.
reject_messageis one column and two API behaviours. The contract layer translates; the database does not lie.
The dependency direction never moves. Only the layer that takes on the new code does — outside the domain when the capability is external, inside the domain when the capability is internal.
External work moves through infrastructure; internal work moves through the domain. The arrow between them never moves.