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.
| # | Path | Trigger | What it does | Where voice lands | Where writing style lands | Where reference images land |
|---|---|---|---|---|---|---|
| A | Autopilot first-draft | scheduled topic | Topic → AI generation → ContentPackage | ✅ via ContentGenerator | ✅ via ContentGenerator | ✅ image gen path |
| B | Source-fetched transform | source sync → pipeline_transform job | ContentItem → AI transform → ContentPackage per platform | ✅ via TextTransformStep prompt_prefix | ✅ via UserStyleStore | ❌ MediaTransformStep ignores refs |
| C | Manual Create-tab | user types post | New ContentPackage, then transform per platform | ❌ no project_id stamping | ✅ if user has style | ❌ same as B |
| D | Approved-from-review re-transform | user approves a draft | Re-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:
internal/pipeline/text_transform_step.go(knows voice + style + refs to varying degrees)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
runTextTransformloads project voice viacontent_item.project_id(or package.project_id when item is absent) and prependsToPromptBlock()—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.
| Table | Has 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_postsfor 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 integration | Argument for keeping it Autopilot-only |
|---|---|
| Consistency: refs apply everywhere, less to explain | Less 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:
- Source sync cadence —
content_sources.sync_frequency_minutes(15 / 60 / 360 / 1440). Per source. Used by sync-worker to schedulenext_sync_at. - Autopilot cadence —
autopilot_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
| Concept | Lives on | Scope |
|---|---|---|
| Source sync cadence | content_sources.sync_frequency_minutes | per source (which means effectively per-project since sources are project-scoped) |
| Autopilot cadence | autopilot_topics.cadence | per topic (per-project via topic's project_id) |
| Project default cadence (new, optional) | projects.settings.default_sync_frequency_minutes | per 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_minutesto 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
| What | Current | Target |
|---|---|---|
| Where state lives | users table | organizations 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 exist | yes — auto-advances past project step | NO — let user explicitly skip if they want |
| Skip-for-now on Sources | goes to /dashboard (bug) | goes to next step |
| OAuth callback during onboarding | hijacks to /dashboard after 1.2s | only redirects if onboarding_completed_at IS NOT NULL |
| Team invitee onboarding | runs 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 NULLand route straight to dashboard
6. Roles — doc vs reality
Reality (in code)
| Role | What they can do |
|---|---|
| Viewer | Read everything in their org |
| Editor | Read + create/publish content, connect Bluesky/Reddit (not OAuth platforms), approve content |
| Admin | Editor + OAuth-platform connections, manage publish settings, manage members |
| Owner | Admin + billing, delete workspace |
| Super-admin (env-var only) | Cross-org / cross-project /admin endpoints |
Doc claims that don't match
team.mdsays Admin/Owner approve content — but code allows Editor to approve.team.mdmay 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.mdcapability 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 type | Status | Doc state |
|---|---|---|
supabase | Real, primary path | Documented as primary |
b2_manifest | Real, used for content (Tirida-style) | Documented (recently genericized) |
notion | Partial — backend handler exists but not validated end-to-end | Implied "available" — should say "experimental" |
web_scraper | Partial | Same |
rss_feed | Untested | Same |
github | Defined in validTypes but not actually used | Same |
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 directly | Create tab hidden (or downplayed) — they only see external-source content flow |
| Autopilot tab visible — generate fresh content on a schedule | Autopilot tab hidden — they only manage external-source ingestion |
| Review queue still works for both internal + external content | Review 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_sourcesrow, 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_enabledpropagates 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_atset 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:
- Transformation per platform (text + image, applying voice + style + refs + platform spec)
- Optional review (decided per project / per content-source-type policy)
- Publishing at the right time (NOW / scheduled / queued / rate-limited)
- 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)
- Do review_raw / review_transformed belong on the project (one policy per brand) or per source (different policies per source within one project)?
- Where do rate limits live? Per-channel (TikTok API), per-project (brand pacing), per-org (quota)?
- Is "queue cap per project" a real thing, or do we rely on user-set publish times to spread bursts?
- 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)? - The two handlers today:
cmd/worker/main.go runTextTransform(manual + scheduled publishes) andinternal/pipeline/text_transform_step.go(called from sync-worker pipeline_transform jobs). Which one becomes the canonical builder, or do we extract a third?
Recommended order
- 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.
- Then: design pass on the 4 use cases — answer the open questions above with the user, write a §9 / new spec page if needed.
- Then carefully: merge the prompt builder first (low risk — only changes how prompts are assembled).
- Then very carefully, with tests: extract a single
Transformservice 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)
- ✅ Critical fixes 1–3 done (DO URL leak, email sender, voice on manual Create)
- Onboarding state migration to orgs (§5) — fix the resume-on-wrong-step + Sources-Skip-to-dashboard bugs in the same pass
- Write & Autopilot rename + redesign (§7a) — small UX + backend wiring
- Cadence model: project default + per-source (§4)
- Role doc alignment (§6) — soften team.md to match code
- Reference Library plumbing with project-level toggle (§3)
- Pause: design pass on the 4 use cases (§8) — answer open questions with user
- Merge prompt builder (low risk)
- Merge transform handlers, carefully, with tests (only after design + prompt-builder merge are stable)
Decisions on file
- Onboarding state lives on organizations, not users or projects.
- Cadence: per-source primary, per-project default, autopilot keeps its own.
- 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.
- Roles: soften
team.mdnow (single-tenant honesty); tighten code as part of multi-tenant work. - Reference Library: full integration via
MediaTransformStep, gated by per-projectuse_reference_library_for_publishingtoggle (default off). - "Platforms" stays in URL slugs and sidebar (renaming was considered, rejected for slug stability).
- 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:
- Review policies on project vs per source vs per content_source_type?
- Where do rate limits live? Per-channel API limits, per-project brand pacing, per-org quota?
- Is "queue cap per project" needed for too-many-posts edge case in fully-automated mode?
- Should
(c)(Create) and(d)(Autopilot) share the same transform code as(a)/(b)(external sources)? - Which existing handler becomes the canonical builder — or do we extract a third?
Plus carried forward:
- 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")?