Herald — Phased Build Plan
Each phase ends shippable and testable with a concrete acceptance check. Do
them in order. Turn each phase into a task-by-task plan with the
superpowers:writing-plans skill, then implement TDD. Do not mark a phase done
until its acceptance check passes with output you actually saw.
The matching kickoff prompt for each phase is in ../prompts/.
Phase 0 — Repo, infra, CI, health
Build: monorepo skeleton (backend/, frontend/); FastAPI app with
/health; Neon connection via DATABASE_URL; first migration (enable
pgcrypto, citext; create tenants, users); UV project; Railway web
service deploys; Vercel project links frontend/; env var wiring documented.
Acceptance: GET /health returns 200 on the deployed Railway URL; the
migration runs clean against Neon; the empty Next.js app renders on Vercel.
Phase 1 — Tenancy + auth (API keys + dashboard login)
Build: tenant + user models; dashboard email/password session login; API-key
create/list/revoke (hash stored, full key shown once); the Bearer hld_… auth
dependency that resolves a request to a tenant; the tenant-scoping data layer.
Acceptance: unit test — a key for tenant A cannot read tenant B's rows; an API call with a revoked key → 403; login works on the deployed dashboard.
Phase 2 — OAuth connect flow + connected_accounts
Build: connectors/base.py Protocol; oauth_states (signed, single-use);
GET /api/oauth/{network}/connect + /callback; encrypted token storage;
resolve_identity; account status. Implement the Instagram and
LinkedIn OAuth halves (authorize URL, code exchange, identity resolution,
IG Business-account detection + rejection of personal accounts).
Acceptance: against each platform's sandbox/test app (or mocked HTTP for
unit tests), a full connect round-trip creates an encrypted connected_accounts
row with the right platform_account_id/page_id; a personal IG account is
rejected with a clear message. Token ciphertext is never plaintext in the DB.
Phase 3 — Media pipeline
Build: object-store client (R2/Blob/S3); POST /v1/media (file + source_url);
public-URL return; media_assets. Helper used by the publisher to guarantee a
public image URL before an IG publish.
Acceptance: upload an image → the returned URL is publicly GET-able (fetch it with no auth and get the bytes).
Phase 4 — Publishing engine + connectors
Build: PublishPayload/PublishResult; connectors/instagram.py
(2-step Graph publish + permalink) and connectors/linkedin.py (image upload +
/rest/posts + permalink); token-refresh (ensure_fresh) before publish;
per-target result mapping; error taxonomy (reauth_required, rate_limited,
invalid_media, …).
Acceptance: unit tests with httpx MockTransport prove the exact request shapes for both networks and the permalink parsing; a real manual publish to one IG Business account and one LinkedIn company Page returns a permalink you open in a browser.
Phase 5 — Public API: posts + targets + status
Build: posts + post_targets; POST /v1/posts (validate, fan out, publish
inline when scheduled_at null); GET /v1/posts/{id}, GET /v1/posts,
POST /v1/posts/{id}/retry; idempotency via external_ref; per-network
validation (caption length, IG needs media).
Acceptance: TestClient — POST /v1/posts to two accounts creates two
targets, publishes (mocked connectors), and GET shows partial when one
target is forced to fail; idempotent repeat returns the same post id.
Phase 6 — Scheduler + token-refresh worker
Build: the Railway worker process: due-target claim
(FOR UPDATE SKIP LOCKED), publish, bounded retry/backoff; proactive
token-refresh loop flipping accounts to expired on failure.
Acceptance: a post with scheduled_at a minute out publishes via the worker
(observed in logs + GET shows published); two worker instances don't
double-publish a target (claim test).
Phase 7 — Dashboard (Vercel)
Build: Next.js dashboard — login; Connect Instagram / Connect LinkedIn buttons (OAuth), connected-account list with status + reconnect/disconnect; a composer (text + media + account multi-select + schedule) that calls the BFF; post history with per-target status + "View post" permalink links; API-keys management screen. i18n keys, light mode, shadcn/ui.
Acceptance: on the deployed dashboard, connect a real account, compose + publish a post, and see the live permalink — all through the UI. Read the screens back at desktop + mobile width; "would I demo this to a paying customer tomorrow?" is yes.
Phase 8 — tars integration (first consumer)
Build: in tars2, a thin HeraldClient (see INTEGRATION-tars.md): on draft
approve, POST /v1/posts to Herald with the tenant API key + the brand's
connected account_id; capture the returned target permalink into the draft;
reuse the existing "View post" UI. Add per-campaign network/account selection +
auto-publish switch, mirroring the WordPress card.
Acceptance: approving a social draft in tars publishes it live via Herald and shows the IG/LinkedIn permalink in the cockpit.
Cross-cutting requirements (every phase)
- Multi-tenant scoping enforced + tested.
- Tokens encrypted at rest; never logged/returned.
/healthstays green; migrations forward-only.- TDD; a phase isn't done until its acceptance check passes for real.
- Secrets from env; no hardcoded credentials.
Suggested order rationale
Auth/tenancy (1) underpins everything; OAuth (2) must exist before you can publish; media (3) before IG publish (4); the engine (4) before the API (5); the API before the worker (6) and dashboard (7); tars (8) last, once the API is real.