Architecture 02 of 14

Nothing Exists Yet

The campaign pipeline is four stages, and every decision worth explaining lives in the boundaries between them.

A campaign in CommsCrew begins as two text fields, a name and an objective. If it goes well it ends as published posts with impression counts joined back to it.

The pipeline between those two states has four stages: strategy, mix, produce, measure.

No column stores which one a campaign is in.

def _stage(c: Campaign, counts: dict) -> str:
    """
    Where this campaign sits in the flow: strategy → mix → produce → measure.

    Always DERIVED, never stored. A stored stage drifts the moment someone
    deletes the last piece or unlocks the strategy, and then the stepper lies
    about where the work actually is.
    """
    if counts.get("published", 0) > 0:
        return "measure"
    if counts.get("total", 0) > 0:
        return "produce"
    if (c.strategy_mix or {}).get("pieces") or c.strategy_status == "locked":
        return "mix"
    return "strategy"

That is _stage in backend/app_new/api/v1/campaigns/crud.py. Three conditions, four returns, recomputed on every campaign read. CampaignStepper.tsx renders the answer and keeps no local copy of it.

The failure mode is quiet. Someone deletes the last piece, or unlocks the strategy to fix a typo, and a stored stage keeps insisting the campaign is further along than it is. A stepper that lies gets ignored, and once ignored, the flow it was drawing stops existing for the user.

The rest of the layer runs on the same instinct. Derive instead of store. Refuse at the boundary instead of warning in the copy.

1 STRATEGY 2 MIX 3 PRODUCE 4 MEASURE CREW ACTS HUMAN DECIDES locked else 409 draft, critique recommend write bodies retrospective revise adjust materialise approve strategy_unchanged zero content status: draft _campaign_performance strategy_status drop or add creates rows ALLOWED_TRANSITIONS the crew stops here the human reads issues, not edits proposal planned rows review publish undelivered messages
Figure 1: The lock is the only way into the mix, and inside the mix only materialise writes rows, so the human always has a place to disagree before any content exists. Undelivered messages return from measure to strategy, which is what makes the four stages a loop rather than a line.

Strategy locks because everything downstream is derived from it

Stage 1 produces one JSON document with five required fields: story, audiences, core_messages, will_not_say, success. It is what the human and the crew argue over before any content exists.

The crew can draft it. The crew can attack it. The crew cannot edit it.

That last part is enforced by what the critique endpoint returns. From backend/app_new/api/v1/campaigns/strategy.py:

    issues = out.get("issues", [])
    campaign.strategy_critique = issues
    await db.commit()
    return {"issues": issues, "strategy_unchanged": True}

strategy_unchanged is in the payload so the client never has to infer it. The Strategist returns issues shaped {issue, why_it_matters, suggested_fix, severity}, and StrategyPanel.tsx renders them in an amber box next to the strategy, closing with a line that says these are proposals and nothing was changed. The revise button stays the human's.

The failure mode here is an agent that both proposes and applies. You end up with a strategy nobody agreed to and everybody assumes someone approved.

Locking is one field:

    campaign.strategy_status = "locked" if lock else "draft"
    campaign.strategy_updated_at = db_utcnow()

The significance is entirely downstream. recommend_mix checks strategy_status != "locked" and 409s. Draft and revise both check the reverse and 409 if it is locked. Three endpoints read one string, and the same route unlocks via a lock: bool query param.

Locking is not ceremony. Pieces are derived from the strategy, so a mix built while the strategy is still moving produces pieces that quietly stop matching the thing they were derived from. Nobody notices, because nothing errors. The strategy just drifts out from under the content.

Revisions are not lost. update_strategy snapshots the previous body into CampaignStrategyVersion before applying the change, and GET /{id}/strategy/versions reads them back newest first. One trap is documented in place: SQLAlchemy serialises JSON at flush time, so passing the live dict into the version row would snapshot the new strategy rather than the replaced one. The fix is dict(current), with a comment saying why, because otherwise someone deletes the copy as redundant.


Recommend, adjust, materialise: three verbs where there was one

The original path was POST /{id}/generate-plan. One button, campaign objective in, ContentItem rows on the board out. The prompt asks for five or six distinct pieces; the tool schema allows four to seven. It worked, and it is still live and untouched.

It also never read the strategy, and it left the human nowhere to disagree with the crew short of deleting pieces after they existed.

Stage 2 splits that single verb into three, and the schema comment on Campaign.strategy_mix in backend/app_new/models/content.py says why:

    # Stage 2. The crew's recommended content mix, held BEFORE the human
    # materialises it into real ContentItems. Shape: {pieces[], recommended_at,
    # materialised_at}. The gap between "recommended" and "materialised" is the
    # point: generate_plan writes straight to the board, which leaves the human
    # nowhere to disagree with the crew.
    strategy_mix: Mapped[dict | None] = mapped_column(JSON, nullable=True)

