Developer docs
Quality

Creating a post

Composer to live link: variants, the approval gate, the publisher's claim, and what each failure looks like.

docs/POST_FLOW.md

From an empty composer to a live post with a link you can click. Every gate below exists to make the third non-negotiable in PROJECT_BRIEF.md structurally true — nothing publishes without a recorded approval — rather than a convention somebody remembers.


One post, many variants

The shape everything else follows: a Post holds the idea, and a PlatformPost holds what each channel will actually receive. They have separate statuses, and that is the point — a post can be live on LinkedIn and still failing on Instagram.

The Post's own status is derived from its variants, never set directly. Once every variant is published, the post is. That is why a partly published post keeps a schedule form for the variants that have not gone out.


The whole path

Two things worth reading off that.

An external contributor never chooses. An agency or client author produces a proposal whatever the project's setting says. The check is not a UI convenience — their post cannot reach the publisher at all.

Approval is per project, overridable per post. requires_approval on the payload wins; an absent one falls back to project.requiresApproval.


The status machine

posts/state.ts holds the only legal moves, and refuses an illegal one rather than recording it.

Four edges carry most of the meaning:

  • publishing → scheduled is a retry, not a rollback. It is how a stuck claim is released.
  • on_hold cannot go straight to scheduled. A client hold parks the whole post out of the publish path; you un-hold to approved first, so nothing escapes a hold by a side door.
  • failed → publishing lets a failed attempt be retried directly, without redoing the work.
  • published has no way out. It is terminal because the post exists in the world now.

publishing and published are also protected: an accidental path — deselecting an account in the composer, an autosave sync — may never delete them, because that would cascade away the analytics and audit rows. Explicit deletion still works; that is the user's call.


Publishing

The claim is the concurrency control. Two worker replicas both see the same due row; only one gets a non-zero update from scheduled → publishing, and the other moves on. No separate lock table, and no window where a row is claimed but unmarked.

A crashed worker leaves rows stuck in publishing. reapStalePublishing finds them on the next tick and returns them to scheduled, which is what that retry edge is for.


What a reader can check afterwards

Every published row carries the permalink the platform returned, so "published" is something you can click rather than something we assert. Where a platform gives no derivable URL, derivePermalink returns null rather than guessing — a link that 404s is worse than no link.

The Publish log (/projects/:id/publish-log) answers the morning question — did everything go out — ordered by what needs a person: overdue first, grouped by cause, settled below.


The endpoints

StepCall
Upload mediaPOST /api/v1/uploads
Create the post + variantsPOST /api/v1/posts
Edit itPATCH /api/v1/posts/:id
Add a channel laterPOST /api/v1/posts/:postId/platform-posts
Schedule or re-time a variantPATCH /api/v1/posts/:postId/platform-posts/:id
Publish one nowPOST /api/v1/posts/:postId/platform-posts/:id/publish
Send for reviewPOST /api/v1/posts/:postId/approvals
DecidePOST /api/v1/approvals/:id/decisions
See what went outGET /api/v1/posts · the Publish log

Where it goes wrong, and what each looks like

SymptomWhat it means
Says Scheduled but nothing happenedthe worker is not running — GET /api/v1/health must show worker: up
One channel failed, others livenormal: variants have their own status. The row carries publish_error verbatim
Stuck in publishinga worker died mid-publish. reapStalePublishing returns it to scheduled
Approved, but the post has no publish datethe approval landed with no scheduled_at on the post — approval and scheduling are separate decisions
Published with no linkthat platform exposes no derivable permalink. Deliberately null rather than a guess