Related Links

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 here
  • SUBMITTED — 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 next
  • APPROVED — supervisor approval; collapses into HIDDEN inside the same transaction
  • HIDDEN — author owns it again; chooses when to publish
  • REJECTED — 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 ArticleInvalidTransition for wrong-source or wrong-role attempts (the error body includes role, from_status, to_status)
  • /submit returns 400 ArticlePublicationRejected with violations when 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:

  1. Only USER operates on DRAFT. The lifecycle entry for DRAFT only contains a USER-owned edge out.
  2. From SUBMITTED either the user pulls back or the supervisor picks up. Two edges, one per role.
  3. Once IN_REVIEW, the user cannot pull back to DRAFT — the supervisor owns the decision. The lifecycle entry for IN_REVIEW has no USER-owned edges.
  4. APPROVED automatically collapses to HIDDEN. The /approve use case performs both transitions inside one transaction; the resting state in the database is always HIDDEN.

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-REJECTED state clears the column.
  • The HTTP contract optimises for client communication and cognitive load. A reject_message: null on a DRAFT article 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 inline reject_message keeps 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:

  • RejectRequest carries only reject_message. There is no supervisor_id — without auth, including one would be theatre.
  • ReviewPickupPublic is a wrapper, not a subclass, because the /review response is conceptually (article, advisory_notes) — two distinct things, not a derived article shape.
  • The @model_serializer does 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_transition from the domain directly
  • the cognitive layer is passed in, not constructed; the application layer is a wirer
  • approve_article performs 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_transition answers a question; assert_transition raises 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, and REJECTED are 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_message is 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.