POST /{id}/mix/recommend writes a proposal onto the campaign row and creates zero content. PUT /{id}/mix lets the human drop pieces, add pieces, retarget them. POST /{id}/mix/materialise is the only one that touches the content board.

The gap between recommended and materialised is the entire stage. Close it and the crew stops proposing and starts acting.

A test exists only to hold that gap open: recommend a mix, then assert GET /{id}/pieces returns an empty list, under the message "recommending must not create content". Another materialises after a PUT and asserts the board holds the human's edit alone.

Materialise replaces rather than appends:

    planned_items = (await db.execute(
        select(ContentItem).where(
            ContentItem.organization_id == current_user.organization_id,
            ContentItem.campaign_id == campaign_id,
            ContentItem.status == "planned",
        )
    )).scalars().all()

Those rows get deleted before the new ones are added. The filter on status == "planned" is the load-bearing part. Anything already drafted is human work and survives. Re-materialising a mix twice gives you one board, not two.

MixPanel.tsx says the same thing to the user. Under the create button before materialise: nothing exists yet, this is still just a proposal. After: re-creating replaces still-planned pieces, drafted work is kept.


Every piece declares what it carries, so a silence becomes visible

The strategy tool asks for three to five core messages. The mix schema allows between three and eight pieces. Nothing about those two numbers guarantees the second set covers the first.

So each piece is made to declare which message it delivers. From MIX_TOOL in backend/app_new/api/v1/campaigns/mix.py:

                        "carries": {
                            "type": "string",
                            "description": (
                                "Which CORE MESSAGE from the strategy this piece carries. "
                                "Quote it from the strategy, do not invent a new one."
                            ),
                        },

carries is in the tool schema's required list, so a piece cannot come back without one. The set difference then falls out, in the body of _uncovered_messages in _shared.py:

    messages = [m for m in (strategy or {}).get("core_messages", []) if m]
    carried = [str(p.get("carries", "")).strip().lower() for p in pieces]
    missing = []
    for m in messages:
        needle = m.strip().lower()
        if not any(needle in c or (c and c in needle) for c in carried):
            missing.append(m)
    return missing

The looseness is the design decision. The model quotes the message back approximately, near enough for a human to recognise and never character-identical. Strict equality would report every message as uncovered on every run, and a warning that fires every time is a warning nobody reads.

Both the recommend and adjust responses return uncovered_messages, and the panel renders an amber banner naming the orphaned message. In the test suite, the fixture strategy has three core messages and the fixture mix carries two, and the assertion is exactly ["Report on request"].

The message survives materialise, attached to the piece:

            # Keep the message this piece was chosen to carry attached to the piece,
            # so the retrospective can check delivery against the strategy later.
            agent_metadata={"carries": spec.get("carries")} if spec.get("carries") else {},

That is the mix writing a note to the retrospective.


The middle leaked because the arrows had been built as pages

An audit partway through this build produced the sentence that reshaped the layer. The product had strong ends, campaign to plan to draft at one end and edit to memory at the other, joined by a middle that leaked. Content sat in a silo away from campaigns. The human approval gate could be skipped in one click. The entire measure half was write-only and invisible to every agent prompt.

The diagnosis was that arrows had been built as endpoints and pages rather than as connections.

The clearest instance: ContentItem to PublishedPost to EngagementSnapshot was a join the schema had always supported and no code had ever walked. Every table existed. Nothing read across them.

Walking that join became _campaign_performance, and where it lives matters more than what it does:

async def _campaign_performance(
    db: AsyncSession, campaign_id: uuid.UUID, org_id: uuid.UUID
) -> dict:
    """
    The rollup itself, callable without a request.

    Split out so the Stage 3 retrospective reads the SAME numbers the campaign
    page shows. A retrospective computing its own totals would eventually
    disagree with the UI, and the human would have no way to tell which was right.
    """

A plain function taking a session and two ids, with no request object in the signature. The HTTP route is a two-line wrapper. The retrospective imports the same function to build its prompt.

The alternative was one aggregation in the route and another in the Analyst prompt. They agree the day you write them and diverge on the first change to either. The failure mode is the crew quoting one impression count while the page shows another, with no way for the user to tell which is wrong.

