Skip to main content

Publishing alignment roadmap

Status: live development Author: Frederike + Claude Last updated: 2026-06-06 Scope: getting brand voice, writing style, reference library, project_id scoping, cadence, onboarding state, and roles all consistent across the 4 content pipelines.

This spec exists because the audit found 7 tangled pieces that need one shared model before patching. Companion to projects-roadmap.md — that one's about the Projects layer itself; this one is about everything that runs on top of it.


1. The 4 content pipelines (current state)

Amplicast has 4 paths from raw input to a published post. They were built at different times and don't share a single prompt builder — that's why voice/style/refs leak through some and not others.

#PathTriggerWhat it doesWhere voice landsWhere writing style landsWhere reference images land
AAutopilot first-draftscheduled topicTopic → AI generation → ContentPackage✅ via ContentGenerator✅ via ContentGenerator✅ image gen path
BSource-fetched transformsource sync → pipeline_transform jobContentItem → AI transform → ContentPackage per platform✅ via TextTransformStep prompt_prefix✅ via UserStyleStore❌ MediaTransformStep ignores refs
CManual Create-tabuser types postNew ContentPackage, then transform per platform❌ no project_id stamping✅ if user has style❌ same as B
DApproved-from-review re-transformuser approves a draftRe-enters scheduled transform❌ ignores voice (parallel handler in cmd/worker/main.go)✅ via UserStyle❌ same

The root cause

There are two parallel text-transform handlers:

  1. internal/pipeline/text_transform_step.go (knows voice + style + refs to varying degrees)
  2. cmd/worker/main.go runTextTransform (knows style only — voice + refs blind)

Path A uses the first; Paths B-D use a mix. Until they merge, every new "make sure X is applied" change has to be made in two places — and easy to forget.

Target state

ContentPackage (always has project_id, user_id, org_id)

ONE prompt builder
├── Project voice (project_id → voice.ToPromptBlock())
├── User writing style (user_id → UserStyle)
├── Reference images (user_id → reference_library, scoped to current image generation)
└── Platform template + per-piece extracted content

AI call (text) + AI call (image)

Per-platform ContentPackage variants

One prompt builder. Every path stamps project_id on the package before it enters the pipeline.

Work to align

  • Stamp project_id on ContentPackage in path A (autopilot Generate) — services/content_generator.go
  • Stamp project_id on ContentPackage in path C (manual Create) — internal/api/content_handlers.go (or wherever CreatePost lives)
  • Worker's runTextTransform loads project voice via content_item.project_id (or package.project_id when item is absent) and prepends ToPromptBlock()cmd/worker/main.go
  • Merge the two transform handlers — long-term refactor; not blocking but tracked
  • Reference library decision — see §3

2. Project_id scoping audit

Project_id should flow end-to-end. Today it stops at source-fetched items (slice C work) but doesn't reach autopilot-generated packages or manual posts.

TableHas project_id column?Always populated?
content_sources✅ since onboarding
content_items✅ for source-fetched (sync-worker propagates); ❌ for manual content_items
content_packages❌ not stamped on Autopilot output; not stamped on manual Create
platform_posts❓ — check
autopilot_topics✅ since Phase 4
platform_accounts✅ for OAuth-stamped channels (slice E)

Work

  • Audit platform_posts for project_id (and add if missing)
  • Stamp project_id on content_package in: ContentGenerator, CreatePost handler, any other ContentPackage origin
  • Add a non-null constraint after backfill (Slice D — already tracked)

3. Reference Library — what's its actual role?

The honest question: should reference images influence scheduled publishing posts, or stay an Autopilot/Preview-only feature?

Argument for full integrationArgument for keeping it Autopilot-only
Consistency: refs apply everywhere, less to explainLess prompt complexity in the publish pipeline
One UI mental model: "this is how my visuals look"Refs already work for Autopilot which is the high-value path
Manual Create users may want platform-specific visuals, not reference-matched