The rollup also carries provenance. Every metrics block gets a source of demo, provider, or mixed, and the response carries is_demo_data. The retrospective reads that flag and changes the prompt:

    if perf.get("is_demo_data"):
        honesty = (
            "IMPORTANT: the engagement numbers below are SIMULATED demo data, not "
            "measured platform results. Say so plainly in your headline and do not "
            "draw confident performance conclusions from them. Judge what SHIPPED "
            "and what the strategy asked for instead."
        )
    else:
        honesty = "The engagement numbers below are measured platform results."

The panel renders the same warning above the headline. An Analyst drawing confident conclusions from seeded demo data is worse than no Analyst, because it is more persuasive.

The retrospective also closes the loop on carries:

    shipped = [i for i in items if i.status == "published"]
    # Messages the campaign PLANNED to carry but never actually published.
    published_meta = [
        {"carries": (i.agent_metadata or {}).get("carries")} for i in shipped
    ]
    undelivered = _uncovered_messages(strategy, published_meta)

Same function as the mix, different input: in the mix it asks which messages the plan misses, in the retrospective which never made it out the door. RetrospectivePanel.tsx renders that list in red, above the crew's findings, because it is the failure the whole chain exists to prevent and the one most easily missed.


The gate is a table on the server

Produce is where the crew writes bodies and a human approves them. The gate is not a disabled button:

# Server-side status state machine for client-driven updates (PUT /content/{id}).
# 'published' is deliberately absent from every value set: only the publishing
# flow (POST /publishing/publish/{id}) may move content into 'published'.
ALLOWED_TRANSITIONS: dict[str, set] = {
    "planned": {"draft"},
    "draft": {"review"},
    "review": {"approved", "rejected", "draft"},
    "approved": {"scheduled", "review"},
    "rejected": {"draft"},
    "scheduled": {"approved"},
    "published": set(),
}

Seven statuses, and published is sealed in both directions. It appears in no value set, so no PUT reaches it, and its own key is empty, so nothing leaves it. A PUT from draft to published returns a 409 naming the allowed next statuses. Same-status writes stay open so body edits are unaffected, and moving into approved or rejected also checks the caller's role and writes an approval record.

This table replaced a UI that hid the publish button. Hiding a button is a suggestion.


What the coverage check does not check

Here is the honest limit of the best idea in this layer.

carries is a string the model wrote about its own proposal. After materialise stores it on the piece, exactly two lines in the codebase read it back, both in retrospective.py: one prints it beside the piece's title in the prompt, one feeds it to _uncovered_messages. Zero read item.body.

So the coverage guarantee is narrower than it sounds. A piece declares it carries "Independently audited", the Writer drafts a body that never makes that claim, and the retrospective reports full delivery. The matching is substring in both directions, so a piece labelled "Report" also marks "Report on request" as covered. The check catches a hole in the plan. It does not catch a piece that abandons its assignment during drafting.

Two other places this layer is thinner than it looks.

The coverage rule is implemented twice, once in Python in _shared.py and once in TypeScript in MixPanel.tsx:

export function uncoveredMessages(coreMessages: string[], pieces: MixPiece[]): string[] {
  const carried = pieces.map((p) => (p.carries ?? "").trim().toLowerCase());
  return coreMessages.filter((m) => {
    const needle = m.trim().toLowerCase();
    return !carried.some((c) => c.includes(needle) || (c && needle.includes(c)));
  });
}

The client copy exists because recommend and adjust return the server's answer but a plain page reload does not, so without it the banner vanishes on refresh. The comment above says to change both together. That is a duplicated invariant held in place by a comment, which is the weakest enforcement there is.

And _stage reaches measure on the first published piece. A campaign with eight materialised pieces and one published reads as being at the final stage, with seven pieces still waiting.


What the design earns

Three things. A strategy that cannot move silently under the content derived from it. A proposal the human can edit before it becomes rows in a table. One rollup function the interface and the crew both read, so they cannot quote different numbers.

It does not earn a guarantee that a published piece says what it promised to say. That check would have to read bodies, and nothing here does.

The commit that finished the flow shipped 184 backend tests, 9 of them new, and walked the flow end to end against live model calls: the lock gate 409ing before the strategy was locked, recommend leaving the board at zero pieces, dropping a piece surfacing a newly uncovered message, and the retrospective returning an empty what_worked rather than inventing a win.

That last one is the test I trust most. The stage was allowed to say nothing happened.

The one-liner: the stages are the easy part, and the boundaries are the product.


Next in the series: the content layer, where a planned piece becomes a draft and the approval gate decides whether it ever ships.

The views expressed here are my own and are not related to or reflective of my work or any organization I am affiliated with.

Next

The Orchestrator's Tools Are the Other Agents

A typed blackboard, five delegation tools, and a dispatcher that refuses illegal moves. How the crew layer decides who works next.