Decision (2026-06-13)

Full integration with a project-level opt-in toggle. Reference images apply to scheduled publishing's image step too, but only when the project (or user) has flipped the toggle on. Off by default so existing flows don't change overnight; flipping on adopts the same references that already work for Autopilot.

Implementation note: store the toggle as project.settings.use_reference_library_for_publishing (default false). MediaTransformStep reads it; when true, loads DefaultReferenceImages and passes them into image regeneration prompts.


4. Cadence — single model

Current state

Two unrelated cadence concepts coexist:

  1. Source sync cadencecontent_sources.sync_frequency_minutes (15 / 60 / 360 / 1440). Per source. Used by sync-worker to schedule next_sync_at.
  2. Autopilot cadenceautopilot_topics.cadence (daily / 3x_week / weekly / biweekly). Per topic. Used by autopilot scheduler.

The onboarding "Automation" step writes (1) to all sources at once. That's the bug — it should either be (a) per-source like the Sources tab, or (b) deferred until a source exists.

Target state

ConceptLives onScope
Source sync cadencecontent_sources.sync_frequency_minutesper source (which means effectively per-project since sources are project-scoped)
Autopilot cadenceautopilot_topics.cadenceper topic (per-project via topic's project_id)
Project default cadence (new, optional)projects.settings.default_sync_frequency_minutesper project — used as the default for newly-created sources in that project

The new projects.settings.default_sync_frequency_minutes is the right place for onboarding's "How often should we check for new content?" question. It applies as a default to new sources in this project; existing sources keep their own. If user creates a source mid-onboarding the default applies; if they don't, the value just sits as a future default.

Work

  • Add default_sync_frequency_minutes to projects.settings (no migration needed — JSONB)
  • Onboarding "Automation" step writes to project default, not to all sources
  • Source-create flow uses project default when no per-source cadence specified
  • Dashboard Sources tab keeps per-source override
  • Doc: clarify the two cadences in Automation docs or new dedicated page

5. Onboarding state model

Decision (2026-06-06)

Per-workspace (= per-org), not per-user or per-project.

Rationale:

  • A user signing up creates a workspace + goes through onboarding
  • A team invitee joining an existing workspace skips onboarding entirely (the workspace is already set up)
  • This avoids the privacy issue of "your colleague @nike.com signed up, ask them first" — team formation happens via explicit invites
  • One person managing multiple projects in their workspace doesn't re-onboard per project; they just create more projects

Current state vs. target

WhatCurrentTarget
Where state livesusers tableorganizations table (onboarding_step, onboarding_completed_at, onboarding_data)
Resume default step"sources" (bug — skips new project step)"project"
First project step auto-skip when projects existyes — auto-advances past project stepNO — let user explicitly skip if they want
Skip-for-now on Sourcesgoes to /dashboard (bug)goes to next step
OAuth callback during onboardinghijacks to /dashboard after 1.2sonly redirects if onboarding_completed_at IS NOT NULL
Team invitee onboardingruns same flow (currently)bypasses onboarding (joins workspace already set up)

Work

  • Migration: move onboarding columns from users to organizations (or add to orgs and read both for back-compat)
  • Default step → "project"
  • Remove project-step auto-skip; backend state drives resume
  • Sources-step Skip-for-now → next step
  • OAuth callback: check getOnboardingStatus() before hijacking
  • Team invitee: detect onboarding_completed_at IS NOT NULL and route straight to dashboard

6. Roles — doc vs reality

Reality (in code)

RoleWhat they can do
ViewerRead everything in their org
EditorRead + create/publish content, connect Bluesky/Reddit (not OAuth platforms), approve content
AdminEditor + OAuth-platform connections, manage publish settings, manage members
OwnerAdmin + billing, delete workspace
Super-admin (env-var only)Cross-org / cross-project /admin endpoints

Doc claims that don't match

  • team.md says Admin/Owner approve content — but code allows Editor to approve.
  • team.md may have other capability rows that don't match Editor's real capabilities.

Work

  • Decide: tighten code (require Admin+ for approve) OR soften doc (any Editor can approve)
  • Update team.md capability matrix to match whichever direction we pick
  • Validate role enum on invite/update endpoints to reject typos ("admn" etc.)

Decision (2026-06-13)

Soften the doc now (single-tenant honesty), tighten the code later as part of multi-tenant work. When SaaS clients with bigger teams come online, gate the approve endpoints behind admin/owner so an editor can't auto-publish without review. Track as a multi-tenant prerequisite, not blocking solo work.


7. Sources actually shipping

User audit (2026-06-06):

Source typeStatusDoc state
supabaseReal, primary pathDocumented as primary
b2_manifestReal, used for content (Tirida-style)Documented (recently genericized)
notionPartial — backend handler exists but not validated end-to-endImplied "available" — should say "experimental"
web_scraperPartialSame
rss_feedUntestedSame
githubDefined in validTypes but not actually usedSame
cms ("Amplicast CMS" — to be renamed to "Write & Autopilot")Intentional UX signal, not a content_sources row. See §7a below.Not documented yet

Work

  • Rename Amplicast CMS tile → "Write & Autopilot" (see §7a — keep, redesign)
  • In docs: mark Notion / RSS / web scraper as experimental — set expectations
  • Stub per-source pages only for sources that ship today (Supabase + b2_manifest); add others as they're verified

7a. "Write & Autopilot" — the internal-content preference (2026-06-13)

The "Amplicast CMS" tile in the onboarding Sources step is not a stale UI. It's an intentional signal: "I want to create content from inside Amplicast, not only ingest from external sources." Two surfaces depend on this preference:

If user picks "Write & Autopilot"If they don't
Create tab visible — user can write posts directlyCreate tab hidden (or downplayed) — they only see external-source content flow
Autopilot tab visible — generate fresh content on a scheduleAutopilot tab hidden — they only manage external-source ingestion
Review queue still works for both internal + external contentReview queue only deals with external content

This gives users who only use external sources (CDN, Notion, Supabase) a cleaner product — no UI for writing they won't use. Users picking "Write & Autopilot" get the full content-creation surface.

Why the current implementation breaks

The frontend posts it as source_type="cms" → backend rejects with HTTP 400 because "cms" isn't a valid source type. Result: the tile errors silently and the preference is never persisted.

Target implementation

  • Frontend: rename tile to "Write & Autopilot" with a writing/pencil icon (or split icons for both); show it alongside external sources but visually grouped differently (e.g. "Where does your content come from?" → external sources + "Or create from inside Amplicast" → Write & Autopilot).
  • Backend: instead of saving as a content_sources row, store as a per-project setting: project.settings.internal_content_enabled = true. No HTTP 400 to swallow. Add to project-create handler so new projects can opt in.
  • Dashboard: gate the Create and Autopilot tabs on internal_content_enabled. Users can flip the preference later in Project Settings.

Multi-select + auto-select default (refined 2026-06-13)

Users should be able to pick multiple sources + "Write & Autopilot" — they're not exclusive. Someone with a Supabase source can also use the Create tab.

Default state in onboarding: "Write & Autopilot" auto-selected. Most users will want the Create tab; pre-checking it avoids them missing the feature. A small inline note: "Deselect if you only post via external sources — you can always switch it back on in Project Settings."

Don't fully hide the Create tab

When internal_content_enabled=false, demote the Create tab rather than hide it — show a small "Need to write something manually? Enable internal content →" affordance in the sidebar (or as a card on the Overview tab). This prevents the failure mode where a user can't find the Create tab and thinks the product is broken. Cheap insurance.

(Onboarding is admin-only and the toggle lives in Project Settings, so we don't need to expose role-gating for the affordance itself.)

Doc copy

In product copy and docs, talk about it as one of the content origin options:

  • "Connect external sources" (Supabase / Notion / b2_manifest / RSS)
  • "Or write inside Amplicast" (Create tab + Autopilot)

Not "CMS" — that word is technical and unclear to non-engineers.

Work

  • Rename tile + change frontend save path to PATCH project settings (not POST /sources)
  • Backend: ensure internal_content_enabled propagates from onboarding into project.settings
  • Dashboard tabs: gate Create + Autopilot on the setting (or fall back to showing them when no projects have any sources, so we never lock users out)
  • Project Settings: add toggle in General tab
  • Docs: mention this in the Projects guide and Sources page

8. The 4 use cases — design before transform-handler merge

The publishing-alignment audit surfaced two parallel transform handlers (§1). Merging them is risky because four different use-case shapes all flow through these handlers today. Before any merge, we map all four explicitly and decide which shared abstraction holds them.

This section is a design analysis, not an implementation plan. Implementation is deferred until we've validated the model against real flows.

Use case (a) — External source, fully automated

Source sync (cadence-driven)
→ ContentItem (project_id stamped)
→ Transform per platform (voice + style + refs)
→ Publish NOW or at scheduled time
  • Time of publishing: immediate after transform, OR at next_publish_at set by source policy
  • Decision points: none — fully autonomous
  • Edge: too many posts: a busy source could overrun rate limits or quota. Need quota check (already exists at user/org level) + per-channel rate limiting + maybe a "queue cap per project" so a burst of 50 items doesn't all post in one hour
  • Components: sync-worker → text+image transform → publisher
  • Human in the loop: none under normal operation; only failures (auth expired, quota exceeded) escalate

Use case (b) — External source, with review / channel pick / re-transform

Source sync
→ ContentItem
→ Transform (initial pass)
→ DRAFT or REVIEW state
→ Human: pick channels, adapt image, re-transform, approve
→ Publish at chosen time
  • Decision points: which channels (subset of project's connected channels); whether to re-run transform with tweaks; image adaptations; final publish time
  • Components: sync-worker → text+image transform → review queue → human edits → publish
  • Human in the loop: required for approval

Use case (c) — Internal Create-tab content

User types post in Create tab
→ ContentPackage (project_id stamped — just fixed)
→ Transform per platform (voice + style + refs)
→ Auto-publish OR review OR draft
  • Decision points: target channels (selected at Create time), publish-now vs schedule, optional review-before-publish
  • Components: create handler → text+image transform → (optional review) → publisher
  • Human in the loop: optional per project policy

Use case (d) — Autopilot generated

Topic (project_id stamped — done in Phase 4)
→ Cadence fires
→ ContentGenerator (with voice + style + refs)
→ ContentPackage
→ Auto-publish OR review queue
  • Decision points: review-before-publish (existing review_settings), which channels (from topic.target_account_ids), generation cadence
  • Components: autopilot scheduler → ContentGenerator → ContentPackage → (optional review) → publisher
  • Human in the loop: optional via review_settings

The unification

All 4 paths produce a ContentPackage (always with project_id) that needs:

  1. Transformation per platform (text + image, applying voice + style + refs + platform spec)
  2. Optional review (decided per project / per content-source-type policy)
  3. Publishing at the right time (NOW / scheduled / queued / rate-limited)
  4. Quota + rate limit + failure escalation

The pipeline could be:

SOURCE (external sync / autopilot / Create / re-transform)

ContentPackage [project_id required, source_type identifies the origin]

[optional review #1: "review raw content?"]

Transform per platform
- voice (project)
- writing style (user)
- reference images (user, opt-in for publishing path)
- platform spec

[optional review #2: "review transformed output?"]

Schedule → publish (quota + rate limit)

Per project, two policies decide review gates:

  • review_raw_content_for_<source_type> — review (a) and (b) before transform? Usually only for external sources.
  • review_transformed_output_for_<source_type> — review (b)/(c)/(d) after transform? Per-source-type.

This way:

  • Fully-automated (a) sets both to false → no human touchpoints.
  • Reviewable (b) sets review_raw_content_for_supabase=true → human curates before AI touches it.
  • Tweakable (c)/(d) set review_transformed_output_for_cms=true → human approves AI output.

Open design questions (BEFORE we merge anything)

  1. Do review_raw / review_transformed belong on the project (one policy per brand) or per source (different policies per source within one project)?
  2. Where do rate limits live? Per-channel (TikTok API), per-project (brand pacing), per-org (quota)?
  3. Is "queue cap per project" a real thing, or do we rely on user-set publish times to spread bursts?
  4. Should (c) and (d) share the same transform code path as (a)/(b), or stay separate because the input shape differs (ContentItem vs typed user input vs autopilot-generated)?
  5. The two handlers today: cmd/worker/main.go runTextTransform (manual + scheduled publishes) and internal/pipeline/text_transform_step.go (called from sync-worker pipeline_transform jobs). Which one becomes the canonical builder, or do we extract a third?
  1. First: ship all the other roadmap work — onboarding state migration, cadence model, role doc alignment, reference library plumbing, Write & Autopilot rename. These are well-scoped and don't risk the transform pipeline.
  2. Then: design pass on the 4 use cases — answer the open questions above with the user, write a §9 / new spec page if needed.
  3. Then carefully: merge the prompt builder first (low risk — only changes how prompts are assembled).
  4. Then very carefully, with tests: extract a single Transform service from the two handlers. Keep both call sites running in parallel until we're confident the new path produces identical-or-better output.

Priority order (updated 2026-06-13)

  1. ✅ Critical fixes 1–3 done (DO URL leak, email sender, voice on manual Create)
  2. Onboarding state migration to orgs (§5) — fix the resume-on-wrong-step + Sources-Skip-to-dashboard bugs in the same pass
  3. Write & Autopilot rename + redesign (§7a) — small UX + backend wiring
  4. Cadence model: project default + per-source (§4)
  5. Role doc alignment (§6) — soften team.md to match code
  6. Reference Library plumbing with project-level toggle (§3)
  7. Pause: design pass on the 4 use cases (§8) — answer open questions with user
  8. Merge prompt builder (low risk)
  9. Merge transform handlers, carefully, with tests (only after design + prompt-builder merge are stable)

Decisions on file

  1. Onboarding state lives on organizations, not users or projects.
  2. Cadence: per-source primary, per-project default, autopilot keeps its own.
  3. Source types not yet shipped = remove from UI, don't half-ship. Exception: "Amplicast CMS" tile is intentional UX (see §7a) — rename to "Write & Autopilot", redesign as project setting.
  4. Roles: soften team.md now (single-tenant honesty); tighten code as part of multi-tenant work.
  5. Reference Library: full integration via MediaTransformStep, gated by per-project use_reference_library_for_publishing toggle (default off).
  6. "Platforms" stays in URL slugs and sidebar (renaming was considered, rejected for slug stability).
  7. Transform handler merge is deferred until the §8 design pass with the 4 use cases is complete and the prompt builder merge has landed cleanly.

Open questions

Carried forward to §8 (design pass) — answer with user before transform-handler merge:

  1. Review policies on project vs per source vs per content_source_type?
  2. Where do rate limits live? Per-channel API limits, per-project brand pacing, per-org quota?
  3. Is "queue cap per project" needed for too-many-posts edge case in fully-automated mode?
  4. Should (c) (Create) and (d) (Autopilot) share the same transform code as (a)/(b) (external sources)?
  5. Which existing handler becomes the canonical builder — or do we extract a third?

Plus carried forward:

  1. Does "team invitee skips onboarding" reduce too much? Should they see a short welcome ("you joined Acme's workspace; here's what's already set up")?