The "what & why" of this project. Updated after every major architectural step. Anyone (or future-you) should be able to load this file and understand the entire system without reading the code.
Last updated: 2026-08-16 (Trend inbox — per-workspace consumption + 7-day retention + dismiss-all. Trends stay a shared/global pool, but consumption moves off trends.consumed_at (one global flag) onto a new trend_consumptions(trend_id, workspace_id) join table, fixing a real cross-workspace leak. That one change plus a user-confirmed 7-day retention window (now the single source of truth for both RSS/HN ingest cutoff and the daily sweep) resolves the "have to page through and select everything to clear the inbox" complaint (new POST /trends/dismiss-all) and the "trends pile up forever" complaint (retention sweep now purely time-based). Single-trend hard-delete is now admin-only. Live-verified against real Supabase, including a real 580-row sweep. Full rationale in the 2026-08-16 decision entry below. 417 backend tests, tsc clean, next build green.) Earlier: 2026-08-16 (BrandSwitcher honesty fix + workspace rename. BrandSwitcher.tsx no longer hardcodes "Nestwhirr" — it reads the real workspace_name off GET /me, and a new PATCH /me/workspace lets any user rename their own workspace from a new Settings control. Small, user-requested fix; not multi-brand support. Full rationale in the 2026-08-16 decision entry below. 421 backend tests, tsc clean.) Earlier: 2026-07-22 (Design revamp — "Newsroom" identity + dual theme + live landing walkthrough. The prior "minimal dark" chrome read stiff/unprofessional because it leaned on a system serif (Georgia/Times) in both the app and the rendered cards, had no motion, flat low-contrast surfaces, and was dark-only. Fixed without changing the editorial thesis. (1) Type: one real editorial system across app AND backend cards — Fraunces (display: masthead/titles/numerals, font-display) + Newsreader (serif: pull-quotes/post body, font-serif); Geist Sans UI, Geist Mono metadata. App loads them via next/font/google (self-hosted, frontend/app/layout.tsx); backend card templates load the same fonts base64-embedded via a shared Jinja partial backend/app/rendering/templates/_fonts.html (generated by backend/app/rendering/assets/build_fonts_partial.py, re-run only when the font set changes) {% include %}d into all 6 templates — verified rendering real Newsreader italic in the PNG output, replacing Georgia. This makes "studio = the artifact it produces" literal at the glyph level. (2) Dual theme: frontend/app/globals.css rewritten to theme-scoped CSS vars on :root (warm-paper light) / :root[data-theme="dark"] (refined warm dark), mapped into Tailwind v4 @theme inline so all existing bg-bg/text-ink-hi/border-border utilities re-theme unchanged. New frontend/components/shell/ThemeProvider.tsx (pref light/dark/system, persisted to localStorage['autopost:theme'], system-change sync) + a no-flash inline <head> script in layout that stamps data-theme before paint. Gotcha recorded: the no-flash script must inline the storage-key literal — a server component importing a value from a "use client" module gets a client-reference stub (undefined), which silently broke theme resolution. Toggle (ThemeToggle.tsx, mounted-guarded against hydration mismatch) sits in the rail footer, mobile top bar, and the logged-out masthead. Rendered cards stay dark in both themes (fixed output identity). (3) Motion: added motion (motion/react, formerly framer-motion) + shared reduced-motion-aware variants in frontend/lib/motion.ts. (4) Landing centerpiece: frontend/components/marketing/StudioWalkthrough.tsx (+ SyntheticCursor.tsx) — a live, code-based studio simulation that walks the whole status spine (trend → Generate → typed-in-voice draft + voice-chip flips clean → one Approve fans out to 6 platform char-meters → Schedule queues each at a staggered slot, status Scheduled → Publish flips all to emerald "Live", status Published) with a scripted synthetic cursor, autoplay+loop, a "Try it yourself" manual handoff, and a prefers-reduced-motion static fallback; its progress rail mirrors the real 5-stage funnel; the cockpit is the real editor|preview two-pane — left = draft + a Visual source picker (Template · AI image · Upload, mirroring render_kind) + the 6-platform fan-out overview (char budget → scheduled slot → live), right = a live per-platform social mockup (new components/marketing/WalkthroughPreview.tsx: real LinkedIn feed card, dark microblog cards, Instagram card — the same idea as the cockpit's PlatformPreview.tsx), cycling platforms with per-platform char budgets + tighter Bluesky/X rewrites; the chosen Visual renders inside each card (Template = the actual PostCardMock quote-card replica, gained a compact variant; AI = diffusion field; Upload = photo), and the brand card stays dark in both themes; built on tokens so it themes automatically. Landing.tsx re-skinned to a newsroom masthead (Fraunces) with the walkthrough as hero; app screen titles/numerals moved font-serif→font-display, post-content stays Newsreader. Frontend tsc clean + next build green (11 routes). No backend logic change beyond the font partial. Earlier: 2026-07-08 (Phase 11 Chunk F — X / Twitter publisher: full X API v2 (OAuth2-PKCE, media upload, POST /2/tweets) as a sixth platform behind PLATFORM_X_ENABLED; the only paid platform (~$0.015/post) with a manual-post ("generate-and-paste") mode. See the 2026-07-08 decision entry below for the full architecture. 379 backend tests. Earlier: Phase 12 Chunks B–D — categories expansion + fresh feeds. Phase 12 code-complete. Chunk B: four new categories (dev_tooling, pricing_business_model, product_postmortem, design_thinking) added to the PostCategory enum + seed migration c5f9a26b81e4 inserting 20 prompt rows (4 categories × 5 platforms, all v1/active). The 4 new enum values are added inside an autocommit_block so the same migration's seed INSERTs can reference them (Postgres forbids same-transaction use of a freshly-added enum value). Per category, the LinkedIn row is a Stage-1 angle/writer prompt (v4 founder-voice shape, 2 real-figure anchors); the Bluesky/Threads/Mastodon/Instagram rows are Stage-2 rewrite prompts built from shared per-platform scaffolds (_bluesky/_threads/_mastodon/_instagram) each carrying a category-tuned anchor honoring that platform's char budget (Instagram is modeled as a 2200-char hashtag-heavy caption, not a compression). Drafts dropdown grows 4→8; the PostCategory TS union + CATEGORY_LABEL/CATEGORY_COLORS maps were extended across all 4 category-rendering pages (drafts, drafts/[id], scheduled, published) to keep tsc green. Chunk C: RSS freshness pass — web-verified candidates live, added 3 (lethain.com, jvns.ca, nngroup.com — all confirmed publishing June/May 2026), cut 2 (rachelbythebay blocks our bot UA with 404s; a16z /feed/ 404s + duplicates Stratechery/Stripe). DEFAULT_FEEDS now 10 feeds. Chunk D: dropdown + Phase-11 PlatformPreview auto-consume the matrix (preview reads target.commentary, now matrix-sourced — zero new UI code); freshness check run live (27 items in 90d, 11 in 30d across the new feeds). The live LLM output-length smoke (Bluesky short vs LinkedIn 140–220w) stays user-driven; matrix dispatch is proven at the code level. Follow-up (migration e8b4c1f7d320): backfilled the 16 missing rewrite prompts for the original 4 categories so all 8 generatable categories are uniform on the matrix (40 active prompt rows); compress_for_bluesky is now a pure fallback. 333 backend tests passing, tsc clean. Earlier: Phase 12 Chunk A — per-platform prompt matrix refactor. prompts gains a nullable platform enum column (migration b7d3a1c8e92f, reuses the existing platform enum type; existing 4 rows backfilled to 'linkedin'); unique constraint swapped to (name, platform, version). Matrix lookup: _load_active_prompt(session, category, platform) prefers the per-platform variant and falls back to platform IS NULL (graceful for new categories pre-seed); the canonical Post is still always the LinkedIn artifact (Stage 3/4/5 + rendering are LinkedIn-shaped) so it loads platform='linkedin'. The Phase-11 hardcoded compress_for_bluesky inline stopgap is retired in favor of DB-driven run_platform_rewrite: _build_platform_commentary(session, category, platform, …) loads the exact per-platform prompt via _load_platform_prompt (no NULL fallback — a NULL prompt is LinkedIn-shaped, wrong for a rewrite source) and runs a FAST-model Stage-2 rewrite of the canonical text. LinkedIn target → commentary=None. No-prompt fallbacks: Bluesky → legacy compress_for_bluesky (keeps existing categories publishing), Mastodon/Threads/Instagram → None (trip the char-gate loudly until their prompt is seeded — intentional pressure). Over-cap rewrite output is NOT truncated; PLATFORM_CHAR_LIMITS mirrors each Publisher.char_limit. Per-platform tone lives in the seeded prompt bodies (Chunk B). 333 backend tests passing (+12 in test_prompt_matrix.py + updated TestBuildPlatformCommentary). Earlier: Phase 11 Chunk E — per-platform preview + E2E. Phase 11 complete. New generic frontend/components/PlatformPreview.tsx replaces the LinkedIn-only preview on the draft detail page: a platform tab strip across the draft's targets dispatching to per-platform card variants (LinkedIn reuses the rich LinkedInPreview; Bluesky/Mastodon/Threads share a text-first MicroblogCard; Instagram gets an image-first InstagramCard that flags the no-image case since IG can't post text-only). Inline char-limit warning per tab — PLATFORM_LIMITS mirrors each Publisher.char_limit (LinkedIn 3000 / Bluesky 300 / Mastodon+Threads 500 / Instagram 2200); over-cap shows an amber banner pointing at Phase-12 per-platform generation as the real fix, not truncation. Code-level E2E added: test_fan_out_persists_distinct_urns_per_platform proves one draft → LinkedIn+Bluesky targets each persist a distinct external_urn. 321 backend tests passing, tsc clean. Live multi-platform E2E (real publishes) remains user-driven. Earlier: Phase 11 Chunk D — Instagram publisher, Meta-gated. Fifth (and final Phase-11) platform. Meta's Instagram Login flow (NOT Facebook Login) — the lightweight single-fixed-app path that skips the legacy Facebook-Page link; requires a Creator/Business account. New instagram_accounts table (migration a3c8e0f5471b) with expires_at but NO refresh_token — IG refreshes the 60-day long-lived token in place via GET /refresh_access_token?grant_type=ig_refresh_token (same in-place pattern as Threads). Raw-httpx InstagramPublisher (two-step container API: POST {graph}/{ver}/{ig-user-id}/media → poll container status_code until FINISHED → POST .../media_publish with creation_id; single image = image_url+caption, carousel = per-item is_carousel_item image containers + parent media_type=CAROUSEL with children). KEY DELTA vs Threads: Instagram has no text-only post — empty assets raise PermanentPublishError. 2200-char caption, 2–10 carousel items; IG fetches image_url from our public Supabase bucket itself. Code-exchange returns the IG-scoped user_id directly (the publish path segment) and handles both flat + {data:[…]} wrapped token-body shapes. PLATFORM_INSTAGRAM_ENABLED flag (default false) gates the connect flow (503) + frontend picker — /auth/instagram/status.enabled is the single source of truth. Scopes are env-driven (INSTAGRAM_SCOPES=instagram_business_basic,instagram_business_content_publish — the Instagram-Login naming). Orchestrator/scheduler/posts API: ZERO changes — Platform.instagram already existed; wiring was one register-import + one _load_account branch. 320 backend tests passing, 30 new. Web-verified IG API against developers.facebook.com (two sources) per CLAUDE.md.) Earlier: Phase 11 Chunk C — Threads publisher, Meta-gated. Fourth platform. Single fixed Meta app (LinkedIn-shaped OAuth, NOT federated like Mastodon). New threads_accounts table (migration f1b3d7a8e564) with expires_at but NO refresh_token — Threads refreshes the 60-day long-lived token in place via GET /refresh_access_token. Raw-httpx ThreadsPublisher (two-step container API: POST {graph}/{ver}/{user-id}/threads → poll container status until FINISHED → POST .../threads_publish with creation_id; carousel = per-item is_carousel_item IMAGE containers + parent CAROUSEL with children). 500-char limit, 2–20 carousel items; Threads fetches image_url from our public Supabase bucket itself (no byte download). PLATFORM_THREADS_ENABLED flag (default false) gates the connect flow (503) + the frontend picker — /auth/threads/status.enabled is the single source of truth, no separate frontend env var. Orchestrator/scheduler/posts API: ZERO changes — Platform.threads already existed and everything downstream is platform-agnostic; wiring was one register-import + one _load_account branch. 290 backend tests passing, 28 new. Web-verified Threads API against developers.facebook.com (two sources) per CLAUDE.md.) Earlier: Phase 11 Chunk B — Mastodon publisher. Third platform, first full-OAuth one. Federated → per-instance dynamic app registration (POST /api/v1/apps); client creds carried in the encrypted OAuth state (no mastodon_apps table). New mastodon_accounts table (migration e2a7c4b96d18, no expires_at/refresh_token — Mastodon tokens don't expire). Raw-httpx MastodonPublisher (two-step /api/v2/media → /api/v1/statuses, 500-char/4-image caps, Idempotency-Key). STRICT char-limit per memory: NO compress_for_mastodon — Mastodon targets get commentary=None, fall back to the LinkedIn-shaped body, and trip the 500-char gate as intentional pressure toward Phase 12's per-platform prompt matrix. Endpoints /auth/mastodon/{start,callback,status,disconnect}; dashboard connect section + Mastodon added to the /drafts platform multi-select. 262 backend tests passing, 30 new.) Earlier: Phase 8 Chunk G — Native LinkedIn carousel via Documents API. Multi-slide carousels now publish as PDF document posts (/rest/documents + /rest/posts with content.media) so the live experience is the swipeable native LinkedIn carousel instead of the previous MultiImage grid (which clipped slide text). New app/rendering/pdf.py assembles slide PNGs into a multi-page PDF via img2pdf; linkedin_client gains the full Documents-API surface including async-processing polling; publisher._attempt_publish routes any len ≥ 2 asset batch through the document path and stamps every slide's linkedin_asset_urn with the shared document URN. carousel_slide.html got a tightened safe zone (1200×1200 outer / 840×1080 inner box, text-6xl → text-5xl). 172 tests still passing.) Phase 8 complete. Chunks C + D + E: APScheduler restart-survival verified live (kill-restart cycle, one-shot job rehydrated from SQLAlchemyJobStore and fired on time); full pipeline smoke verified live; README rewritten to reflect Phase 6/8 state and fix the .env path bug. Chunk E mid-phase: fixed the hook + tag duplication bug on the live LinkedIn post. Stage 3 prompt now requires body to exclude the hook line and trailing hashtag line; new generator.clean_body(hook, body) helper applies that deterministically at parse time; publisher._build_commentary runs the same helper at publish time so legacy drafts ship correctly without re-generation; LinkedInPreview now takes a hook prop so dashboard preview matches the live post. Backend suite 172 passing. Chunks A + B. Chunk B — Failure surfacing: two new endpoints — GET /posts/{id}/logs (lists PublishingLog rows newest-first) and POST /posts/{id}/retry (resets failed → approved, clears post.error, re-runs publish_post). Frontend /drafts/[id] gets a prominent red Failed panel with the error, a Retry button, and an expandable Attempt history fed by /logs. Chunk A — Auth health surface. Dashboard frontend/app/page.tsx gains a tokenExpiry() helper + a top-of-page banner driven off expires_at from /auth/linkedin/status: silent > 10d, amber ≤ 10d, red ≤ 3d or already expired. Each tier surfaces a "Reconnect LinkedIn" CTA — the operational fix for the day-60 manual re-OAuth that standard dev apps require. No backend change needed; expires_at was already on the status response since Phase 1. The "dedupe linkedin_accounts rows" checklist item turned out to be N/A — UniqueConstraint('user_id', 'account_kind') has been enforced since the initial schema migration.)
Phase 6 Chunk D — End-to-end smoke verified live: publish-now and scheduled APScheduler fire both produce real LinkedIn posts. Token-refresh path is built correctly but structurally unreachable on a standard LinkedIn dev app — LinkedIn doesn't issue refresh tokens with just w_member_social scope. Access tokens last ~60 days, then the operator re-runs OAuth. Phase 6 is functionally complete; ready to start Phase 7 (Analytics).
An AI-powered LinkedIn company branding & content automation platform. Fetches fresh tech/startup/AI trends, generates founder-voice posts via Groq LLM, renders branded visuals from HTML/Tailwind templates with Playwright, and auto-publishes to a LinkedIn company page on schedule with human-in-the-loop approval.
Internal-first for one company page, but architected to evolve into multi-company / multi-platform.
Seven loosely-coupled subsystems backed by one Postgres database. Subsystems communicate via the DB or in-process function calls — never HTTP between subsystems.
RETRIEVAL → INTELLIGENCE → RENDERING → DASHBOARD (human approval) → SCHEDULER → PUBLISHING → ANALYTICS
| # | Subsystem | Location | Responsibility |
|---|---|---|---|
| 1 | Retrieval | backend/app/retrieval/ |
Pull items from RSS, HackerNews, Reddit → trends table |
| 2 | Intelligence | backend/app/intelligence/ |
Groq LLM turns trends into founder-voice drafts → posts |
| 3 | Rendering | backend/app/rendering/ |
HTML/Tailwind templates → PNG via Playwright → generated_assets |
| 4 | Scheduler | backend/app/scheduler/ |
APScheduler in-process, Postgres jobstore |
| 5 | Publishing | backend/app/publishing/ |
LinkedIn OAuth + UGC Posts API |
| 6 | Dashboard | frontend/ |
Next.js — preview, edit, approve, schedule |
| 7 | Analytics | backend/app/analytics/ |
Poll LinkedIn share stats hourly |
Each entry: decision — rationale.
2026-08-16 (Trend inbox — per-workspace consumption + 7-day retention + dismiss-all; the trend-pool cleanup discussion)
- Problem raised (user): clearing the Inbox required paging through 50-at-a-time and multi-selecting to hard-delete, trends silently pile up forever with no expiry, and it was unclear whether trends should stay shared across workspaces or become isolated. All four questions turned out to be one design decision, not four independent ones.
- Resolved: retrieval/storage stays SHARED (unchanged — CLAUDE.md), but CONSUMPTION becomes per-workspace.
trends.consumed_at(one flag, global) is replaced by a new join tabletrend_consumptions(trend_id, workspace_id, consumed_at)— composite PK, both FKsondelete=CASCADE. New modelapp/models/trend_consumption.py. Migration2f27bf499527creates the table and dropstrends.consumed_at(+ its index); no backfill — historical consumption predates the retention tightening in the same change and would be stale within a day anyway, so every trend starts unconsumed for every workspace. Rationale for shared-storage-but-scoped-reads over full duplication: avoids refetching the same RSS/HN feeds per workspace (wasteful, matches the original Phase 14 rationale) while fixing the one real bug — workspace A generating a draft from a trend was silently marking it consumed for every other workspace too. Live-verified the fix directly: a second workspace still sees a trend as unconsumed after the first workspace consumes it. - This one schema change resolves all three other complaints:
- Bulk clear — new
POST /trends/dismiss-all?status=&source=bulk-upserts consumption for everything matching a filter server-side, no id list, no "load every page and select all" dance. OldPOST /trends/bulk-delete(id-list hard-delete) is gone. - Staleness — trends are shared, so retention must be a single global time-based rule, not per-workspace-conditional (a trend can be consumed by A and not by B — there's no one "is this trend done" flag to sweep on anymore).
scheduler/jobs.py::cleanup_consumed_trends_jobrenamed totrend_retention_job: deletes anyTrendrow wherefetched_atis older thansettings.TREND_RETENTION_DAYS, full stop, regardless of any workspace's consumption. Cron unchanged (TRENDS_CLEANUP_CRON, daily 3:30am). Safe per an earlier finding: nothing dereferences trend content once a draft exists (Post.source_trend_idsonly ever renders as a count). - "Delete a single trend" is now admin-only (
require_adminonDELETE /trends/{id}) — it's a hard delete of a SHARED row, so it has cross-workspace effect the moment there's a second real workspace; regular editors get non-destructive "Dismiss" instead. The per-row ✕ delete button was removed from the Inbox UI entirely (no admin trend-management surface was requested — just the endpoint gate).
- Bulk clear — new
- Retention window = 7 days, user-confirmed, and it now governs BOTH ends (ingest and cleanup) from one setting (
settings.TREND_RETENTION_DAYS, replacesTRENDS_CONSUMED_RETENTION_DAYS=60): (a)rss.py::MAX_ENTRY_AGE— was hardcodedtimedelta(days=90), now reads the same setting; (b)hackernews.pygained an age filter it never had before (HN'stop/bestendpoints can surface an older evergreen story; RSS already had a cutoff but HN had none) — filters on the item'stimefield. One number, one meaning, ingest and sweep can't drift apart. generator.pypicks + marks consumption per-workspace:_pick_trends(session, workspace_id, trend_ids, limit)now LEFT JOINsTrendConsumptionscoped to the caller's workspace for the auto-pick path (explicittrend_idsunaffected — those bypass the unconsumed filter entirely, unchanged).generate_draftmarks the picked trends consumed via an upsert intotrend_consumptionsfor itsworkspace_idinstead of writingTrend.consumed_atdirectly.- Live-verified end to end against real Supabase (not just mocked): consume/un-consume round-trip, per-workspace isolation (the actual bug),
dismiss-allbulk-upserting correctly scoped by filter, and the retention sweep. The retention-sweep smoke test fired for real and deleted 580 real stale trend rows from the live pool (everything older than 7 days — pool went from 685 → 106) as a side effect of verifying the job does what it's supposed to; flagged to the user as it happened, not something explicitly requested as a standalone action. - Tests:
tests/api/test_trends.pyrewritten (dropped theBulkDeleteRequestschema tests — that request type no longer exists; kept + renamed the sweeper-cutoff tests to the new workspace-independent,fetched_at-only math).tests/retrieval/test_hackernews.pygained an age-cutoff test + fixture dates bumped to "now-relative" (the old fixture used a fixed 2023 timestamp, which would've made every HN test item "too old" under the new 7-day filter).tests/retrieval/test_rss.pyone fixture date tightened from 10 days to 3 (was inside the old 90-day window, would've failed the new 7-day one). 417 backend tests pass. Frontend:app/trends/page.tsx— "Delete" language replaced with "Dismiss" throughout (per-row delete button removed, bulk "Delete N selected" → "Dismiss N selected" via looped PATCH, new "Dismiss all" button drives the new endpoint).tsc --noEmitclean,next buildgreen (11 routes, unchanged).
BrandSwitcher.tsxno longer hardcodes"Nestwhirr". It was a static prop from Phase 13 (2026-06-22, pre-multi-tenancy) that never got reconciled with Phase 14's realworkspacestable — it displayed a brand name with zero data behind it, implying a working multi-brand switcher that doesn't exist. Now readsworkspace_nameoffGET /mevia the existing shareduseMe()cache (no new fetch). "Add brand — coming soon" stays as an explicit placeholder rather than a functional dropdown. This is a UI-honesty fix, not multi-brand support — the underlying model is still one workspace per user (Phase 14); true multi-brand (a user belonging to several workspaces) would need aworkspace_membersjoin table + an active-workspace-selection mechanism and is out of scope here.GET /meresponse gainedworkspace_name(app/api/me.py): loads the caller'sWorkspacerow byuser.workspace_id(a directsession.get, not the lazyUser.workspacerelationship — accessing that lazily inside an async request would raiseMissingGreenlet). 404s if the workspace row is somehow missing rather than fabricating a name.- New
PATCH /api/v1/me/workspace({name}, 1–255 chars trimmed, blank → 422) lets any signed-in user rename their own workspace — the display name shown in the BrandSwitcher. No extra ownership check needed beyonduser.workspace_iditself (one workspace per user today); this is display metadata, not a tenant-isolation-sensitive write, so any role (not just admin) can do it. Frontend:components/settings/WorkspaceName.tsx, a single field + Save button placed above the Connections/Prompts/Templates/Voice sub-nav in/settings(identity metadata doesn't fit any existing tab).lib/useMe.tsgained a small pub/sub (setCachedMe) so a rename propagates live to every mounteduseMe()consumer (rail, mobile bar, admin gate) without a refetch. - Workspace names today are still whatever Phase 14's wipe-migration seeded (email local-part, e.g.
shivammaheshwari658) unless the operator renames them via this new control — nothing auto-migrates existing rows to "Nestwhirr". - Tests:
tests/api/test_me.py(4 — read returns workspace_name, missing-workspace 404, rename trims + persists, blank rejected). 421 backend tests pass (was 417).tsc --noEmitclean.
- The CLAUDE.md "no multi-tenant logic beyond
org_urn" Forbidden-in-MVP rule is lifted (user-approved 2026-07-19, deferred to this chunk so the schema landed first). Replaced with a lift-note pointing at this decision log, plus a new standing Architectural Rule: any new private table/write setsworkspace_id; any id-based fetch filters by the caller's workspace and 404s (not 403s) on mismatch; invited users defaulteditor; the only auth-bypassing routes arePOST /access-requests+ the OAuth callbacks. This encodes the Phase-14 invariants so future work can't silently regress tenant isolation. - Docs squared with the code:
INSTRUCTIONS.md"Invite-only access" now covers theSUPABASE_SERVICE_ROLE_KEYrequirement, the/adminpanel walkthrough (requests queue · users table · direct invite), and the seed-via-curltrick for smoke-testing approve against a real inbox (with the@example.com-rejected caveat).DESIGN.md §2gained thePostCardMock/login-door artifact note.CHECKLIST.mdPhase 14 fully ticked. - No code change this chunk — Phase 14 (invite-only + per-workspace multi-tenancy, Chunks A–I) is complete. Remaining items are user-driven live verifications only: the real approve→invite-email path (mocked in tests) and browser visual QA of landing/login/admin.
- Root route now session-gated.
app/page.tsxgained aHomeGate: no session → the public marketing landing, session → Today (unchanged). The old/→/loginredirect is gone. AppShell already renders bare (no rail) without a session, so the landing is full-bleed;/loginstays the invited-user door. Landingwears the artifact's identity. Four bands (hero · what-it-does · how-it-works = the pipeline spine · request-access) in the same minimal-dark + serif-display language as the rendered cards and studio chrome — deliberately not a SaaS hero (DESIGN.md §3 "the studio looks like the artifact"). Branded "Nestwhirr Studio".- Public intake wired to the funnel.
components/marketing/RequestAccessForm.tsxposts to the unauthenticatedPOST /access-requeststhrough a newlib/api.ts::apiPostPublic(no bearer — there's no session on the landing). Carries the backend's honeypot (website, off-screen +tabIndex=-1+aria-hidden); opaque success state so nothing leaks. These rows surface in the/adminqueue (Chunk G) for approve/deny (Chunk F). - Reconcile rule honored: DESIGN.md §2 IA updated in the same change (the logged-out landing is a new top-level surface). Verification:
tsc --noEmitclean;next buildcompiles (/is now static). Live smoke (curl mirroring the form's payload): honeypot submit dropped (no row), real submit created apendingrow with name/company intact; cleaned up. Visual browser e2e is user-driven. - Design pass (post-review — the first cut read generic and login was still boilerplate). The landing now leads with the artifact:
components/marketing/PostCardMock.tsxreplicates the renderedquote_card.html(black field, serif-italic pull-quote, giant open-quote glyph, hairline rule + uppercase brand strap) and anchors the hero — the "studio looks like what it produces" thesis made literal, not just claimed. Capabilities became numbered editorial rows; the pipeline a bordered 5-cell grid with notes; all flat/hairline/no-shadow (DESIGN.md §3).app/login/page.tsxwas rebuilt off the leftover Next.js boilerplate (zinc-*, "AutoPost") into the token/serif identity — a split artifact-panel/form layout reusingPostCardMock, cross-linking/#request. Lesson: when adding a user-facing surface, match the locked visual identity from the first cut — don't ship a token-less placeholder.
- New role-gated
/adminroute. Double-gated: an unauthenticated visitor is sent to/login, a signed-in non-admin is bounced to Today (/). Reuses the Settings blueprint verbatim —?tab=deep-linked sub-nav (Requests · Users),max-w-3xlcolumn, minimal-dark tokens — so it adds a surface without inventing a visual language. - Role plumbing: new
lib/useMe.tsuseMe()hook fetchesGET /meonce (module-level cache + inflight-dedupe, so the rail, mobile bar, and admin page share a single request). TheNavRailgains an admin-only "Admin" entry (shield glyph, below Settings behind a divider) shown only whenme.role === "admin";MobileTopBarmirrors it in the account menu.NavRailwas refactored to a sharedRailLinksub-component to render the conditional entry without duplicating markup. - Two panels:
RequestsQueue(status filter — Pending default / Approved / Denied / All; approve+deny per row →POST /admin/access-requests/{id}/{action}, toasts, backend 409s surfaced) andUsersTable(role<select>→PATCH, Revoke →DELETEbehind aconfirm(); self-revoke disabled client-side; last-admin/self guards surfaced from the backend). Both loaders follow the repo'suseCallback+useEffect(() => load())idiom. - Reconcile rule honored: DESIGN.md §2 IA updated in the same change (the admin rail entry is a new top-level surface). Verification:
tsc --noEmitclean;next buildcompiles/admin(11 routes). The repo-widereact-hooks/set-state-in-effectlint (22 pre-existing hits — a new Next 16 rule the codebase predates and doesn't gate on) also flags the admin data-loaders; left consistent with the surrounding code rather than diverging. Browser e2e (approve→invite email, role change, revoke) is user-driven — needs an admin login.
- New admin surface at
/api/v1/admin/*, every route behindrequire_admin(admin role → else 403): review the public access-request queue and approve/deny; list users; change role; hard-revoke; and a direct-invite escape hatch. Powers the/adminpanel (Chunk G). - Approve = Supabase invite + local pre-provision.
POST /admin/access-requests/{id}/approvecallssupabase.auth.admin.invite_user_by_email(email, {redirect_to})(web-verified against supabase-py 2.13.0 — sync SDK, service_role, returns the Auth user UUID), then pre-provisions the localusers+workspacesrows keyed by that same UUID so the invitee's first-login JIT path recognizes them (JIT otherwise denies any un-provisioned token). New users are alwayseditor— admin is granted only via the role-change endpoint or the bootstrap-admin config. Idempotent: an already-approvedrequest is a no-op; an email that already has a local user skips the invite and just closes the request. Invite failure → 502 (nothing provisioned). - New infra:
app/core/supabase_admin.py— a lazy service_role client mirroringrendering/storage.py(module client + lock,asyncio.to_threadaround the sync SDK), exposinginvite_user_by_email(...) -> UUID/SupabaseAdminError. And a sharedcore/auth.py::provision_workspace_and_user(session, *, user_id, email, role)(add+flush workspace then user, caller commits) — the JIT bootstrap-admin path was refactored onto it so the two provisioning sites can't drift. - Safety rails:
PATCH /admin/users/{id}blocks demoting the last remaining admin (409);DELETE /admin/users/{id}(hard revoke — deletes the local user + workspace, cascading their posts/accounts) blocks self-revoke and last-admin revoke (409). Revoke leaves the Supabase Auth identity intact by design — harmless, since JIT denies a token with no local row, and a re-invite reuses it. - Verification:
tests/api/test_admin.py(12) +tests/core/test_supabase_admin.py(2, invite mocked — no network); 416 backend tests pass (was 402). Live Supabase smoke: seed pending → list(pending) finds it → deny →deniedwith a realreviewed_byFK → list_users=3; cleaned up. Not yet live-verified: the real approve→invite email path (mocked in tests) — first real approve should hit a disposable inbox once the UI lands.
- New public intake:
POST /api/v1/access-requests— the single unauthenticated write in the whole API. A logged-out visitor (marketing landing, Chunk H) submitsemail+ optionalname/company/reason; the row landsstatus=pendingfor an admin to approve in/admin(Chunk F). Backed by the newaccess_requeststable (app/models/access_request.py, migration70117d5b9596) with areviewed_at/reviewed_byaudit pair (reviewed_byFK→users.idondelete=SET NULLso deleting an admin never orphans the query). Deliberately not tenant-scoped — these rows precede any workspace. - Opaque-by-design responses: every path returns
202 {ok:true}— stored, deduped, honeypot-dropped, or rate-limited all look identical, so the endpoint never leaks who has already requested access or how close a scraper is to the limit. - Spam guards (captcha deferred): (1) honeypot — a hidden
websitefield; any value ⇒ bot ⇒ silent drop; (2) per-IP fixed-window rate limit — 5/hour via a newapp/core/ratelimit.py::InMemoryRateLimiter(in-process, thread-locked dict; MVP runs single-process so this is sufficient — flagged to swap for a shared store if it ever scales horizontally); (3) email regex — a pragmatic shape check that avoids pulling in theemail-validatordependency (magic-link delivery proves the address anyway); (4) dedupe — a repeat email with an openpendingrow is a no-op.client_iphonors the firstX-Forwarded-Forhop. - Verification:
tests/api/test_access_requests.py(9 tests — validation, insert+lowercase, honeypot, dedupe, rate-limit cap, limiter per-key, ip helper); 402 backend tests pass (was 393). Live Supabase smoke: honeypot→0 rows, real submit→1pendingrow, dedupe→still 1; cleaned up.
- Publishing can no longer cross workspaces. All 6 per-platform
load_account(session, post)loaders now filterWHERE <Account>.workspace_id == post.workspace_id. The orchestrator already passedpostinto every loader (the# noqa: ARG001 — parityparam), so this was a one-line.where(...)per file; the noqa came off sincepostis now used. The publisher therefore resolves the account belonging to the post's owning workspace, never the global-latest — the fix for the "invited user publishes to someone else's LinkedIn" hazard flagged in the Chunk-B/C notes. - Accounts get
workspace_idat connect time. The Bluesky connect endpoint is authenticated (userin scope) so it setsworkspace_id=user.workspace_iddirectly. The other 5 are browser-redirect callbacks that only carryuser_idin the OAuth state, so each now doesdb_user = await session.get(User, user_uuid)after decoding state and setsworkspace_id=db_user.workspace_idon the new account row (redirecting?<platform>=error&message=unknown_userif the user is gone). This restores account-connect, which Chunk B's NOT-NULL column broke. status/disconnectremainuser_id-scoped — identical to workspace isolation while it's 1 user/workspace, and the security-relevant boundary is the publish-pathload_account(now workspace-scoped). Flagged for the team-workspace future: switch those two toworkspace_idso a second member sees shared connections.- Verification: 4 publisher
load_accountunit tests updated to pass a workspace-bearingpost(mock sessions ignore the filter, so they only exercise the code path); 393 backend tests pass. Live isolation smoke: inserted a Bluesky account in each of two workspaces, calledbluesky.load_accountwith a post in workspace A → returned A's account, never B's; cleaned up. The Phase-14 generate → connect → publish loop is now fully workspace-isolated and functional (the B→C→D interim break is closed).
- Every post-scoped API path now filters by the caller's workspace. New
get_current_workspacedependency (core/auth.py) returnsuser.workspace_id. Inposts.py, a single choke point_load_owned_post(session, post_id, workspace_id)doessession.get(Post)+ an ownership check and raises 404 — not 403 — when the id is missing OR belongs to another workspace, so a cross-tenant probe can't distinguish "not yours" from "doesn't exist". All id-based endpoints (get/update/approve/schedule/publish-now/retry/unapprove/unschedule/delete/upload-image) route through it; the child/list endpoints (render, render-carousel,/logs,/targets,/assets) do a pre-query ownership check so you can't read another workspace's assets/logs by guessing a post id.list_postsandpost_statsaddWHERE Post.workspace_id == ws. All 15 endpoints moved from_: User = Depends(get_current_user)toworkspace_id: UUID = Depends(get_current_workspace); the now-unusedget_current_user/Userimports were dropped fromposts.py. generate_draftnow requiresworkspace_id(keyword-only) and stamps it on the newPost— this restores the post-generation path that Chunk B's NOT-NULL column broke. The/generateendpoint passes the caller's workspace through. Verified live: aworkspace_id-scopedPostinsert→commit→delete against real Supabase succeeds.- Not yet scoped (Chunk D): the
auth_*.pyOAuth callbacks still create social-account rows withoutworkspace_id, so connecting an account still fails; and the publisher's_load_personal_accountstill loads the global-latest account ignoring workspace. Post generation works after C; account connect/publish waits for D. 393 backend tests pass (tests/api/test_post_scoping.pycovers the IDOR guard directly with a mock session; the full httpx+test-DB integration harness stays the deferred Technical-Debt item).
workspacesis now the tenant boundary. NewWorkspacemodel (id,name, timestamps).users.workspace_id(NOT NULL, FKondelete=RESTRICT) — one user per workspace today; the seam supports members later without re-migrating scoped tables.workspace_id(NOT NULL, FKondelete=CASCADE, indexed) added topostsand the 6 social-account tables (linkedin/x/bluesky/mastodon/threads/instagram_accounts). Downstream tables (post_targets,generated_assets,publishing_logs,analytics) scope through their parent post'sworkspace_id— no own column (they alreadyondelete=CASCADEoff posts).ai_generationsleft as-is (audit,SET NULLoff posts). SHARED / unchanged:trends,prompts,templates.- Data was WIPED, not backfilled (user-approved 2026-07-20). Posts have no owner column, so there was no correct workspace to assign 39 existing posts to; the only connected accounts belonged to a stray test user (
computer.basics.600). Rather than guess, migrationd9a1c4e7f012truncatesposts+ the 6 account tables (RESTART IDENTITY CASCADE, which also clears post children), then creates one fresh workspace per existing user (name = email local-part) so nobody is orphaned. Trends kept (417 rows). Result verified live: 3 workspaces (shivammaheshwari658 / computer.basics.600 → editors, nestwhirr → admin), posts=0, accounts=0. The migration is self-contained + reproducible (truncate is a no-op on a fresh DB); downgrade reverses the schema only (wiped rows are unrecoverable — noted in the file). - JIT bootstrap-admin path updated:
core/auth.pynow creates aWorkspace(flush → id) before theUserso a brand-new bootstrap admin lands in their own workspace.GET /mereturnsworkspace_id(frontend will use it in Chunk G). - Interim breakage is expected between B and C: writes now require
workspace_id(NOT NULL), butgenerator.generate_draftand theauth_*.pyOAuth callbacks don't set it yet, so post-generation + account-connect fail until Chunk C (query/create scoping) and Chunk D (publisher account scoping) wire it through. Do not deploy between B and C. 390 backend tests pass (unit tests use mock sessions, so the NOT-NULL DB constraint isn't exercised there — the scoping tests land in Chunk C against a real test DB).
- The tool is now invite-only at the code layer.
core/auth.py::get_current_userno longer JIT-provisions every valid token asadmin. A valid Supabase token whose email is not already inusersand not a configured bootstrap admin now raisesAccessDeniedError(403). Bootstrap admins (settings.BOOTSTRAP_ADMIN_EMAILS, comma-separated/case-insensitive, defaultnestwhirr@gmail.com) are the only self-provisioning path — JIT-created asadmin— and self-heal toadminon every login (so an accidental demotion can't lock the owner out). Existing non-bootstrap users keep their row + role (email is still synced); they are never auto-promoted. Newrequire_admindependency added for the /admin panel (Chunks F–G).User.rolemodel default flippedadmin → editor(safe-by-default; Python-side default only, no migration/no server_default). - Frontend login is
shouldCreateUser: false.login/page.tsxno longer creates an auth user from the sign-in screen; an un-invited email silently gets no magic link. The success copy is now honest ("if you have access, a link is on its way") + a "Request an invite" link to/.GET /api/v1/me(newapp/api/me.py) returns{id, email, role}for gating the admin UI (Chunk G);workspace_idjoins it in Chunk B. - Admin ownership (user-confirmed 2026-07-19): admin =
nestwhirr@gmail.com— a NEW email not yet in the DB; it JIT-provisions asadminon first magic-link login. The two pre-existing accounts found inusers(shivammaheshwari658@gmail.comcreated 2026-05-28,computer.basics.600@gmail.comcreated 2026-06-01 — bothadminpurely from the old hardcoded default) were demoted toeditorvia a one-off DB UPDATE. No functional change for them yet (no role gating exists until Chunk G); role is now correct for when it does. - Two manual Supabase-dashboard steps remain the operator's (documented in INSTRUCTIONS.md "Invite-only access"): (1) turn OFF "Allow new users to sign up" (the real server-side gate —
shouldCreateUser:falseis only the client half); (2) create thenestwhirr@gmail.comauth user (Add user), since signups being off means it can't self-create. Until Chunk F's admin panel, additional users are invited straight from the Supabase dashboard (provisionededitoron first login). - Tests:
tests/core/test_auth.py(7, mock-first per repo convention — patches_verify_supabase_jwt+ AsyncMock session): deny-unprovisioned, bootstrap-JIT-admin (case-insensitive), self-heal-if-demoted, no-auto-promote, email-sync, require_admin allow/block. 390 backend tests pass; frontend tsc clean. No new dependency, no migration.
2026-07-19 (Phase 14 planning — Invite-only access + per-workspace multi-tenancy; direction locked, not yet built)
- The tool is moving from "single-operator, all data global" to invite-only, multi-tenant. Today (verified 2026-07-19): Supabase magic-link signup is open (
signInWithOtp, no restriction) + Supabase's "allow new signups" is on, so any email can self-provision and get in;core/auth.py::get_current_userJIT-creates theusersrow on first JWT and hardcodesrole=adminfor everyone; theUserRoleenum (admin/editor/viewer) exists but is unused (no route checks it); frontend gate is purely "has a Supabase session" (AppShellrenders bare on/loginor no session). This entry records the user-confirmed direction (2026-07-19) so the research isn't lost — implementation is tracked in CHECKLIST Phase 14, nothing below is built yet. - User decisions (locked 2026-07-19, via clarifying Q&A):
- Access model = isolated per-user workspaces (NOT a shared team workspace). Real multi-tenancy — chosen over the lighter "everyone works one Nestwhirr page" model.
- Gate mechanism = hybrid. Disable Supabase public signup (identities can't self-create) + app-side
access_requeststable + activate theUserRoleenum; approving a request fires SupabaseinviteUserByEmail(auto-sends the invite email — no custom email infra). Chosen over a pure app-side allowlist (which leaves a "logged-in-but-denied" limbo state and is a weaker gate). - Landing page = fuller marketing page for logged-out visitors (hero / what-it-does / how-it-works / request-access form), not a minimal one-screen gate.
- Admin panel = dedicated
/adminroute (admin-only), not a Settings sub-tab. - Isolation boundary: SHARED (global) =
trends,prompts,templates(retrieval is a shared read pool — per-user RSS/HN fetching would be wasteful; voice prompts/templates stay shared defaults). PRIVATE (per workspace) =posts+ downstream (generated_assets,post_targets,publishing_logs) + the 6 social-account tables (linkedin/x/bluesky/mastodon/threads/instagram_accounts) + schedule + published history. - Isolation key =
workspace_idseam, 1 user per workspace for now. Scope data byworkspace_id(each new invited user gets their own workspace); chosen over rawuser_idon every table so adding teammates / a second brand later is a membership row, not another migration across every data table. Matches DESIGN.md's brand-switcher seam. Note: the 6 account tables already carryuser_idFKs; posts/trends do not.
- This intentionally lifts the CLAUDE.md "no multi-tenant logic beyond the existing
org_urncolumn" Forbidden-in-MVP rule — with explicit user approval (2026-07-19). CLAUDE.md's Forbidden list must be updated in the same change that lands the schema (Phase 14 Chunk I). - Key risks captured for the build: (a) IDOR / horizontal access is the main risk — every id-based fetch (
get_post, render, schedule, publish-now, upload-image, retry, logs, stats) must verify workspace ownership and 404 (not 403) on mismatch so existence isn't leaked; (b) the publisher's_load_personal_accountcurrently grabs the global latest personal account ignoring the user — it must load the account for the post's workspace or an invited user could publish to someone else's LinkedIn; (c) the JIT defaultrole=adminmust become non-admin (invited users defaulteditor; only the bootstrap usershivam@sacomt.comisadmin); (d) the publicPOST /access-requestsendpoint is the only unauthenticated write path — needs a basic spam guard (honeypot + IP rate-limit; captcha deferred); (e) web-verify the current Supabaseauth.admin.invite_user_by_emailAPI + the "disable signups" dashboard setting name before wiring (CLAUDE.md rule — supabase-py 2.13.0 already a dep). - Backfill plan: the schema migration creates one workspace, assigns the existing sole user (
shivam@) + all existing posts/accounts to it (zero data loss), setsshivamrole=admin. - Build order (dependency-correct, chunked in CHECKLIST): A lock-the-door (ships the gate immediately, manual Supabase-dashboard invites work in the interim) → B workspace schema+backfill → C query scoping → D publisher account scoping → E access-requests table+public endpoint → F admin API+invite → G
/adminfrontend → H marketing landing → I docs/housekeeping.
- Operators can now upload a custom image for a post; it publishes exactly as uploaded (raw passthrough, no render). Third visual path alongside HTML templates and AI/Flux. New endpoint
POST /posts/{id}/upload-image(multipartUploadFile): validates the content-type against_UPLOAD_CONTENT_TYPES(png/jpeg/webp/gif), enforces the 5 MB Supabase object cap, reads realwidth/height, uploads the bytes to the same public bucket under<post_id>/<asset_id>.<ext>, and inserts oneGeneratedAsset. From there it flows through publishing identically to a rendered asset —_select_publish_assetspicks the newest asset (created_at desc), so a fresh upload becomes the post's single image with zero publisher changes. User-confirmed 2026-07-13: single image only (no multi-upload carousel this pass), raw only (no brand-wrap option — that stays the AI-image frame feature). render_kindgains a third value'upload';generated_assets.template_idis now NULLABLE. A raw upload has no HTML template, so forcing atemplate_id(via a sentinel template row) would pollute the studio's template picker and misrepresent the row. Madetemplate_idnullable instead — the earlier "reject nullable template_id" decision (2026-07-01, AI frames) was specific to AI images, which genuinely do fit a template; its rationale (guaranteed 1200×1200, reuse the render/upload path) doesn't hold for arbitrary user uploads, which need their own path and accept any size. Migrationc7f1a4d90e23:ALTER TYPE renderkind ADD VALUE 'upload'inside anautocommit_block(Postgres can't use a same-transaction enum value), thentemplate_id DROP NOT NULL. Downgrade deletesuploadrows (they'd violate the restored NOT NULL) and re-tightens; the enum value is left (Postgres can't cleanly DROP one).is_carousel_slidein the orchestrator already handlestemplate_id=None(template_map.get(None)→ None → not a slide), so uploads are never mistaken for carousel slides.- Dimensions parsed from the file header, no Pillow. Publishers don't consume
width/height, but the columns are NOT NULL, so_image_dimensionsreads them straight from the PNG/GIF/WebP(VP8/VP8L/VP8X)/JPEG(SOF-marker scan) header — a ~40-line dependency-free parser rather than pulling in Pillow for two integers. Covered byTestImageDimensions(4 cases). Frontend: the cockpit Visual panel gets an "Upload custom image" button (hidden file input,apiUploadmultipart helper inlib/api.ts) next to the template/AI controls;templateNameById(null)renders "Uploaded image" and an "Uploaded" chip mirrors the "AI" chip. Bucket MIME allow-list note updated in INSTRUCTIONS.md (add webp/gif or uploads of those types 400). 383 backend tests, tsc clean.
- X is a full sixth platform, wired through the existing per-platform seams — no new pattern. New
Platform.xenum value (migrationd7e4a1f9c2b8, added inside anautocommit_blockso the follow-up seed can reference it),x_accountstable,XPublisher(registered → orchestrator picks it up with one_load_accountbranch),x_oauth.py,auth_x.pyroutes, and 8 seeded X rewrite prompts (migratione1f8b3c6a4d9, one per generatable category). char_limit=280, max_images=4. The X preview reuses the sharedMicroblogCard(X is Bluesky-shaped). 379 backend tests (+27 intest_x_publisher.py, +4 orchestrator skip tests); tsc clean. Migrations applied live to Supabase (enum + table + 8 prompt rows verified). - X is the only PAID platform (~$0.015/post, no free tier for new devs as of 2026). Gated behind
PLATFORM_X_ENABLED(default false) exactly like the Meta-gated Threads/Instagram — connect flow returns 503 and the frontend hides the picker/connect section until the operator provisions a paid X app. User-confirmed 2026-07-08 to build full API posting (not manual-assist). - OAuth 2.0 Authorization Code + PKCE, confidential client. UNLIKE every other platform: (1) PKCE is mandatory —
/startgenerates acode_verifier/S256code_challenge, carries the verifier inside the encryptedstate(same trick Mastodon uses for client creds), callback pulls it back for the exchange; (2) the token endpoint (POST https://api.x.com/2/oauth2/token) uses HTTP Basic auth (base64(client_id:secret)); (3) access tokens are short-lived (~2h) with a rotating refresh token viaoffline.access— each refresh returns a NEW access AND refresh token, sox_accountsstores both Fernet-encrypted and_ensure_fresh_tokenre-persists both (this refresh path is actually exercised, unlike LinkedIn's structurally-dead one). Web-verified against docs.x.com (2026-07). - Posting is upload-then-tweet; X does NOT fetch image URLs (unlike Threads/Instagram). For each asset: download bytes from the public Supabase URL → one-shot
POST /2/media/upload(multipartmedia+media_category=tweet_image, well under the 5 MB cap so no chunked INIT/APPEND/FINALIZE) → collectdata.idmedia ids →POST /2/tweetswith{text, media:{media_ids}}→ 201,data.id= tweet id stored asexternal_urn. Requires themedia.writescope for upload +tweet.writefor the post. Published-page link useshttps://x.com/i/web/status/{id}. - Char-cap overshoot fix — bounded shorten-retry in
run_platform_rewrite(never truncate). LLMs can't count characters, so on a dense source post (many concretes to preserve) short-platform rewrites (X ≤280, Bluesky ≤300) reliably over-shoot — measured 9/12 over 280 on a deliberately number-packed source.run_platform_rewritenow, when the first pass is over cap, RE-ASKS the model (up toMAX_SHORTEN_RETRIES=3, temp 0.3) to shorten — telling it the exact overage and aiming BELOW the cap (char_limit - max(15, char_limit//10), ~10% margin) so the model's own overshoot still lands inside — and keeps the shortest attempt. This is regeneration, not clipping (no mid-word cut), so the never-truncate philosophy holds; the retry dropped the worst-case to 3/12, and realistic-density sources were already ~0/6 over. Fires only on over-cap output, so normal rewrites pay nothing. A still-over result after retries is returned whole and fails the orchestrator gate loudly (unchanged). Root-cause follow-up (migrationa2c5e8f1b4d7): the retry alone still left ~3/12 over on a worst-case source, because X's 280 cap sits right in the 280–300 band where the model naturally lands a short-microblog rewrite — whereas Bluesky rarely trips its 300 cap since its prompt aims for "under 280" (20 chars of headroom). Gave X the same proportional buffer: the 8 seeded X prompts now aim for ~240 chars (250 ceiling) instead of "under 275", and instruct the model to keep the single strongest concrete + ≤1 hashtag. Result: 0/12 over on the same worst-case dense source (was 9/12), realistic sources land 176–267 and stay substantive. Never-truncate is unchanged (user-chosen); this just shifts the distribution so the gate almost never trips. Hashtag correction (migrationb3d6f9a2c5e8): the tighten's "0–1, prefer zero" rule was based on a stale assumption; web-verified 2026 data shows 1–2 relevant hashtags earn ~21% more engagement on X than zero (only 3+ get spam-penalized; hashtags are no longer used for topic classification since X ranks on semantic embeddings, but still help reach). Swapped the X prompt rule to "exactly 1–2 relevant lowercase hashtags, hard-ceiling 2." X posts now carry 1–2 tags and still land ~180–245 chars. Also fixed a latentKeyError:generator.PLATFORM_CHAR_LIMITSwas missingPlatform.x— every X generation would have crashed at the rewrite step (surfaced by user asking to test the content quality). - Manual-post mode:
PLATFORM_X_ENABLED=true+ no X account connected = generate-and-paste, not API-publish (user-confirmed 2026-07-08). New terminalTargetStatus.skipped(enum migrationf4a9c2e7b1d6): inpublish_target, a_load_accountPermanentPublishErrorfor a_is_manual_skip_platform(currently just X) → the leg is markedskipped(notfailed), so the fan-out stays green while the operator posts X by hand._maybe_aggregate_post_statustreatsskippedas terminal but excludes it from the published/failed decision (all-skipped drafts leaveposts.statusuntouched). Once a real X account is connected,_load_accountreturns it and X publishes normally — the skip only triggers on a missing account. Frontend: draft cockpit + Published renderskippedas a neutral "manual" chip. The generate-and-paste affordances live in the draft cockpit:PlatformPreviewhas a Copy text button (grabs the active platform's exact variant) and the Visual panel has a download link per image/carousel-slide (fetch→blob→save, since the plaindownloadattr is ignored on our cross-origin Supabase CDN URLs).
GROQ_MODEL_PRIMARY→qwen/qwen3.6-27b,GROQ_MODEL_FAST→meta-llama/llama-4-scout-17b-16e-instruct. Groq deprecatedllama-3.3-70b-versatile+llama-3.1-8b-instant(email 2026-07; decommission 2026-08-16). Chosen by A/B-testing candidates through the real primary stages (angle → voice rewrite + AI-tell strip → Stage-5 image prompt) on one live trend + the real DB prompt. qwen3.6-27b wrote the clearly sharpest founder-voice ("Your moat is not your feature set. It is your stance."), matching its "adheres to the prompt" reputation. llama-4-scout was a near-tie #2 and became the fast tier: same Meta lineage as the old8b-instant(lowest drift), clean JSON extraction, and the most generous free-tier limits (30K TPM / 500K TPD vs qwen's 8K / 200K).gpt-oss-120b(Groq's own recommended replacement) was rejected: despite topping intelligence benchmarks it hallucinated off-topic on thin context, injected non-breaking-hyphen unicode tells, and can't disable reasoning (only low/med/high → ~4× our token cap per post). Confirms benchmark ≠ founder-voice fit.- Reasoning plumbing (
groq_client._reasoning_effort_for). qwen3 + gpt-oss reason by default and reasoning tokens count againstmax_tokens; unconstrained they truncated the length-capped angle/voice stages and returned empty content on the JSON stages (json_validate_failed— observed live). The client now auto-attachesreasoning_effortby model prefix:qwen/→"none"(fully off, behaves like an instant model — exactly what our scripted prompts want),openai/gpt-oss*→"low", everything else → nothing (llama rejects the param).chat()gained an optionalreasoning_effortoverride. Verified end-to-end: fullrun_pipelinethrough the real client, all 5 stages clean, no AI-tells, valid Stage-4/5 JSON.
- AI-image renders are encoded as JPEG (q85); templated cards stay PNG. Full-frame photographic AI images rendered at 2× (
device_scale_factor=2→ 2400×2400) run ~4MB as lossless PNG and intermittently blew past the Supabase bucket's 5MB object cap →413 Payload too large(only on detail-heavy images, so it looked flaky). JPEG q85 is ~0.3–1MB at the same resolution and LinkedIn recompresses uploads anyway, so there's no visible loss. Text/graphic card templates keep PNG (lossless text matters, and they compress small). Plumbing:render_png/playwright_runner.html_to_pnggainedimage_format+quality;_render_ai_imagerendersimage_format="jpeg", writes a.jpgkey, and uploadscontent_type="image/jpeg".storage.upload_png→ renamedstorage.upload_image(path, data, *, content_type="image/png"). Bucket note: if the operator restricted the bucket's allowed MIME types toimage/png, they must addimage/jpeg(see INSTRUCTIONS.md) or AI uploads 400.
- How much brand chrome wraps an AI image is a per-render choice with three options. New
RenderRequest.frame: Literal["card","mark","none"] = "card"→ threaded throughrender_post(frame=...)→_render_ai_image(frame=...), which maps it viaAI_IMAGE_FRAME_TEMPLATES:card→ai_image_wrap.html(full black brand card, image inset 800×800 + strap — the default/original look),mark→ai_image_mark.html(Flux image full-bleed 1200×1200 + small corner brand mark over a legibility scrim),none→ai_image_bare.html(full-bleed, no chrome). Unknown values fall back tocard. Studio shows a 3-way segmented control (Brand card / Brand mark / Full bleed) only when "AI image (Flux)" is selected. (Started as awrap: bool; generalized toframethe same day when the brand-mark variant was added — don't reintroduce the boolean.) - Each frame is its own template, NOT a nullable
template_id/ raw-bytes passthrough. Considered makinggenerated_assets.template_idnullable and shipping raw Flux PNGs for the frameless cases. Rejected: (1) schema migration on a NOT-NULL FK invariant, (2) raw Flux output isn't guaranteed 1200×1200, (3) duplicates the upload/asset-row path. Routing every frame through a template reuses the exact Playwright→Supabase→asset pipeline, guarantees 1200×1200 (object-cover), and keepstemplate_ida valid FK. New frames are just an HTML file + a seed row (f2c9a7d41b60bare,a3d1e6f92c74mark) under the existingai_imagecategory — no enum change. An explicittemplate_idstill overrides theframepick. The studio's regular template dropdown filters out everything namedai_image_*.
- Stage 5 moved from the 8B FAST model to the 70B PRIMARY (
llama-3.3-70b-versatile). Writing a concise, evocative FLUX prompt is a small but genuinely creative task the 8B underperformed on (weak, generic visuals). It's one ~200-token call per post so the cost delta is negligible — same reasoning as the per-platform rewrite (2026-06-23). Supersedes the "Stage 5 runs on FAST" note in the Phase 10 entry below. - Stage 5 now writes a palette-neutral SUBJECT, not a fully-styled prompt. Web-verified FLUX-1-schnell prompting (July 2026): FLUX reads natural language (not keyword soup), weights the first tokens most, and wants ~30–60 words structured subject → environment → light-behavior → lens. The old 12–25 word "minimal dark, black background" prompt was too short and baked the brand palette into the subject. New system prompt leads with the subject, describes how light behaves, allows a camera reference, and explicitly does not force a color/background.
- Brand framing (dark/editorial) is now an OPTIONAL suffix, off by default. New
settings.AI_IMAGE_BRAND_STYLE(bool, default False). When on,_render_ai_imageappendsAI_IMAGE_BRAND_STYLE_SUFFIX(near-black bg, muted palette, low-key light, film grain) to the Stage-5 subject before callinggenerate_image— kept a suffix because FLUX treats trailing tokens as global style modifiers. Default off means generations pick whatever palette fits the subject (no forced black framing); the fallback prompt is likewise palette-neutral now. Note: the brand-wrap composite (black 1200×1200 wrapper) is unchanged and independent — this toggle only affects the inner Flux image.
- Fixed a real mobile dead-end. The whole
<aside>ishidden md:blockandMobileTabBaronly carries the 5 primary nav items, so on a phone Settings, Sign-out, the token-health "Reconnect LinkedIn" CTA, and the brand switcher were all unreachable (the only other route to Settings was the ⌘K CommandBar — no keyboard on mobile). Newcomponents/shell/MobileTopBar.tsx(md:hidden,fixed top-0 h-14): brand (BrandSwitcher) left; right cluster = token-health dot (TokenHealthPill collapsed— dot-only, null when healthy), a gear →/settings, and an avatar button opening a small email + Sign-out dropdown (outside-click close, same pattern as BrandSwitcher).AppShellrenders it and addspt-14 md:pt-0to the shell row so the fixed bar doesn't cover content (mirrors the existingpb-20for the bottom bar). tsc + eslint clean. DESIGN.md §6 mobile row updated.
- The left rail is now user-collapsible, not just breakpoint-driven. Previously
AppShellforced the rail state off a 1280px media query (useWideRail) with no user control — expanded on desktop, icon-only on tablet, no way to change it. Replaced withuseRail: a persisted collapse pin (localStoragekeyautopost:rail-collapsed,"1"/"0") whose default (when unset) still follows the 1280px line, but the user can pin either state at any width via a footer toggle button or the[keyboard shortcut (ignored while typing in input/textarea/contentEditable). LazyuseStateinitializer (no setState-in-effect — the shell only mounts client-side behind a resolved session, sowindowis safe). - Hover-peek overlay. When collapsed, the
<aside>reserves onlyw-16;onMouseEntersets apeekingflag that renders the rail panel as an absolute opaquew-60overlay (bg-surface-1+ shadow) so content never shifts under it. A singleexpanded = !collapsed || peekingflag drives all children (BrandSwitcher/NavRail/TokenHealthPilltakecollapsed={!expanded}) and the footer user row, so peek is fully labelled + interactive. Mobile bottom tab bar (MobileTabBar) unchanged — no collapse concept there. tsc + eslint clean. DESIGN.md §6 updated (breakpoints re-stated as 1280, collapse-control paragraph added).
- Scroll is now contained, not whole-page. The shell's content column became the single scroll container (
AppShell: outerh-screen overflow-hidden, content columnoverflow-y-auto). Board/list pages (drafts,published,trends,scheduled) restructured toflex h-full flex-colwith ashrink-0header/chrome region + amin-h-0 flex-1 overflow-y-autobody, so the page header/filters stay put while only the list/grid scrolls. Today/Settings/cockpit are short and just scroll in the column (the cockpit'sstickyActionBar still sticks to the column viewport). - "Load more" paging (user chose explicit buttons over infinite scroll — plays well with the Inbox's j/k nav, fits the restrained feel). Backend:
offsetquery param added toGET /postsandGET /trends(ge=0, default 0). Frontend pages fetch a page (LIST_PAGE/PAGE/TREND_PAGE= 30/30/50), append on "Load more", and inferhasMorefrompage.length === PAGE. Applied to Published (unbounded history) and Inbox (high-volume triage; Load-more lives as the last<li>so the keyboard list still owns it). - Drafts board is now an overview, not a dump. Each column shows a recent slice of ≤5 (four small
?status=…&limit=5fetches) with the true total from/posts/statsin the header and a "View all N →" when total > shown. View-all destinations: draft/approved have no dedicated page so they expand in place via?view=draft|approved(a full single-status list on the board with Load more); scheduled → Calendar, published → Published. The board page is now a router:DraftsRouterpicksBoardOverviewvsStatusListViewoff?view. Each column scrolls inside the bounded board body. - Calendar keeps its header/view-toggle/nav fixed; the grid + "Next up" rail scroll in the contained body. All green: tsc 0, 338 backend tests pass, only the project-wide data-fetch
set-state-in-effectlint remains.
- Counts now come from aggregate endpoints, not full row sets. The redesign had the NavRail (every shell mount) and Today pulling up to ~300 and ~500 full
PostRead/TrendReadrows (targets eager-loaded) purely to.lengththem for badges/funnel counts — and Today silently capped at "100+". AddedGET /posts/stats(oneGROUP BY status+ one filtered count →{draft, approved, scheduled, publishing, published, failed, published_7d}) andGET /trends/stats({unconsumed, total}). NavRail dropped from 3 list calls to 2 stat calls; Today dropped from 7 heavy calls to 5 light ones (2 stats + a 5-row trends peek + a boundedscheduledfetch only to find the next slot). Counts are now exact, not capped. GET /poststakes a multi-status filter (status=scheduled,publishing, comma list, backward-compatible with the single-value callers). The Calendar now asks for exactly the statuses it renders instead of fetchinglimit=200of everything and filtering client-side — which also fixes a latent bug: published history grows unbounded and sorts newest-first, so it would eventually crowd scheduled posts out of the 200-row budget. (Bothfuncimports added;statusquery param shadows the fastapistatusmodule insidelist_posts, so the 400 there uses the literal code.)- Pagination deliberately NOT added. The user asked about it, but at internal-MVP volume the working sets (drafts/calendar/inbox) are small and the real cost was counting-by-over-fetching, now fixed. Adding offset/cursor params with no UI that needs them is the speculative complexity CLAUDE.md forbids. The one place it will eventually matter is the Published page (unbounded history, currently
limit=100) and possibly the Inbox at high volume — flagged in CHECKLIST Technical Debt, to add a "load more" when the count actually warrants it. No test harness exists for HTTP endpoints (existing API tests are pure-helper/lifecycle unit tests), so the new aggregates are covered by import/route checks + the green existing suite, consistent with the repo's approach.
- Settings is now tabbed (
DESIGN.md §5G): Connections · Prompts · Templates · Voice, with the active tab in?tab=(Suspense-wrapped foruseSearchParams) so panels deep-link and back works. Connections shipped in Chunk D; this adds the other three. - New backend surface — the prompts API (
app/api/prompts.py, registered inapp/api/__init__.py):GET /promptsreturns the full versioned matrix;POST /prompts/{id}/activatemakes a version live by settingis_active=Falseon every sibling sharing its (name, platform) key, thenis_active=Trueon the target — so the generator's(name, platform, is_active)lookup always resolves to exactly one row. NULL platform (the legacy/any variant) is its own group, matched withIS NULL(Postgres treats NULLs as distinct). Read + activate only, no authoring — prompt bodies stay migration-seeded because anchor examples are how voice quality is held (CLAUDE.md); a free-form editor risks generic output. No schema change, no migration (theis_active/versioncolumns already exist). - Three panels:
Prompts.tsxgroups versions by (name, platform), highlights the live one, Activate on the rest (optimistic local flip + toast), collapsible body.Templates.tsxis a read-only grid over the existingGET /templates— there is no per-templateis_activebecause template selection is per-post at render time, not a global toggle, so "activate" doesn't apply here (deliberate divergence from §5G's "read/activate" wording).VoiceRules.tsxis a read-only reference renderingBANNED_PHRASES(imported fromlib/voice.ts— the same array that drives the cockpit's live inline flagging, so the Settings view and the editor can never disagree) plus the structural/formatting rules; editing them is a code change by design. - Phase 13 (UI/UX redesign) is now code-complete — all chunks A–I done; browser e2e verification remains user-driven. tsc clean; only the project-wide data-fetch
set-state-in-effectlint remains across the new files.
/publishednow renders the full fan-out, not just LinkedIn (DESIGN.md §5F). Each row readspost.targets[]and shows one chip per platform with a status-coded outcome: published = ✓ (or an outbound ↗ where a public URL is reconstructable), failed = red "· failed" withtarget.errorin the tooltip. A partially-successful fan-out shows the failed leg red next to the live ones — the honest multi-platform outcome the old single-linkedin_urnrow couldn't express.- Per-platform public links via
targetUrl(platform, external_urn)(frontend-only, derived from the stored urn): LinkedIn/feed/update/{urn}, Blueskyat://did/coll/rkey→bsky.app/profile/<did>/post/<rkey>, Mastodon's storeduri/urlif it's http. Threads + Instagram persist opaque media ids, so they show the outcome without a deep link rather than fabricate one. Legacy pre-fan-out posts (onlylinkedin_urn, noPostTargetrows) fall back to a single LinkedIn chip. - Reserved (empty) Performance column — a real
sm:grid-cols-[1fr_150px]column with a header and an em-dash cell (tooltip "deferred until Member Data Portability lands"). Honors the §5F / memory rule: analytics is deferred (r_member_socialclosed in 2026), so draw the slot, not fake charts; a metrics column drops into this cell when real data exists. Collapses to one column on mobile.nowsnapshotted once via lazyuseState. tsc clean; only the baseline data-fetchset-state-in-effectlint; no backend change.
/trendsis now a dense email-triage Inbox (DESIGN.md §5B), not a card list — trends are high-volume and disposable, so "skim, judge, act" wants a list. Left filter strip (Status + Source as button lists, source-coded dots), rows = source dot + title (link, dims when consumed) + HN score + relative age. Kept the optimistic consume toggle, single delete, and refresh.- Keyboard triage via a window
keydownlistener (ignores form-control focus + modifier keys):j/k/arrows move a cursor (active row tinted +scrollIntoView),etoggle consume,ggenerate-from-row,⏎open the source URL. The cursor is auseStateindex reset on filter change; mouse hover also moves it. - Per-row "Generate →" closes the loop into generation. It deep-links to
/drafts?trend=<id>&t=<title>rather than generating inline (generation still needs a category + platforms, which live on the board). The Drafts board is nowSuspense-wrapped (foruseSearchParams), reads the pinned trend, shows a dismissible "Generating from this trend" Banner, threadstrend_ids:[id]intoPOST /posts/generate, and on success routes straight to the new draft's cockpit (/drafts/<newId>). Non-pinned generation keeps the stay-on-board refresh. This collapses "read → go to drafts → pick category → re-find the trend" into one move while still letting the operator choose category/platforms. nowsnapshotted once viauseState(() => Date.now())for pure relative-age formatting. tsc clean; only the project-wide data-fetchset-state-in-effectlint remains; no backend change (the generate endpoint already acceptedtrend_ids).
- The flat
/scheduledlist is now a month/week calendar grid (DESIGN.md §5E) — scheduling is the APScheduler backbone but had no time view, and a list can't show cadence gaps ("nothing ships Thu–Sun") or clustering ("three stacked Monday morning"). Route stays/scheduled(NavRail already labels it "Calendar"). Month = 6×7 grid with off-month days dimmed; Week = 7 tall day columns. Today is ringed (accent); past days are dimmed and reject drops. - Drag-to-reschedule via native HTML5 DnD (no new dependency). Each post is a draggable block, left accent bar colored by its primary platform (
PLATFORM_DOTexported fromPlatformBadgefor this). Dropping a block on a new day reschedules preserving the original time-of-day (only the date moves) — optimistic local update, thenPOST /posts/{id}/schedulewith the new ISO (idempotentreplace_existingper target), revert + error banner on failure. Client guards slots earlier thannow + 60s(mirrors backendMIN_SCHEDULE_LEAD_SECONDS=60) with a warn toast rather than a round-trip 400. - Right rail "Next up" is the chronological scheduled list (preserves the old next-up-first sort), each row a deep-link to the cockpit. Empty-cell hover "+" deep-links to
/drafts(generate-into-slot). Fetches/posts?limit=200once, filters toscheduled+publishingclient-side. - Responsive: the grid is
hidden md:block; on mobile the "Next up" rail becomes the full-width agenda (per §6 — mobile collapses calendar to a list).nowsnapshotted once viauseState(() => Date.now())for pure past/future comparisons;anchor(the viewed period) is lazy-init state. tsc clean; only the project-wide data-fetchset-state-in-effectlint remains; no backend change.
- The flat status-filtered
/draftslist is now a four-column board (DESIGN.md §5D): Draft / Approved / Scheduled / Published, read left-to-right as the funnel. The status enum maps 1:1 to columns with two doubled-up:publishingrides the Scheduled column (it's in-flight from a schedule),failedrides the Published column rendered red with a Retry button (POST /posts/{id}/retry). All sixPostStatusvalues stay visible across exactly four columns — the honest representation of a status machine, not kanban-for-fashion. - Cards are presentational; bucketing does the work. One
useMemooverpostscomputes each card's voice-violation count via the clientscanVoiceTellsmirror (lib/voice.ts) and sorts newest-first within each column. Card = serif hook (line-clamp-3) +CategoryChip+ de-dupedPlatformBadgeper target platform +VoiceQualityChip. Click → cockpit. Draft/failed cards get a hover Delete (optimistic remove + toast). - Generate strip stays on the board. Today's "Generate a draft" CTA deep-links to
/drafts, so the category/platform/generate affordance lives at the top of the board (restyled to tokens, accent generate button, Meta-gated Threads/Instagram still hidden behindenabled). Fetches all posts (/posts?limit=200) once and buckets client-side — no more per-filter refetch. - Responsive:
grid-cols-1 sm:grid-cols-2 xl:grid-cols-4(mobile single column → tablet 2×2 → desktop four across). tsc clean; no backend change.
- Home is now the work, not the plumbing.
app/page.tsxwas a wall of 5 connection cards + 4 nav cards — you read the machine's setup before you could act. It's replaced by Today (DESIGN.md §5A): three bands — token-health (shown only amber/red, reusinglib/tokenExpiry), three deep-linked worklist counters (Needs review = draft+failed / Scheduled + next-up time / Published last-7d), and the pipeline funnel (Inbox → Draft → Approved → Scheduled → Live) with live counts, each cell a deep-link — plus a fresh-trends peek (top 5 unconsumed) and the primary "Generate a draft" CTA. Counts come from parallel/posts?status=…&limit=100+/trends?status=unconsumed&limit=50fetches, capped (100+/50+). - Connections relocated to Settings — pulled forward from Chunk I — because the OAuth flow forced it. Every platform callback redirects to
{FRONTEND_BASE_URL}/?<platform>=connected|error(verified inbackend/app/api/auth_*.py; left unchanged). So removing the connection UI from home would have stranded platform setup. The 5 sections moved verbatim (logic unchanged, chrome restyled to tokens + kitBanner) intocomponents/settings/Connections.tsx, rendered byapp/settings/page.tsxbehind a Connections·Prompts·Templates·Voice sub-nav (the latter three disabled "soon"). Today still surfaces the?<platform>=…callback result as a dismissible Banner since the browser lands on/after authorizing. Net: connect from Settings, land on Today with a confirmation — no backend redirect change. - Render purity. Today snapshots the clock once via a lazy
useState(() => Date.now())instead of callingDate.now()/barenew Date()during render, so the only lint residue is the project-wide data-fetchset-state-in-effectbaseline. tsc clean; no backend change.
- The cockpit is the proving slice;
/drafts/[id]is now three panes. Editor (left) · Platform Preview (center, the visual center of gravity) · Rail (right), with a sticky status-awareActionBar. Replaces the old stacked vertical sections where you scrolled between editing and previewing and lost the cause→effect link. Desktop grid[minmax(0,0.95fr) · minmax(0,1.35fr) · clamp(280px,24vw,360px)]; tablet (md) collapses to a two-pane Preview/Editor toggle; mobile stacks preview-first → editor → rail with the ActionBar pinned (mobile's job is approve-on-the-go). - The founder-voice check went live, client-side. New
frontend/lib/voice.tsmirrorsbackend/app/intelligence/voice.py :: strip_ai_tells— the same banned-phrase tuple, summary openers, and em-dash-separator rule — but detection only (the human rewrites, we never auto-strip in the UI). It returns per-occurrence spans (for highlighting) plus a deduplicatedcountmatching the backend'slen(violations)(em-dash once, summary once, each distinct phrase once) — that count feedsVoiceQualityChip. Keeplib/voice.tsandvoice.pyphrase lists in lockstep. - Inline underlines via a backdrop-overlay textarea (
components/cockpit/HighlightField.tsx). A transparent<textarea>rides on top of a styled backdrop div that renders the same text with wavy-red<mark>underlines on each tell. Because the visible text is always the backdrop, highlights can never drift out from under the caret. Auto-resize (content height) avoids scroll-sync. One component serves both editable (hook in serif, body in sans) and read-only (non-draft posts still show their tells). - Working copy lifted to the page; preview reads live edits. The cockpit owns
{hook, body, tags}working state (synced from the post on load and after every mutation viaapplyPost).PlatformPreviewrenders from the working copy, so a word change shows its per-platform char/shape consequence immediately — the whole reason the preview is centered, not the editor. Inline editing replaces the old explicit Edit-mode toggle; Save appears only when dirty (andApprovewarns on unsaved edits). PlatformPreviewrefactored onto the kit, not reinvented. Always-onPlatformTabstrip (even single-target) +CharBudgetMeterreplace its inline tab buttons and hand-rolled char counter; over-cap shows aBannerpointing at per-platform generation (Phase 12), as the code already intended. Active platform is derived at render (no reconciling effect) so a changed target set can't strand a dead tab. It's still the cockpit's only consumer, so the refactor is contained.- Actions → kit + toasts; lossy steps gated.
ActionBar/ActionButtoncompose the status-aware bar (Save/Approve/Schedule/Publish/Retry/Delete; primary = parchment accent on the highest-emphasis action for the current status).confirm()still gates publish-now and the lossy reverts; success fires auseToastnotification. All Phase-4 Chunk-G carousel-batch logic, the failed-panel/retry/attempt-history, and the render slot were preserved, moved into the rail. tsc clean; new components lint clean; no backend change.
- One domain vocabulary, one place:
frontend/lib/domain.ts.PostStatus/PostCategory/Platformtypes +CATEGORY_LABEL/GENERATABLE/PLATFORM_LABEL/PLATFORM_LIMITSwere copy-pasted into every list page. Now they live once; the kit and the pages import from here, so a label or a char cap changes in a single edit.PLATFORM_LIMITSstays the documented mirror of each backendPublisher.char_limit. - Status owns colour; categories go neutral.
StatusPillrenders the pipeline state with the semantic status tokens (literal per-status class strings — Tailwind v4 can't see dynamically-built class names) and a single status dot (publishing pulses).CategoryChipis deliberately a neutral hairline chip, not the old rainbowCATEGORY_COLORSmap — editorial restraint perDESIGN.md §3, with colour reserved for pipeline state so the board (Chunk E) reads by status, not by topic. Platform hues are the one exception (real brand identity):PlatformBadge/PlatformTabcarry a small platform-colored dot on otherwise-neutral chrome. - Consolidation executed on the three list pages;
drafts/[id]deferred by design.drafts,scheduled,publishednow import fromlib/domainand renderStatusPill/CategoryChip; their localCATEGORY_COLORS/STATUS_COLORSrainbow maps are deleted.drafts/[id]/page.tsxstill has its local maps on purpose — Chunk C rewrites it into the three-pane cockpit, so migrating it now would be immediately thrown away (the one place rainbow chips persist for one chunk). - Toasts are Banners; the kit shares one alert language.
Banneris the single inline-alert component (4 tones on the semantic tokens) that replaces the ~8 hand-rolled colored<div>s;ToastProvider/useToastrender transientBanners and are mounted once byAppShell.ActionBar/ActionButtonare presentational primitives (primary = the parchment accent fill = highest-emphasis action like Approve) that Chunk C composes into the status-aware draft bar. - ⌘K palette is global chrome.
CommandBar(mounted inAppShell) is keyboard-first nav + safe actions (generate routes to drafts; "refresh trends" hits the existing manual/trends/refreshendpoint and toasts the result).VoiceQualityChipships presentational this chunk; the live client-sidestrip_ai_tellsscanner that feeds it is Chunk C's job. tsc clean; no backend change.
- The studio inherits the artifact's identity, literally. New design system (
DESIGN.mdis the locked spec) replaces the generic dark card-wall.frontend/app/globals.cssis rewritten from the Next.js light/dark + Arial boilerplate into an intentional Tailwind-v4@themetoken set: near-black surfaces, hairline borders, a warm parchment accent (#e8e1d3, the card's brand-strap tone — deliberately NOT SaaS blue), and pipeline-status colors. The display serif is the system serif stack (ui-serif, Georgia, "Times New Roman") — the exact stack the rendered card templates use (their comment: "webfonts swap fast; the system serif stack is the typographic identity"), so the chrome wears the same typeface the output ships in, with zero new webfont requests. Geist sans/mono stay (already self-hosted vianext/font). - App shell wraps every authenticated route via the root layout, gated client-side — no route-group file moves.
components/shell/AppShell.tsxrenders the left rail + content column and is mounted once inapp/layout.tsxinsideAuthProvider. It renders bare (<>{children}</>) on/loginor when there's no session, so the rail never appears unauthenticated and protected pages keep their own redirect logic. The shell uses a<div>content column (not<main>) because each page already owns its<main>— avoids nested-<main>. Chosen over a Next route-group ((app)/) because client-side Supabase auth makes a pathname/session gate simpler and moves zero existing files; the route-group option stays open if multiple root layouts are ever needed. - Navigation is a worklist, not just links.
NavRail(Today/Inbox/Drafts/Calendar/Published/Settings) shows "needs me" badges — Inbox = unconsumed trends, Drafts =draft+failedposts — fetched once per shell mount via three parallelapiGets (silent-fail so a badge never blocks chrome). Inline line-art SVG glyphs, no emoji/icon-lib dependency. - The brand switcher ships now as a seam (multi-company soon, tenancy later).
BrandSwitcheris a static single "Nestwhirr" entry at the rail top so the mental model is "working inside a brand"; brand #2 becomes a dropdown row, not an IA redesign. Real tenant isolation (per-brand FKs, query scoping, membership) is explicitly deferred to Phase 14 — user-confirmed 2026-06-22 to do the UI first, hardcode one brand, and not pull multi-tenancy forward ahead of a confirmed second brand. (Contradicts the MVP "no multi-tenant logic" rule only at the seam level — no tenancy logic was added, just a brand-shaped UI slot.) - Token-health is global chrome.
tokenExpiry()moved frompage.tsxintolib/tokenExpiry.ts(shared source for the pill and the future Today screen);TokenHealthPillfetches/auth/linkedin/status, stays silent >10d, shows amber ≤10d / red ≤3d with a Reconnect CTA — so the day-60 LinkedIn token death (refresh tokens structurally unavailable) follows the operator everywhere, not just on home. - Responsive tiers via
matchMedia, SSR-safe by construction. Full rail ≥1280 → icon-only 768–1280 → bottom tab bar <768. The(min-width:1280px)hook would normally risk hydration mismatch, but the shell only renders for an authenticated session (resolved client-side,sessionnull on server), so the rail is never server-rendered — the hook is safe here.MobileTabBardrops Settings to keep five thumb targets. A/settingsstub page was added so the nav entry resolves before Chunk I builds the real surface. tsc clean. Existing pages (home card-wall, drafts, trends, etc.) are unchanged this chunk — they get reworked in Chunks D–I; for now they render inside the new shell (the home page's own header is briefly redundant with the rail until Chunk D replaces it).
- Bug: a scheduled post with ≥2 targets published every target on time, but
posts.statusstayedscheduledforever (andscheduled_atstayed set), so the dashboard showed it as never-published.publish-nowwas unaffected. Root cause: the scheduler runs one APScheduler one-shot per target, each in its OWN DB session.Post.targetsislazy='selectin', so each job eager-loaded all sibling targets when it fetched the Post — while they were stillpending/publishing._maybe_aggregate_post_statusthen re-queried the targets, but a plain SELECT returns identity-mapped rows WITHOUT refreshing, so each job read a stale sibling status, concluded "not all terminal," and skipped the roll-up. publish-now escaped it because it runs all targets in one shared session (siblings refresh naturally). - Fix (
orchestrator._maybe_aggregate_post_status): read committed state withexecution_options(populate_existing=True)on both the Post and PostTarget selects, andSELECT ... FOR UPDATEthe Post row to serialize concurrent per-target aggregators. With the lock, the last target to commit its own status necessarily sees every sibling terminal and performs the roll-up. No call-site changes; the aggregation still runs in each target's success/fail/char-gate path. - Data repair: one already-stuck post (
9bc4cd23, mastodon+bluesky both live since 13:27Z) was back-filled tostatus=published,published_at=earliest target stamp,scheduled_at=NULLvia a one-off idempotent UPDATE (all-terminal posts inscheduled/publishing). Requires a backend restart to load the orchestrator fix for future schedules.
- Stage-2 per-platform rewrites now run on
GROQ_MODEL_PRIMARY(70B), notGROQ_MODEL_FAST(8B). The 8B reliably overshot hard char caps — a live Bluesky rewrite came back 407 against the 300 cap, tripping the gate. We never truncate (project philosophy), so an over-cap variant is a dead post until regenerated; the only real fix is the model honoring the budget. Switched bothrun_platform_rewrite(the DB-matrix path) and the legacycompress_for_blueskysafety net to the primary 70B. A one-shot rewrite per target is cheap enough that dropping the fast tier here doesn't matter; correctness on the char budget does. This deviates from the earlier "Stage-2 = fast model" choice — recorded here as the rationale. - The over-cap preview banner no longer cites "Phase 12" as a pending fix. Phase 12 shipped, so the old wording ("the fix is per-platform generation (Phase 12), not truncation") read as if nothing had been done — confusing when per-platform generation had run and merely overshot.
PlatformPreviewnow branches oncommentary === null: no rewrite attached → "falling back to the LinkedIn body, regenerate"; rewrite attached but over budget → "regenerate or tighten the rewrite prompt — we never truncate."
- Bluesky
#tagsare promoted to richtext facets at publish time; the other platforms keep plain text. On atproto a#tagin the post text is inert grey text unless it's attached as anapp.bsky.richtext.facet#tagfacet (a byte-offset range). Mastodon and Instagram parse hashtags from the body/caption server-side, and LinkedIn auto-links them, so only Bluesky needs the work.bluesky._build_richtextreparses the generator's commentary throughclient_utils.TextBuilder(verified present in the pinned atproto 0.0.62;.tag()/build_facets()), emitting.text()runs +.tag(display, value)for each#tag. Byte offsets are the SDK's job (TextBuilder), which matters for multibyte text (tested withrésumé). No-tag commentary still sends the plain string (path unchanged); pure-numeric#1is left as text (invalid tag).PostTarget.raw_request.faceted_tagsrecords whether facets were attached. This is the concrete payoff of letting the per-platform prompts emit Bluesky hashtags — they now actually function. (+5 tests, 338 backend total.)
- LinkedIn prompt = Stage-1 writer; the other four = Stage-2 rewriters — asymmetric on purpose. A category's LinkedIn row is consumed by
run_pipelineas the angle/writer system prompt (input = trends). The Bluesky/Threads/Mastodon/Instagram rows are consumed byrun_platform_rewriteas rewrite system prompts (input = the finished canonical LinkedIn post). They are NOT the same kind of prompt, even though they share a(name, platform)key. This falls straight out of the Chunk-A architecture (canonical Post = LinkedIn; per-target commentary = rewrite) and is why the seed builds the LinkedIn body from_linkedin(...)and the rest from per-platform rewrite scaffolds. - New enum values added in an
autocommit_block. Postgres refuses to use an enum value added byALTER TYPE ... ADD VALUEin the same transaction. Alembic runs the whole upgrade in one transaction (env.py wrapsrun_migrationsin a singlebegin_transaction), so the fourpost_categoryadditions run insideop.get_context().autocommit_block()— committing them before the seedbulk_insertreferences them. Downgrade deletes the seeded rows but leaves the enum values (Postgres can't cleanly DROP an enum value). - Instagram is a rewrite, never a "compress." Its 2200-char cap is longer than a typical LinkedIn body, so the IG rewrite prompt explicitly says "this is NOT a compression," asks for a caption-shaped hook + 5–10 discovery hashtags. This is the concrete payoff of retiring the inline
compress_for_<platform>pattern from Chunk A. - Freshness gate cut 2 of 5 candidates, and a bot-block counts as a 404. rachelbythebay returns 404 to our retrieval User-Agent on both the feed and the site (she aggressively blocks non-browser clients); a feed our bot literally cannot read fails the gate regardless of how good the content is. a16z
/feed/404s and its angle duplicates Stratechery+Stripe. Kept the 3 that fetched clean and publish monthly+ in 2026 (lethain, jvns, nngroup). Feeds are not category-tagged — they widen the shared trend pool the operator draws from per category. - All 8 generatable categories are uniform on the matrix (migration
e8b4c1f7d320). Chunk B seeded the matrix for the 4 NEW categories; a follow-up (user-confirmed 2026-06-17) added the 16 missing Stage-2 rewrite prompts for the 4 original categories (founder_insight/engineering_lesson/ai_automation/startup_opinion × bluesky/threads/mastodon/instagram), reusing each one's v4 anchor voice (Jason Fried hiring, the caching-vs-indexing lesson, Simon Willison's AI numbers, the MVP take). The original categories' v4 LinkedIn rows stay untouched (Stage-1 writer); the new rows are version=1 Stage-2 rewriters. Net: 40 active prompt rows (8 categories × 5 platforms).compress_for_blueskyis now a pure safety net — no active category relies on it as the primary path. (technical_humor+carouselPostCategory values still have NO prompts and would raiseNo active promptif generated; they're intentionally absent from the UI'sGENERATABLElist, so they're latent, not reachable.)
- The canonical Post stays the LinkedIn artifact; per-platform variation lives on
PostTarget.commentary, not onPost.body. Stage 3 (extract), Stage 4 (visual fields), Stage 5 (AI image prompt) and the whole rendering layer are LinkedIn-shaped. Re-parametrizingrun_pipelineper platform would mean running those LinkedIn-specific stages for Bluesky/Threads (wasted, wrong shape). Insteadrun_pipelineproduces the canonical LinkedIn Post once (loadingplatform='linkedin', NULL fallback), and each non-LinkedIn target gets a DB-prompt-driven Stage-2 rewrite stored oncommentary. This preserves the Phase-11-iteration architecture (LinkedIn →commentary=None, others rewritten) with zero orchestrator/renderer change — the "signature change" the checklist names lands on_load_active_prompt's newplatformarg + the matrix lookup, not on a deeprun_pipelinerewrite. run_platform_rewrite(DB-driven) retires the inlinecompress_for_blueskystopgap per [[project-per-platform-generation-strategy]]. Each platform's voice/shape prompt is a row inprompts(seeded in Chunk B), so iterating a platform's tone is anis_activeflip, not a code deploy, and Instagram's longer 2200-char caption is modeled as a rewrite (not a "compress").compress_for_blueskysurvives only as the no-prompt fallback so the existing 4 categories keep publishing to Bluesky before their full matrix is seeded._load_platform_prompthas NO NULL fallback (unlike_load_active_prompt). Aplatform IS NULLprompt is the legacy LinkedIn-shaped voice; using it as the system prompt for a rewrite would just re-emit LinkedIn shape. So the per-target rewrite lookup is exact-platform-or-nothing; "nothing" routes to the graceful fallback (Bluesky compress / others None-and-fail-loud).- Over-cap rewrite output is never truncated. A seeded prompt that over-shoots its char budget is a prompt bug; the orchestrator's char-limit gate fails the target loudly with a clear error, which is the signal to fix the prompt. Silent truncation would ship mangled posts and hide the defect — same loud-failure philosophy as the Mastodon char-gate decision.
- One
PlatformPreviewdispatching to card variants, not five standalone previews. The component takes the draft'stargetsand renders a tab per target platform, dispatching the active tab to a card variant. LinkedIn reuses the existing richLinkedInPreviewverbatim (no rewrite — it's imported and wrapped); the three microblogs (Bluesky/Mastodon/Threads) collapse into oneMicroblogCardwith a platform-accented handle since their feed shape is near-identical; Instagram is the one structurally different surface (image-first) so it getsInstagramCard. This keeps the "five variants" the checklist asked for without five near-duplicate files. - The preview reads
target.commentary ?? canonical— exactly the orchestrator'sbuild_commentaryfallback. So what the operator sees per tab is what that target will actually publish (Bluesky shows its 300-char rewrite; the others show the LinkedIn-shaped body they currently fall back to). The canonical builder is approximate (noclean_bodyport to TS) but the char count is faithful enough to flag a cap problem. - Char-limit warning is the operator-facing half of the Phase-12 pressure.
PLATFORM_LIMITSduplicates each backendPublisher.char_limit(a deliberate, commented duplication — frontend can't import Python). Over-cap → amber banner that explicitly says the fix is per-platform generation, not truncation, reinforcing [[project-per-platform-generation-strategy]]. The Instagram card additionally flags the no-image case because that's a permanent publish failure, not a soft cap. - Live E2E stays user-driven; the code-level E2E stands in. Real multi-platform publishes need connected accounts and go live, so they can't run in CI.
test_fan_out_persists_distinct_urns_per_platformcovers the same risk at the unit layer: two targets (LinkedIn + Bluesky), per-platform publishers, asserting each persists its own distinctexternal_urnwith no cross-contamination. That's the dispatch + persistence guarantee the live test would confirm.
- Instagram Login flow, NOT Facebook Login. Web-verified (developers.facebook.com, two sources, 2026-06-16) that there are two ways to publish to IG: the legacy Facebook-Login path (requires a linked Facebook Page +
instagram_basic/instagram_content_publish) and the newer Instagram Login path (single fixed Instagram app, no Page link,instagram_business_basic/instagram_business_content_publish). We use Instagram Login per the checklist — fewer moving parts, no Page. Endpoints: authorizehttps://www.instagram.com/oauth/authorize, code exchangePOST https://api.instagram.com/oauth/access_token, long-livedGET https://graph.instagram.com/access_token?grant_type=ig_exchange_token, refreshGET .../refresh_access_token?grant_type=ig_refresh_token, profileGET .../{ver}/me. Structurally the same shape as Threads Chunk C, so the implementation is a near-clone with platform-specific deltas below. - Code exchange returns the IG-scoped
user_iddirectly — it's the publish path segment. Unlike Threads (where/meresolves the id), the IG short-lived token response carriesuser_idalongsideaccess_token. That id is what goes ingraph.instagram.com/{ver}/{user_id}/media. We persist it oninstagram_accounts.instagram_user_id. The response has shipped two body shapes historically (flat{access_token,user_id}and wrapped{data:[{…}]});_token_from_bodyhandles both so a Meta format flip doesn't break connect. instagram_accountshasexpires_atbut NOrefresh_token— identical in-place refresh story to Threads ([[#2026-06-16-phase-11-chunk-c-threads-publisher-meta-gated]]).ig_refresh_tokenreturns a fresh 60-day access token;_ensure_fresh_tokenrefreshes proactively inside a 2-day lead; the orchestrator'shasattr(account,"refresh_token")guard degrades a hard 401 to terminal-after-retries.- KEY DELTA: Instagram has no text-only feed post. Every container needs media, so
InstagramPublisher.publishraisesPermanentPublishErroron empty assets (Threads happily posts text-only). Operationally this means an IG target on a draft with no rendered card fails loudly — the operator must render a card first. Single image =image_url+caption(nomedia_type); carousel = N×is_carousel_item=trueimage containers (each polled FINISHED) → parentmedia_type=CAROUSEL+children. Caption cap 2200, carousel 2–10 items. - Container status field is
status_code, notstatus. The poll helper queries?fields=status_code,statusand matchesFINISHED/ERROR/EXPIREDonstatus_code(Threads usedstatus). Caption param iscaption(Threads usedtext). These two field renames are the only real divergence in the publish flow. Same bounded poll (15×2s) and same_classify(incl. OAuthException/code-190 body sniff → Auth). - IG fetches
image_urlitself +PLATFORM_INSTAGRAM_ENABLEDsingle-source-of-truth flag + zero orchestrator changes — all identical to Threads.Platform.instagramalready existed in the enum (scaffolded Chunk A); wiring was one register-import + one_load_accountbranch. Scopes live inINSTAGRAM_SCOPESenv so theinstagram_business_*vsinstagram_*naming question is a one-line change, not a code edit. - Tests mirror the Threads suite (
httpx.MockTransport, no network/DB/real Meta app). 30 new: OAuth helpers incl. the flat-vs-wrapped code-exchange bodies and the legacyid-vs-user_idprofile field, token staleness + proactive refresh, the requires-image gate, single-image (asserts absence ofmedia_type+captionparam), carousel children assembly, truncate-to-max, container ERRORstatus_code→permanent, the 7-case error matrix, account loader. 320 total (was 290).
- Single fixed Meta app, LinkedIn-shaped OAuth (not federated). Unlike Mastodon's per-instance dynamic
POST /api/v1/appsregistration, Threads is one central Meta app:client_id/client_secretlive inTHREADS_APP_ID/THREADS_APP_SECRET, thestateonly carries the user id + nonce + ts (no client creds to smuggle). Web-verified endpoints against developers.facebook.com (2026-06-16): authorizehttps://threads.net/oauth/authorize, code exchangePOST https://graph.threads.net/oauth/access_token, long-livedGET .../access_token?grant_type=th_exchange_token, refreshGET .../refresh_access_token?grant_type=th_refresh_token, profileGET .../{ver}/me. Two sources cross-checked per CLAUDE.md. - Short-lived → long-lived upgrade happens at callback time, before persisting. The code exchange returns a 1-hour short-lived token; we immediately call
exchange_long_livedto get the 60-day token and store only that. Storing the short-lived token would strand the account an hour later. Mirrors the "resolve everything at connect, persist the durable thing" pattern. threads_accountshasexpires_atbut NOrefresh_token. Threads refreshes the long-lived token in place —GET /refresh_access_tokenreturns a freshaccess_token, not a separate refresh token. SoThreadsPublisher._ensure_fresh_tokenrefreshes proactively when within a 2-day lead of expiry (any near-expiry token is ~60 days old, trivially past the endpoint's ≥24h-old requirement). The orchestrator's forced-refresh-on-401 branch guards onhasattr(account, "refresh_token"), so its absence means a hard 401 degrades to terminal-after-retries → operator reconnects. Same clean degradation as [[#2026-06-16-phase-11-chunk-b-mastodon-publisher]], for a different reason.- Two-step container API with async status polling. Create a media container (
POST .../threads), poll itsstatusfield untilFINISHED(media is processed asynchronously, ERROR/EXPIRED → permanent fail), then publish (POST .../threads_publishwithcreation_id). Carousels are N×is_carousel_item=trueIMAGE containers (each polled to FINISHED) assembled into a parentmedia_type=CAROUSELcontainer with comma-joinedchildren. Polling is bounded (15 attempts × 2s) — exhaustion → transient (retry the whole target). - Threads fetches
image_urlitself — no byte download. Unlike LinkedIn (upload bytes to DMS) and Mastodon (POST /api/v2/mediamultipart), Threads ingests a public URL. We passstorage.public_url(asset)straight through. The Supabase bucket has been public since Phase 4 Chunk E for exactly this class of reason. - Error classification sniffs the body for
OAuthException/code 190. Meta commonly returns 400 (not 401) with anOAuthExceptionbody for a dead/expired token. The HTTP-status-only taxonomy (401/403→Auth) would misfile that as permanent, so_classifyalso matchesoauthexception/"code":190in the body → Auth. Otherwise 429/5xx→Transient, other 4xx→Permanent. PLATFORM_THREADS_ENABLEDflag, backend is the single source of truth. Default false (Meta App Review gatesthreads_basic+threads_content_publish— precedent [[project_linkedin_org_posting]])./auth/threads/startreturns 503 when off;/statusreturnsenabled: false. The frontend reads thatenabledfield to hide BOTH the dashboard connect section AND the/generateplatform picker option — no duplicatedNEXT_PUBLIC_*flag to drift out of sync with the backend. The publisher still self-registers regardless of the flag (so a flipped flag needs no restart-time wiring change).- Orchestrator/scheduler/posts API needed zero changes.
Platform.threadsalready existed in the enum (scaffolded in Chunk A), and the publish/schedule/retry/unschedule/aggregate paths plus the char-limit gate are all platform-agnostic. The entire Threads wiring into the publish machinery was one register-import (from app.publishing import threads as _threads) + oneif platform == Platform.threadsbranch in_load_account. This is the abstraction from Chunk A paying off exactly as designed. - Tests mirror the Mastodon suite (
httpx.MockTransport, no network/DB/real Meta app). 28 new: OAuth helpers (state round-trip, authorize URL, exchange_code/long-lived/refresh/profile), token staleness + proactive refresh (skip-when-fresh, refresh-when-stale re-encrypts + flushes), publish paths (text, single-image, 3-item carousel asserting per-item + children assembly, truncate-to-max), container ERROR→permanent, the 7-case error-classification matrix (incl. OAuthException-in-400 → Auth), account loader. 290 total.
- Per-instance dynamic OAuth app registration. Mastodon is federated — no central app registry — so the OAuth client is registered at connect time via
POST {instance}/api/v1/apps(web-verified against docs.joinmastodon.org 2026-06-16)./auth/mastodon/start?instance=normalizes the instance, registers the app, and returns the authorize URL; the frontend redirects (same shape as LinkedIn's/start). First platform besides LinkedIn to need a redirect dance (Bluesky uses App Password). - Client creds ride in the encrypted OAuth
state, nomastodon_appstable. The callback needsclient_id/client_secretagain for the token exchange. Rather than persist a per-instance app table, they're carried inside the Fernet-encrypted, TTL-checkedstate(mastodon_oauth.encode_state/decode_state) and discarded after callback. Keeps the schema to one table (mastodon_accounts). mastodon_accountshas noexpires_at/refresh_token. Mastodon access tokens don't expire by default and the standard flow issues no refresh token. The orchestrator's auth-retry branch guards onhasattr(account, "refresh_token") and account.refresh_token, so a 401 just becomes terminal after retries (operator reconnects) — degrades cleanly with no special-casing. Migratione2a7c4b96d18, no backfill.- Raw httpx publisher, no SDK (
publishing/mastodon.py), consistent withlinkedin_client. Two-step media flow:POST /api/v2/media(multipart) per image → handle 200-sync vs 202-async (pollGET /api/v1/media/{id}untilurlnon-null, bounded byMEDIA_POLL_ATTEMPTS), thenPOST /api/v1/statuseswithmedia_ids[]+ anIdempotency-Keyheader so a transient retry can't double-post.char_limit=500,max_images=4, error classification 401/403→Auth, 429/5xx→Transient, other 4xx→Permanent. Orchestrator wiring is one import (triggers@register) + one_load_accountbranch — no other orchestrator change (char-limit gate, retry, aggregation already platform-agnostic). - STRICT char-limit, NO
compress_for_mastodon(per memory [[project-per-platform-generation-strategy]], user-confirmed 2026-06-16). Mastodon targets getcommentary=None→ orchestrator falls back to the canonical LinkedIn-shapedPost.body(~1100–1600 chars) → trips the 500-char gate as a loud, intentional failure. The publisher + connect flow ship code-complete; real Mastodon posts only pass once Phase 12's per-platform prompt matrix lands. Adding a per-platform compression here would homogenize the voice and be debt Phase 12 must unwind — Mastodon wants its own engineering-leaning voice, not a compressed LinkedIn. 262 backend tests passing (30 new intest_mastodon_publisher.py: normalize_instance, state round-trip, register_app/exchange_code/verify_credentials, media sync+async-poll, carousel truncation, error classification, account loader).
- Platforms move pre-generation, not pre-approval. Live operator hit the predicted char-limit failure: 1641-char LinkedIn draft → Bluesky 300-char gate fails the target. Original Chunk A design picked targets at approve — the dashboard correctly fail-loud'd but the UX was wrong because content shape depends on platform. Fixed by moving the picker to the
/draftsgenerate form./generatebody now acceptsplatforms: [Platform](default[linkedin]). Approve becomes a pure status flip; legacytargetsfield on the approve body is kept as a back-fill path so curl-against-old-shape doesn't 409. PostTarget.commentarycarries per-platform rewrites (nullable TEXT, migrationd4f6b8e1029c). NULL = canonical, orchestrator falls back toPost.hook + body + tags. For non-canonical platforms the generator writes the platform-shaped rewrite here. Bluesky-only path implemented in this iteration; Mastodon/Threads/Instagram stub through withNoneso they pick up the canonical body until their publishers land.- Stage 2.5:
compress_for_blueskyruns inline at draft-time. Pre-generation, after Stage 2 produces the LinkedIn-shaped voiced text, the generator runs a single FAST-model call against_BLUESKY_COMPRESS_SYSTEMto compress to ≤300 chars. Output is sanity-checked (strip wrapping quotes, must be ≤300, must not be empty) and falls back to a brand-safe one-liner (_BLUESKY_FALLBACK_COMMENTARY) when the model misses. Cheaper than rebuilding a whole Stage 1+2 per platform — the angle is already done. - Bluesky ALWAYS gets a compressed commentary, regardless of which platforms are selected. Live caught a bug 2026-06-16 where a Bluesky-only draft skipped compression: the canonical-platform resolver treated Bluesky as canonical (only option) and the per-target rewrite was skipped, but Stage 2 hardcodes the LinkedIn voice prompt — so Post.body landed as 1323-char LinkedIn-shaped text and the orchestrator's char-limit gate failed at publish. Fix: drop the canonical-skip optimization, always run
_build_platform_commentaryper target. LinkedIn targets keepcommentary=None(orchestrator falls back to Post.body which is LinkedIn-shaped — correct); Bluesky targets always get compressed text oncommentary._canonical_platformutility is kept for future use (per-platform Stage 2 in Phase 12 will resurrect the "which platform's voiced text becomes Post.body" question) but no longer called bygenerate_draft. - Approve body kept legacy-tolerant.
ApproveRequest.targetsis nowdefault=[](wasmin_length=1). New flow: targets are pre-attached by/generate, approve sees them, status-flips. Old flow (curl with{targets: [...]}against a draft without pre-targets): we backfill at approve. Avoids breaking the API surface for callers that haven't migrated yet. - Publish-now partial-failure now writes
post.error. Old behavior: 502 when ALL targets failed, otherwise 200 with emptypost.error. New: 502-on-all-failed unchanged; 200 withpost.error = "<platform>: <error> | ..."on partial. The dashboard's failed-banner now surfaces per-target errors via the post'stargets[]array AND falls back topost.errorfor legacy rows — fixes the "(no error message recorded)" empty banner the operator hit. - Dashboard error panel iterates
post.targets. Per-platform errors render as a list with the platform name in bold. Triggered when any target is failed (not just when posts.status='failed'), so partial-failures surface immediately even when other targets published successfully. Retry button stays — it re-attempts every failed target. - Why not run Stage 2 separately per platform? Considered: re-run angle→voice→extract fully per platform with a different system prompt each time. Rejected because the angle (Stage 1) is a stable take on the trend; rewriting it per platform burns Groq tokens and risks inconsistent positioning across platforms (LinkedIn says X, Bluesky says not-quite-X). One canonical voiced text + one compression pass per non-canonical platform is cheaper, more consistent, and easier to debug.
- Phase 12 Chunk A still relevant. The full per-platform
prompts.platformmatrix from Phase 12 supersedes this lightweight inline approach — when we add Mastodon/Threads/Instagram each will want its own voiced text (different audience, different shape), not just a compression of LinkedIn's. Until then the inline approach handles LinkedIn+Bluesky cleanly without a schema migration.
posts.statuscaps atscheduled; per-platform delivery moves topost_targets. Editorial lifecycle (draft → approved → scheduled) stays onposts. EachPostTargetrow carries its ownpending → publishing → published|failed.posts.statusaggregates back topublished(≥1 target succeeded, all settled) orfailed(all failed) when every target terminates. Aggregation runs insidepublish_targetafter each per-target outcome so the post status is always consistent with its rows.- Targets are picked at approve time, not at schedule/publish-now. Approve takes a
targets: [Platform]body — content decisions (a meme is Bluesky-only, an essay is LinkedIn-only) belong with editorial signoff. Schedule/publish-now/retry/unschedule all fan out across whatever was approved. Unapprove drops every target (re-approval is the supported path to change platforms). PublisherABC + registry, one impl per platform. Newpublishing/base.pydefines the abstractPublisher.publish(*, session, post, account, commentary, assets)plus four exception classes (PublishError,Permanent,Transient,Auth). Subclasses self-register via@register. The orchestrator (publishing/orchestrator.py) owns retry classification, status flips, char-limit gating, PublishingLog rows — none platform-specific. LinkedIn-onlypublisher.pydeleted.- Bluesky auth is App Password, not OAuth. atproto Python SDK is pre-1.0 and still session-based; TypeScript client has OAuth but the Python equivalent isn't there yet (web-verified 2026-06-15). App Password (Settings → App passwords on bsky.app) maps to the existing Fernet-encrypted-at-rest pattern. OAuth migration deferred — same "code is correct, dependency isn't ready" shape as [[project_linkedin_refresh_tokens]] but for a different reason. Connect flow is a
POST /auth/bluesky/connectform, no redirect dance. atproto==0.0.62pinned exactly, no caret ranges. Pre-1.0 SDK explicitly does not guarantee compatibility between versions. Per CLAUDE.md "pin exact, no caret." Upgrade gate is a Phase 12 task (or sooner if a Bluesky outage forces our hand).- No session caching for atproto.
Client.loginruns fresh per publish (in a thread viaasyncio.to_threadsince the SDK is sync). Trades ~few hundred ms for not having to manage refresh of session tokens that expire mid-job — same reasoning as why we don't cache LinkedIn image-init responses. - Bluesky error classification is heuristic string-matching. atproto raises a single
AtProtocolErrorfor everything. Until typed errors land,BlueskyPublisher.publishmatches on message substrings:invalid/password/credential→PermanentPublishError,rate/429/timeout→Transient,too long/exceeds→Permanent, default →Transient(safer to retry once than swallow a real outage as permanent). - Char-limit gate lives in the orchestrator, not the publisher impl. Each
Publisherexposes achar_limitclass constant; orchestrator builds commentary and fails the target fast with aPermanentPublishError("commentary {len} chars exceeds {platform} limit of {limit}") before callingpublish(). Bluesky 300, LinkedIn 3000. Phase 12's per-platform prompt matrix fixes this upstream at generation time — for now it's a loud fail with a clear error per target. - Carousel truncation to platform
max_imagesis implicit and silent on the publisher side (Bluesky chops to first 4, operator pre-warned at approve time via the platform cap). LinkedIn keeps its document-PDF path for ≥2 slides since AT Proto has no equivalent construct. post.targetsislazy='selectin', ordered bycreated_at. Eager-loaded so PostRead serialization (from_attributes=True) doesn't trip the async lazy-load guard. Sameselectinstrategy lands here for the first time in the codebase — no other relationship had it because no other relationship was serialized into a response model.- One APScheduler job per target, not per post.
publish_target_job(target_id)with stable idpublish-target:<uuid>. A draft scheduled for LinkedIn + Bluesky becomes two independent fires (different rate limits, different failure modes, different misfire grace windows possible later). Backend restart picks up the same jobs from SQLAlchemyJobStore —replace_existing=Truekeeps reschedule idempotent. - Legacy
posts.linkedin_urnstill stamped from the first LinkedIn target. Removal slated for Phase 12 cleanup migration. Keeps any pre-Phase-11 reads (analytics queries, dashboard cells) working with zero edits in this chunk. - Backfill on migration
c9e1f02db4a7: everypublished,failed,scheduledpost got onepost_targetsrow (platform=linkedin, status carried over, external_urn/published_at/error/scheduled_at preserved). 12 rows in the dev DB.draft/approvedposts get no rows — operator picks targets at next approve. Existing scheduled APScheduler jobs (publish:<post_id>) point at code that no longer exists — operator must unschedule + reschedule any in-flight scheduled posts (single-user MVP, no large-scale migration concern). publishing_logs.target_idis nullable. Existing rows (pre-Phase-11) carry onlypost_id. New rows always carry both for clean per-platform audit. Removing the column entirely would break existing log reads; nullable lets old + new coexist.tests/publishing/test_publisher.pydeleted, three new files in its place (orchestrator, linkedin, bluesky). Old tests covered LinkedIn-only orchestration of a function (publish_post) that no longer exists. New tests preserve every behavior (text-only / single-image / carousel-via-document / permanent-fail / transient-retry / transient-exhaust / auth-refresh) plus add Phase-11-specific cases (char-limit gate, aggregation skips while sibling pending, Bluesky carousel truncation, Bluesky error classification).
- AI image is additive, not a replacement.
render_post(kind: RenderKind = RenderKind.template)— every existing caller keeps the templated Playwright path unchanged; AI image is opt-in per render. Templated cards remain the on-brand default per [[project_visual_style]]. The whole point of Phase 10 was a second renderer backend behind the same seam, not a swap. generated_assets.render_kindis a native Postgres enum, server-defaulted to'template'. Backfills every pre-Phase-10 row to'template'viaserver_default— zero data migration, zero behavior change for existing assets. Chose native enum over varchar+check so the value set is enforced at the DB and the model imports a real PythonRenderKindenum (consistent withPostStatus,AccountKind,TemplateCategory).- Cloudflare Workers AI flux-1-schnell is primary; Pollinations is the soft fallback. Web-verified June 2026: Cloudflare endpoint
/accounts/{id}/ai/run/@cf/black-forest-labs/flux-1-schnellreturns{result.image: <base64>}; free tier 10k neurons/day ≈ 260 images. Pollinationsimage.pollinations.ai/prompt/{q}?model=fluxis no-auth but had documented Flux outages in 2026 (pollinations#8201) — that's exactly why it's the fallback, not primary. Composergenerate_image(prompt, primary?, fallback?)swallows any exception from primary (includingCloudflareConfigErrorfor missing creds) → logsai_image.primary_failedwith error class → calls fallback. Tests inject mock generators via the two kwargs, no monkeypatching needed. - Cloudflare config falls back on
None, not on falsy.CloudflareFluxGenerator(account_id="")must hit the missing-creds path, not silently pick up settings. Constructor usessettings.X if param is None else paraminstead ofparam or settings.Xso tests can force the config-error branch by passing"". - Stage 5 (AI-image prompt) lives in the same
Post.extractsJSONB, not a new column. Per the Phase 4 Chunk D precedent — JSONB absorbs new fields without a migration. Stage 5 runs inrun_pipeline()after Stage 4 on the FAST model (llama-3.1-8b-instant, temp 0.4, 200 max_tokens) — well under the $0.0002/post cost target. Persisted asextracts["ai_image_prompt"]. - Brand-safe fallback prompt for Stage 5 failures. When the model returns bad JSON or an empty string,
extract_ai_image_promptreturns a hard-coded string ("minimalist editorial illustration on a near-black background, soft contrast, one subject centered, no text"). The renderer never has to handle a missing prompt mid-render — the contract is "always a usable string." Bad JSON logs a warning so we can spot if the FAST model starts regressing on the JSON contract. - Lazy Stage-5 backfill on first AI-image render.
_ensure_ai_image_prompt(session, post)mirrors the existing Stage-4 lazy-extract pattern inrender_post: ifpost.extracts.ai_image_promptis missing (any draft generated before Phase 10), run Stage 5 inline againstpost.bodyand persist onto the JSONB. Subsequent AI renders skip the LLM call. Older drafts upgrade silently — no batch backfill needed. ai_image_wraptemplate is the onlyTemplateCategory.ai_imageentry, picked by name in the renderer. The category default-pick mapping (PostCategory → template name) intentionally never resolves toai_image_wrap— picking it requires either the operator selecting "AI image (Flux)" in the UI or callingrender_post(kind=ai_image)directly. The frontend dropdown filtersai_image_wrapout of the regular template list so it can't be picked twice (once as "Template" and once as "AI image"). Sentinel value__ai_image__in the<select>is what triggers the AI-image branch.- Brand-wrap composite (1200×1200 wrapper + 800×800 Flux inset) over raw Flux passthrough. Considered shipping the bare 800×800 Flux PNG directly to LinkedIn — much simpler. Rejected because the rest of the feed is the templated Nestwhirr identity (black bg, eyebrow, brand strap) and a bare AI image breaks visual consistency, looks like a different brand posted it. The wrapper costs one Playwright pass per render (~1s) and keeps every asset on-brand.
- API surfaces
render_kindonGeneratedAssetReadso the UI can label assets. Without this the operator can't distinguish a templated card from an AI image in the earlier-renders strip after a refresh — both are just 1200×1200 PNGs. - Tests: adapters mock httpx via
MockTransport(mirroringlinkedin_clientpattern), Stage 5 mocksgroq_client.chat, brand-wrap template tested as pure Jinja2 (no Playwright). No new pytest fixtures needed beyond the per-module httpx patch. End-to-end (Flux call → composite → Supabase upload → LinkedIn ship) verified manually 2026-06-13.
- Reversals are first-class endpoints, not query-string flags on existing ones.
POST /posts/{id}/unapproveandPOST /posts/{id}/unschedulemirror the forward verbs (/approve,/schedule). Tried: a singlePATCH /posts/{id}withstatus: "draft". Rejected because the lifecycle gate has different valid sources per transition — encoding that in a generic PATCH leaks state machine logic into the client and makes the audit grep ("when did we ever unschedule?") harder. One endpoint per transition keeps the state machine in the router and the logs clean. DELETE /posts/{id}is gated on{draft, failed}, not free for all. Approved/scheduled posts must be reversed first (unapprove or unschedule). Forces the operator through the explicit cancel-the-scheduler step instead of letting a DELETE silently abandon a queued APScheduler job that would still fire and 500 on a missing post. Publishing/published are deferred per the Phase 9 cut — published-delete is high-blast-radius (LinkedIn-side, not just DB) and low real demand.ai_generations.post_idkeepsON DELETE SET NULL, not CASCADE. A draft delete should not erase the LLM audit trail — those rows are cheap and useful for "why did this category produce a bad post" forensics across deleted drafts.generated_assetsandpublishing_logscorrectly CASCADE because they're tied to the post's lifecycle (no point keeping a rendered PNG for a deleted draft, and apublishing_logwith no post is just noise).cancel_publishlives inside the unschedule endpoint, not in a model hook. Considered: SQLAlchemybefore_updateonPost.statusthat drops the APScheduler job when transitioning out ofscheduled. Rejected because the scheduler is a process-level singleton and reaching into it from an ORM event handler creates non-obvious side effects on every test that touches a Post row. Explicit call in the router keeps the scheduler dependency exactly where the user-visible action lives.- Trend sweeper retention defaults to 60 days, configurable via
TRENDS_CONSUMED_RETENTION_DAYS. 60d covers analytics windows ("did the post from that consumed trend perform well?") without letting the table compound forever. Sweeper runs daily at 03:30 (TRENDS_CLEANUP_CRON), well clear of the 06:00 retrieval slot to avoid lock contention. Unconsumed rows are never swept — they're the working pool. - Bulk-delete caps at 500 IDs per request. Pydantic
Field(min_length=1, max_length=500)enforces it at the API boundary. 500 is well above any realistic UI multi-select (the/trendslist page caps at 100 visible) and well below what would cause a connection-pool stall on a single transaction. Single statement (DELETE FROM trends WHERE id IN (...)) — no per-row round trip. - Frontend: optimistic delete with snapshot rollback, not pessimistic spinner. Both
/trendsand/draftslist pages remove the row from state before the API call, then restore the snapshot on error. Network is fast on local + Supabase Session Pooler; a confirm-then-spinner pattern would feel sluggish for a destructive action that's already gated by aconfirm()prompt. The rollback onApiErroris the safety net. - Hover-trash is gated by status on
/drafts, not always visible. Only{draft, failed}rows show the ✕ icon — matches the backend gate exactly so the operator never gets a 409 from a button that shouldn't have appeared. Approved/scheduled rows funnel through the detail page where the unapprove/unschedule lifecycle is explicit. - Endpoint behavior covered by manual E2E, lifecycle gates covered by unit tests. Followed the existing repo convention (test_posts.py): unit tests assert the invariants (gate-allowed status sets) so future refactors that drift the lifecycle are loud, but the wet path (DB write, scheduler call, cascade) is verified end-to-end against the live dev DB and the live UI loop. The sweeper got an inline round-trip verification (insert 65d-old + fresh + 5d-old → run job → assert exactly the 65d-old was deleted) on 2026-06-11.
- Live publish-now verified. Draft → approve → render → click Publish now → ~14s later post is live on LinkedIn. URN
urn:li:share:7467618890898919424came back in thex-restli-idheader exactly as the Posts API docs specify. The full pipeline executed: Supabase Storage GET (140KB PNG) → LinkedIn/rest/images?action=initializeUpload(200) → DMS binary PUT (201) →/rest/posts(201) → DB commit withlinkedin_urn+published_at. SinglePublishingLogrow written withstatus=success. - Live scheduled fire verified. APScheduler job persisted via
SQLAlchemyJobStorefired at the exactrun_date; the publisher ran end-to-end under its own session (not the FastAPI request session) and the post transitionedscheduled → publishing → publishedautomatically with no operator action. Thepublish:<id>job id pattern means a backend restart between schedule and fire-time picks up the same job from the DB and continues. - Refresh-token path is structurally unreachable on a standard LinkedIn dev app. Smoke 3 forced
expires_at = now() + 1minvia SQL, then ran publish-now. The publish succeeded — but the refresh log lines (publisher.token.refreshing/publisher.token.refreshed) never appeared. Diagnosis:refresh_token IS NULLon thelinkedin_accountsrow. Theensure_fresh_tokenguard (if not stale OR not refresh_token: return decrypt(access)) short-circuits before checking staleness. The access token still worked because LinkedIn validates tokens against their own table, not ourexpires_atcolumn — the 1-min expiry was only in our DB, LinkedIn still saw a token with 59 days of life. - LinkedIn doesn't issue refresh tokens for
w_member_social-only apps. This is the real operational constraint, not a code bug. Refresh tokens are gated behind Marketing Developer Platform approval — same legal-entity + manual-review path as the Community Management API. Our app has standard "Sign In with LinkedIn" + "Share on LinkedIn" products, which grant 60-day access tokens but no refresh. The OAuth code IS storingrefresh_tokenif LinkedIn sends one (oauth.py:152); LinkedIn just never sends one for this app tier. - Operational reality at day ~60: the access token dies, posts start 401'ing, the publisher's auth-error branch marks them
failed. Recovery: operator clicks "Connect LinkedIn" on the dashboard (which already exists from Phase 1) → re-OAuth → fresh 60-day window. No data loss; failed posts can be retried via Publish now once the token is refreshed. The day-60 surprise is the worst part of the UX — added a Phase 8 task to surface a "token expires in N days" banner on the dashboard so the operator has lead time. - Two duplicate rows in
linkedin_accountsdiscovered. Bothaccount_kind='personal', distinctuser_ids (the unique constraint is(user_id, account_kind)so it didn't catch them). Likely from an earlier dev session that signed in via a second magic-link email and reconnected LinkedIn. The publisher'sLIMIT 1 ORDER BY created_at DESCpicks the most recent and works fine, but the dead row is data hygiene debt. Added to Phase 8 cleanup. - Org-page posting is not happening today and won't without LinkedIn-side approval. Verified live: the publisher correctly posts as
urn:li:person:<member_sub>becauseaccount_kind='personal'. Pivoting toaccount_kind='organization'requires (a) Community Management API approval (legal entity, business email, screencast review, multi-week wait), (b) re-OAuth withw_organization_socialscope, (c) the authenticated user holding an ADMIN role on the target Company Page. The code path is already in place (linkedin_client.py author_urn()) — the only blocker is LinkedIn-side approval.
publish_post_jobowns its own DB session. APScheduler runs jobs outside the FastAPI lifespan, soDepends(get_session)doesn't apply. The job opensAsyncSessionLocal()as a context manager and threads it throughpublish_post. The session is short-lived (one publish cycle, ~5-20s), and SQLAlchemy's connection pool handles checkout/return.- Job id format is
publish:<post_id>, not a random opaque string. Stable per-post soreplace_existing=Trueon reschedule swaps the trigger atomically, andremove_job(jid)on publish-now can pre-empt the queued one-shot without scanning. APScheduler'sSQLAlchemyJobStorepersists by id, so a backend restart picks up the same job; if the operator reschedules in between, they edit the row, not append a duplicate. misfire_grace_time = 3600on the publish trigger. If the scheduler is offline at fire time (deploy, crash, planned restart), APScheduler treats the job as "misfired" and skips it once the scheduler restarts — unless a grace window says otherwise. One hour means: scheduled at 3pm, backend down 2:55–3:10pm, comes up at 3:10 → still publishes (3:10 ≤ 3:00 + 1h). Larger window than the retrieval cron (10 min) because publishing is a discrete user-intent action where late-by-an-hour is better than silently skipping; retrieval is recurring and dropping one cycle is fine.- Late-import of
publish_postinsidepublish_post_job.app/scheduler/jobs.pyis imported at app startup (viaregister_recurring_jobs), andpublish_postdrags inhttpx,supabase-py, theLinkedInAccount/PublishingLogmodels — none of which the scheduler needs to register its retrieval cron. Late import keeps the startup chain narrow and any future test that wants to inspect the scheduler doesn't load the LinkedIn surface as a side effect. publish-nowcancels any pending APScheduler job first. If the operator scheduled for 10pm and clicks Publish now at 9:55pm, both paths would otherwise fire (publish-now wins inline, then the APScheduler one-shot tries 5 minutes later on an already-published post andpublish_postcorrectly rejects on status — but a noisy PublishingLog row gets written and the operator sees an unexplained failure log). Cancelling the queued job avoids the duplicate-fire entirely. If cancel finds nothing (no schedule was set), that's fine —cancel_publishreturns False without raising.- Publish-now raises 502 on
status=failed, not 200 with an error field. REST semantics: the upstream LinkedIn call failed, so 5xx is correct. The body carriespost.error(which includes the LinkedIn HTTP status code from Chunk B) so the dashboard's red error panel shows something diagnostic. The Post row is still updated tostatus=failed+error=...in the DB regardless — the operator sees the same final state by refetching the post. Choosing 200-with-error would force every frontend consumer to remember to inspectstatus/errorinstead of relying on standard HTTP error handling; 502 plus the error body is cleaner. - Emerald button color for "Publish now," distinct from the indigo Schedule and blue Approve. Three CTAs on the same action bar, all advancing the lifecycle, each needs to be visually distinct so the operator doesn't fat-finger. Color mapping: blue = approve (the "OK this is good" cue, same as the publish flow's first commit), indigo = schedule (the "queue for later" cue, neutral hue), emerald = publish now (the "this goes live now" cue, green for go/positive but bright enough that it never blends into the page background). Plus a
confirm()gate on the click — irreversible action, friction is appropriate. - No new tests in Chunk C. The endpoints are thin glue (status guard + commit + scheduler call) and the underlying surfaces —
publish_post(Chunk B, 24 tests),schedule_publish/cancel_publish(one-line wrappers around APScheduler's own well-tested API), the lifecycle gate (already tested via the approve + schedule endpoints in Phase 5) — are all covered. The fire-time path itself (job → session → publisher) only exercises code already under test; the real verification is the Chunk D end-to-end smoke against live LinkedIn.
- Three-tier error classification is the whole publisher. Most of the publisher's logic is one decision: "what kind of LinkedIn error is this and what do I do about it?"
_classify(LinkedInAPIError)returnsauth | transient | permanent. auth (401/403): the access token was rejected — force a refresh (setexpires_at = nowsoensure_fresh_tokenre-fetches) and try once more. If still auth, treat as permanent — refresh-token is bad too, operator needs to reconnect. transient (429, 5xx): LinkedIn's problem — exponential backoff (2s, 4s) up toMAX_ATTEMPTS=3. permanent (other 4xx like 400/422): we sent something LinkedIn rejected — don't retry, mark failed, surface to operator. This three-bucket model covers every realistic failure without ad-hoc per-status handling. - Status flip to
publishingcommits before any HTTP work. The lifecycle gate (status in {approved, scheduled}) is checked, then the row commits withstatus=publishingbefore the first network call. If APScheduler crashes mid-publish or two workers somehow race, the lock is visible in the DB. A future "stale publishing" sweeper job can pick up any row stuck inpublishingpast N minutes — that's a Phase 8 hardening task, not MVP. publishing_logsis append-only, one row per attempt regardless of outcome. Success rows carrylinkedin_response={"urn": "..."}; failure rows carry the truncated LinkedIn body (4 KB cap) + the error message (500-char cap). That's enough to diagnose what LinkedIn rejected without bloating the table with full response payloads. The eventual analytics layer (Phase 7) reads this table for retry-rate dashboards.- Token freshness is checked per-attempt, not once up front. A refresh between attempts costs nothing extra but covers the edge case where attempt 1 fails with 401, the publisher forces
expires_at = now, and attempt 2 must see the staleness before its own create_post call. Puttingensure_fresh_tokenat the top of every attempt loop closes that race without special-casing the auth-retry path. - Carousel batch selection mirrors the frontend's 60s-window detection. The frontend's Phase 4 Chunk G
LinkedInPreviewdecides which slides are "the current batch" using a 60-second window between consecutivecarousel_slideassets. The publisher uses the same window so what the operator approved is what ships. The alternative (abatch_idcolumn onGeneratedAsset) is cleaner but premature — the timestamp heuristic is exact in practice because batches commit in a single transaction with sub-second timestamps. Addbatch_idonly when there's a real race to disambiguate. - Image download via httpx, not supabase-py. The bucket is public (decided in Phase 4 Chunk E for exactly this reason — LinkedIn's ingest can fetch). Internal downloads use the same public URL via plain httpx — one HTTP GET, no SDK round-trip, no service-role-key in flight. supabase-py download would add a sync client + thread hop for zero benefit.
- Single personal LinkedIn account lookup (MVP).
_load_personal_account(session)selects the latestaccount_kind=personalrow.Posthas nouser_idcolumn today; in the single-user MVP that's fine. Phase 8 (or whenever multi-tenant lands) will addPost.user_idand switch this lookup toPost.user.linkedin_accounts. The column doesn't exist yet so adding it now would be premature — multi-tenant is far off and the upgrade is one Alembic migration + one query change. raw_requestpersisted on the Post on success. The exact payload that we POSTed to/rest/postsis stored inpost.raw_request(JSONB). Cheap insurance for replay debugging — "what did we actually send" is often the question after a weird LinkedIn rendering result, and storing it solves the answer.PermanentPublishErrorraised only for pre-flight failures (missing post, wrong status, no connected account). LinkedIn HTTP failures inside the attempt loop never escape as exceptions — they flow into the status-flip path and the publisher returns aPostrow withstatus=failedanderror=.... Caller (the scheduler job orpublish-nowendpoint) can branch onpost.statuswithout try/except gymnastics for the normal failure paths, only for the unrecoverable pre-flight cases.
- Pure HTTP module, no DB.
app/publishing/linkedin_client.pyis the seam where every byte that touches LinkedIn passes through. It knows nothing aboutPost,GeneratedAsset, orLinkedInAccount. The publisher (Chunk B) will compose this with DB orchestration. Rationale: every LinkedIn call needs deterministic test coverage (we can't hit production LinkedIn from CI), and a pure-HTTP module is mockable viahttpx.MockTransportwithout any DB fixtures. Same split that worked forgroq_clientandarticlein Phase 3. - One client surface, three endpoint shapes. Image init (
POST /rest/images?action=initializeUpload), binary upload (PUT {uploadUrl}withapplication/octet-stream), and post create (POST /rest/postsreturning URN in thex-restli-idresponse header). The Posts API replaces the deprecated/v2/ugcPostsand accepts three content shapes: text-only (nocontentkey), single image (content.media.id), and MultiImage (content.multiImage.images[], 2-20 entries). Three matching payload builders, all sharing one_post_envelopeso the published shape stays consistent across post types. - Web-verified specs before writing code, per CLAUDE.md rule. Microsoft Learn
li-lms-2026-05is the canonical source (LinkedIn's docs site is just an alias). Verified: required headers (Authorization,X-Restli-Protocol-Version: 2.0.0,LinkedIn-Version: YYYYMM,Content-Type: application/json), success codes (201for create_post,200for initialize,201for binary PUT), URN response location (x-restli-idheader, not body), and MultiImage organic support (sponsored carousels are a differentcontent.carouselshape requiring a separate API — not supported in MVP). - Carousel = MultiImage, not Carousel. LinkedIn's docs use "Carousel" for paid/sponsored ads only — that's the
content.carouselpayload shape withcards[].landingPage. Organic multi-image posts (what we want for our slide decks) usecontent.multiImage.images[], max 20, no landing pages. OurPostCategory.carouselresolves to this. Renamed in the function (build_multi_image_post_payload) to match the LinkedIn doc terminology so future-you doesn't grep "carousel" and pull in ad code. x-restli-idis the only place the URN appears. A 201 body from/rest/postsis empty; the URN comes back as thex-restli-idresponse header (case-insensitive — httpx normalizes). Surface the shapeurn:li:share:NNN(orurn:li:ugcPost:NNNfor legacy responses) directly to the caller; the existing frontendlinkedinUrlFromUrn()helper (Phase 5 Chunk C) already knows how to render either form into the public activity URL.- Little-text escape is defensive, not load-bearing today. LinkedIn's
commentaryfield uses a "little text format" where\<>#~_*[](){}@|are reserved for things like@[Mention](urn:...)and{hashtag|#|x}templates. Plain#hashtagworks as-is (LinkedIn parses + auto-converts on read). Our generator never emits mentions or those special chars today, but the escape is cheap insurance — if voice tuning ever produces(parenthetical)or@person, the publish doesn't break.escape_commentary()is exposed but the publisher will pick whether to call it; for now I'd default to ON in Chunk B. LinkedInAPIError(status, body)carries the HTTP envelope. The publisher in Chunk B will categorize:401/403→ token issue (refresh + retry once),429→ rate limit (back off),5xx→ transient (retry with exponential backoff up to 3),4xx other→ permanent (markfailed, surface to operator). The HTTP-layer client doesn't make those decisions itself — it raises with the envelope and the orchestrator decides. Same pattern as the Groq client's tenacity retries.- Token refresh seam is here, not in Chunk B.
refresh_access_token()is a one-line re-export ofoauth.refresh_tokenand_is_token_stale(expires_at)checks against a 5-min refresh lead. Chunk B adds the DB-sideensure_fresh_token(session, account)that wraps these two with decrypt + persist. Keeping the staleness check at the HTTP layer means the test (a pure datetime comparison) doesn't need a DB fixture. - 30s HTTP timeout, not 10s. Image init + binary upload to LinkedIn's
dms-uploadsendpoint occasionally takes 5-10s for a 200KB PNG (Supabase Storage is fast, LinkedIn's ingest is slower). 30s gives slack for cold buckets without letting a truly-hung request block a scheduler tick forever. Connect timeout is 10s — that's network setup, which should never legitimately need more.
- One endpoint for both initial schedule and reschedule.
POST /posts/{id}/scheduleacceptsstatusin{approved, scheduled}, setsscheduled_at, flips toscheduled(no-op on second call). Considered a separatePATCH /posts/{id}/scheduled-at, but the action is conceptually the same: "publish at this time." Splitting would force the frontend to switch endpoints based on current state for no real difference in behavior. The endpoint name "schedule" reads accurately for both — you're (re-)scheduling. - No
unscheduleendpoint in Chunk C. Revertingscheduled → approvedis a real future need (operator changes their mind, wants to edit again), but adding it now means designing the UX too. The current Reschedule button covers "change the time"; the "abandon entirely" path is rare enough that DB poking is fine until someone asks. Deferred. MIN_SCHEDULE_LEAD_SECONDS = 60sserver-side, 70s client-side. Server gate prevents a "schedule for right now" race against the APScheduler tick that Phase 6 will wire — if the worker is mid-poll when the row commits, it could miss it. 60s is enough lead time that the next poll cycle picks it up. Client mirrors at 70s so the user sees the "pick at least ~1 min in the future" error before clicking (no server round-trip), with a 10s skew buffer to absorb client/server clock drift.- Naive datetime → UTC at the API boundary.
<input type="datetime-local">produces wall-clock time with no offset;new Date(localValue).toISOString()converts it to UTC correctly using the user's TZ. Backend's Pydantic parse leaves it tz-aware (or tags naive as UTC). This is the standard "user picks local time, server stores UTC, client renders local" pattern. The user's TZ label appears in the picker header so there's no ambiguity about what "3:00 PM" means. - Default value is
now + 15 minrounded up to the next 5-min mark. Picked 15 minutes because it's "soonish but not racing the server gate" — gives the user time to second-guess and reschedule without rushing the picker. The 5-min rounding makes the time look intentional (you'd write "3:00 PM", not "2:53 PM") and lets the operator skim a series of scheduled times without having to scan minutes-precision noise. Both knobs are local — no env var. - Lists sort client-side, not via the API.
GET /posts?status=Xalways returnscreated_at DESCper the existing contract. For/scheduledwe want next-up-first (scheduled_at ASC), for/publishedmost-recent-first (published_at DESC). Adding asort=query param would broaden the API surface for two callers; sorting after fetch is simpler and the result set is capped at 100 anyway. If we ever hit 1K+ rows, the right fix is server-side keyset pagination, not client sort. linkedinUrlFromUrn()returns null for unrecognized URN shapes. LinkedIn's URN forms (urn:li:share:NNN,urn:li:ugcPost:NNN) all redirect to the canonicalfeed/update/urn:li:activity:NNN/URL when you swap the last segment. The helper extracts the numeric ID and builds the activity URL; if the URN is missing, malformed, or the suffix isn't all-digits, returns null and the row shows "No LinkedIn link" instead of a broken link. Phase 6 will populate this column for the first time; this is the consumer-side rendering ready and waiting.- Reschedule is the same Save flow as initial Schedule — the picker pre-populates with the current
scheduled_atwhen re-opened, and the button label flips ("Schedule" → "Update schedule") so the user knows the form is in edit mode. No mode switch in state —initialScheduledAtprop drives both the default value AND the button label. - Dashboard nav has separate cards for Drafts / Scheduled / Published, not one combined "Posts" card. Three distinct mental modes: write (drafts) / queue (scheduled) / archive (published). Each card has a one-line description tuned for "what's the state of work I have at this stage." Could collapse later if the dashboard feels heavy; today four cards (LinkedIn, Trends, Drafts, Scheduled, Published) is fine on a single screen.
- Approve is a one-line status transition, not a heavier "review packet" flow. The endpoint loads, gates on
status='draft', flips toapproved, commits. No reviewer comment, no checklist enforcement, no "approved by user X" audit beyond the existingupdated_at. Why: the operator is the user; there's no team approval workflow to design for at MVP. Adding columns or audit rows now would be premature. If team approvals become a real need, the schema gets anapprovalstable — additive, no rewrite. - LinkedIn preview is fidelity-of-content, not fidelity-of-chrome. The signal the operator needs before approving is "does my body text render with the right line breaks, do my hashtags look right, does the image hang where it should." That's the 90% — not pixel-perfect avatars, not the live engagement count animations. Trying to match LinkedIn's chrome exactly is an infinite chase (they ship visual tweaks weekly) and the divergence is more confusing than helpful. The component does mimic the basic feel — white card, dark text, blue hashtag links (
#0a66c2), avatar + name + headline header — but stops short of pretending to be the real thing. - Author name pulled from the Supabase session email's local part, not from a stored LinkedIn profile. Reason: we don't store LinkedIn display name / headline / avatar yet —
linkedin_accountskeeps onlymember_sub(the OIDC subject ID) and the encrypted tokens. Pulling display data is a Phase 6 task (the publisher needs the author URN, and we'll incidentally have a chance to cache name + headline + picture URL at OAuth callback time). For now,session.user.email.split('@')[0]is a decent stand-in (shows "shivam" for shivam@sacomt.com); the placeholder doesn't affect the actual publish payload. - Carousel preview shows slide 1 + a "1 / N" badge, not all N stacked. The detail page already has the dedicated Carousel slides strip (Chunk G) for browsing every slide. Repeating that inside the preview would double up the surface for no new information. The single-slide preview matches what a LinkedIn viewer actually sees first — they have to swipe to see slides 2..N anyway.
- Body is rendered as a single
<p>withwhitespace-pre-wrap, not split on\nand joined with<br>. Equivalent visually, simpler code, and copy/paste from the preview preserves the newlines the way the user sees them. The generator's voice rules use short lines + line breaks for breathing room; this is the rendering side of that contract. - Approve button uses a blue primary CTA (not white-on-black like Save/Render). Visual hierarchy: the approve action is the one we want the operator to click most often, distinct from the "neutral" edit/render actions. Picked LinkedIn-blue (
bg-blue-600) deliberately to associate it with "publishing to LinkedIn" — small cue but the action bar is dense and the color codes the intent. - Edit + Approve buttons are both gated on
status='draft'with status-aware tooltips. Once approved, both grey out and explain why; the operator can't fall into the "I clicked Edit and nothing happened" trap. Approved → "Approved ✓" label flip is a tiny but important state mirror — the button confirms the action took without needing the user to read the status badge.
- Editing is gated on
status='draft', no exceptions. The CLAUDE.md lifecycle isdraft → approved → scheduled → publishing → (published | failed)with no skipping. If editing were allowed onapproved, the approval gate would mean nothing — anyone could change content under an approved label. Backend returns 409 for any non-draft PATCH; frontend disables the Edit button with a lock-tooltip. If a user wants to change an approved post, the future "revert to draft" path or regenerate is the answer. Not adding revert in Chunk A — there's no real need yet, and adding it before there's a user complaint risks designing for a hypothetical. - Tag normalization happens server-side, not client-side. The frontend has its own lowercase + hash-strip in
DraftEditorfor nice live UX (chip preview matches what'll be saved), but the canonical sanitization runs inapp/api/posts.py::normalize_tags. Why: the database is the source of truth, and a future second client (CLI, mobile, API consumer) would otherwise need to duplicate the rules. The helper handles: lowercase, strip leading#, trim, dedupe (order-preserving — first occurrence wins), cap at 6 tags, truncate any tag past 40 chars. Cap at 6 (not 3, even though voice guidelines say "max 3 hashtags in a post") because tags in the DB include both LinkedIn hashtags and internal taxonomy markers; the rendering layer + publisher will subselect when posting. Today they're the same, but the headroom is cheap and protects future divergence. PATCHis partial, not replace. Body shape is{hook?, body?, tags?}— any omitted field is left untouched. Saves bandwidth and avoids the "missing field = null it out" footgun. Validated viaField(default=None, min_length=1, max_length=512)on hook andmin_length=1on body so passing empty strings 400s — empty hook/body is never valid.- Editor is a separately mounted client component, not inline in the page. Reason: the draft detail page is already 470+ lines doing visual rendering, carousel batch detection, asset fetching, and now editing. Extracting
DraftEditor.tsxkeeps the page tractable and sets the pattern for the upcomingLinkedInPreview(Chunk B) andSchedulePicker(Chunk C) components. Editor receives only what it needs (initial values + saving flag + callbacks) — no coupling to the fullPosttype or to the API helpers. - Auto-resizing textareas via ref + scrollHeight effect, not a CSS-only solution. The pure-CSS
field-sizing: contentis too new for safe cross-browser use yet, and<input>doesn't support multiline. The ref approach is one shareduseAutoResizehook inDraftEditor.tsx, called twice (hook + body). Reflows on every keystroke (cheap for a single textarea). - Backspace-on-empty pops the last tag. Common chip-input UX — matches GitHub's tag picker, Linear's labels, Notion's multi-select. The alternative (only × to remove) is fine but slower for keyboard users.
- Save updates page state from the server response, not from local state.
setPost(updated)afterapiPatchensuresupdated_atand any server-applied normalizations (e.g. lowercase tag with the # stripped) round-trip into the UI. Avoids the bug where local state drifts from server state after a save. - No optimistic UI on save in Chunk A. The full round-trip is small (~100ms over localhost) and the user is editing one post at a time. Optimistic UI adds rollback complexity for no felt benefit at this scale. The trends page does have optimistic toggles, but those are list-mutating actions across many rows where the latency would feel different.
- Two list concepts, not one. Chunk F shipped carousels but kept the single-image list shape — slide 1 in the primary preview, slides 2..N tucked into the collapsed
"earlier renders"expander. User correctly pointed out that's bad UX: you just generated a 5-slide carousel and 80% of it is hidden behind a click. Split into:- "Carousel slides" (always visible strip, NEW): every slide of the most recent carousel batch as a
1/N, 2/N, ... N/Nthumbnail grid. - "Earlier renders" (collapsed
<details>, existing): only prior render attempts — older single renders, older carousel batches.
- "Carousel slides" (always visible strip, NEW): every slide of the most recent carousel batch as a
- Front-of-list cluster detection with a 60s window, not explicit state. Considered tracking
currentCarouselIdsas React state set by the/render-carouselresponse, but that loses the carousel on page reload (user navigates away, comes back, strip is empty even though the batch is still inassets). Instead,useMemooverassetswalks from index 0 forward, grouping consecutivecarousel_slideassets whosecreated_attimestamps are within 60s of each other. Reload-safe, no extra state, decays correctly (re-render 60s+ later breaks the batch). - Why 60s. Real batches render at ~1-2s per slide (Stage 4 + Playwright + Supabase upload per slide). A 5-slide batch finishes well under 30s. 60s is generous enough that internal jitter never breaks a real batch, and short enough that two genuine carousel renders separated by a real human pause never merge into one fake mega-batch.
- Single-slide carousels don't show the strip.
currentCarousel.length > 1is the gate. A carousel post that Stage 4 collapsed into 1 slide (very short post) renders just like a single-image template — no point in a one-thumbnail strip. olderslice starts atcurrentCarousel.length || 1so slides 2..N never double-list. When no carousel is active, falls back toslice(1)— identical to pre-Chunk-G behavior. The change is purely additive for non-carousel post detail pages.
- Carousel renders are atomic and all-or-nothing. Chunk D shipped with the visible bug that the slide indicator said "1 / 5" but only slide 1 ever rendered — the frontend always sent
slide_index=1to/renderand there was no surface to produce the other 4. New endpointPOST /posts/{id}/render-carouselloops every slide, uploads each, persists NGeneratedAssetrows in a single SQLAlchemy commit. Ifrender_pngorstorage.upload_pngraises mid-loop, no rows commit — there's no half-rendered partial-carousel state to clean up. Upload orphans (uploaded but no row) are acceptable: future cleanup job can prune by listing the bucket and reconciling against DB. - Two endpoints, not one overloaded one. Considered making
/renderreturnGeneratedAssetRead | list[GeneratedAssetRead]based on template type, but that's an ambiguous response shape and forces callers to switch on type. Cleaner to split:/renderalways returns exactly one asset (single template OR explicit slide of a carousel),/render-carouselalways returns the full list. Frontend dispatches based on the selected template's name. This also leaves room to re-render just one bad slide (/renderwithslide_index=N) without forcing a full carousel re-roll. render_post_carouselvalidates the template iscarousel_slide. Auto-pick on a non-carousel post would resolve to a single-image template (e.g.ai_automationwithout metric →insight_cardvia Chunk D's smart fallback), and looping a single-image template across slide indexes 1..N would produce N identical PNGs. The validator raisesValueError→ 400. Callers either explicitly pass thecarousel_slidetemplate_id or call from acarousel-category post where auto-pick resolves to it.- Frontend dispatch is purely view-state, not server round-trip. The
isCarouselRenderboolean is computed fromselectedTemplateId+ the in-memorytemplateslist +post.category. No extra API call to figure out which endpoint to hit. Button label adapts live: pick the carousel template, see "Render carousel (5 slides)"; pick anything else, see plain "Render". - Slide-1-first ordering in the response list.
render_post_carouselappends slides 1..N in render order, soassets[0]is slide 1. Frontend prepends the whole list to the existing assets state, so the visible preview (assets[0]) is slide 1 and the older-renders strip shows slides 2..N + any previous render history. Display-order, not reverse-chronological — matches reader intuition (the carousel starts at slide 1).
- Rendered assets moved from local disk to Supabase Storage.
backend/storage/assets/is gone; new PNGs land in theautopost-assetsSupabase bucket. Why now (not at Phase 0): local disk was fine for Chunks A–D smoke iteration, but Phase 6 (LinkedIn publishing) needs an internet-fetchable URL for LinkedIn's media-upload API to ingest. Local127.0.0.1:8000/static/assets/...is unreachable from LinkedIn's servers. Object storage is the right shape, and we're already paying the Supabase tax (DB + Auth), so adding their Storage product is incremental — no new vendor. - Bucket is public, not signed. Renders are branded marketing visuals that end up on public LinkedIn anyway, so there's nothing to gate. Public buckets are CDN-fronted by Supabase (fast, cached) and bypass signed-URL round trips. Reads use the canonical public URL pattern
{SUPABASE_URL}/storage/v1/object/public/{bucket}/{path}; writes are still authenticated viaSUPABASE_SERVICE_ROLE_KEY(which bypasses storage RLS — same key already used for the DB). supabase-py(sync) wrapped inasyncio.to_thread(). The official Python SDK is sync. Same pattern we already use fortrafilaturainintelligence/article.py. No async-supabase needed; the upload is a single HTTP POST under the hood (no chained awaits to serialize).image_pathshape unchanged onGeneratedAsset— still the within-bucket key ({post_id}/{asset_id}.png). The bucket name lives inSUPABASE_STORAGE_BUCKET, not the column, so swapping vendors (S3, R2, etc.) is a URL-template change plus an upload-impl change — never a DB rewrite.GeneratedAssetRead.image_urlis computed in the Pydantic schema, not stored.StaticFilesmount removed fromapp/main.py. Backend no longer serves images. The dev-only/static/assetsroute is gone; the frontend's plain<img>tag now points at the Supabase CDN URL instead. Cross-origin still works (images don't trigger CORS).- Lazy client init in
storage.py. A module-levelClientis created on the first call, not at import time. Lets test environments without Supabase creds still import the renderer (currently irrelevant — no tests touch real Supabase — but it's the right shape for future test runners on machines without secrets). - 17 orphan
GeneratedAssetrows + 2.2 MB local directory wiped after smoke. Old rows pointed at paths that no longer exist anywhere; cleaner to start the post-Chunk-E history fresh than backfill smoke artifacts.Posttable untouched (drafts persist; only the render-history table was reset). - Phase 6 setup cost: zero. LinkedIn's
/rest/images?action=initializeUploadwill accept these public URLs directly. No image-server proxy, no signed-URL refresh logic, no separate bucket policy work.
- Stage 4 added to the generator, FAST model, JSON output. After Stage 3 finalizes hook/body/tags, Stage 4 reads the voiced text and returns
{pull_quote, metric_value, metric_label, carousel_slides[{text}]}. Lives onPost.extracts(new nullable JSONB column). The original "all four templates restage the same hook" critique made this necessary — visual variety without content variety is a half-finished pipeline. Measured cost: ~700ms latency, ~130 output tokens, ~$0.0001 per draft onllama-3.1-8b-instant. - Why JSONB, not 4 separate columns. Adding
pull_quote_text VARCHAR,metric_value VARCHAR, etc. would be tidy at first, then we'd need a migration every time we want a new per-template field (e.g. an OG-card subtitle, a quote attribution, a chart-card data series). JSONB pushes that off: new fields = code-only change. Trade-off is no DB-level constraints on extract shape; that's fine since the renderer + the parser already tolerate every weird shape the model can emit. - Tolerant parser, not strict validation.
_parse_visual_extractaccepts: bad JSON (returns empty extracts), non-dict JSON, missing fields, bare-string slides (the model occasionally returns["slide one", "slide two"]instead of[{"text":...}]), and orphanedmetric_valuewithoutmetric_label. Metric fields are co-required — if either is missing both null out, so the template never shows a number without context. - Lazy backfill on first render. Posts written before Chunk D have
extracts=NULL.render_post()checks for this and runsextract_visual_fields(voiced_text=post.body)inline on the first render, persists, and proceeds. Adds ~700ms to that single render; subsequent renders skip. No batch migration needed — old posts upgrade silently as they're touched. Logged asrenderer.lazy_extract. - Hook is always the fallback for every extract field.
pull_quote = extracts.pull_quote or hook.slide_text = extracts.carousel_slides[idx-1].text or hook. If there are no slides, slides become[{text: hook}](single-slide carousel). Posts with sparse or missing extracts render identically to pre-Chunk-D behavior. This is what makes lazy backfill safe: the renderer never assumes extracts exist. - LLM decides carousel slide count (3-5), capped at 5. The Stage 4 prompt instructs: slide 1 = hook (compressed if needed), middle slides = key beats one-per-slide, last slide = takeaway, each ≤14 words. The renderer reads the count from
extracts.carousel_slidesand surfaces it asslide_total;slide_index(1-based) picks the active one.MAX_CAROUSEL_SLIDES = 5is enforced in the parser to guard against runaway model output. The/renderendpoint validatesslide_indexas1 ≤ N ≤ 20(the high end leaves headroom if we ever raise the cap). - Smart auto-pick: ai_automation without a real metric falls back to insight_card. Discovered mid-smoke that long hooks (~140 chars) overflow the metric_card fallback typography. Two fixes: (a)
default_template_name_for_post()checksextracts.metric_valuebefore auto-pickingmetric_card; if null, returnsinsight_cardinstead. (b) The metric_card fallback typography was tightened fromtext-[112px] uppercasetotext-[64px] sans boldplus|truncate(140)so an explicit pick still renders without overflow. The explicit-pick fallback is more of a "safe failure mode" than the primary path — when extracts has a real metric (e.g. "$2,180/mo"), it goes to the proper metric branch with 160px display type. run_pipeline()return tuple changed from 4 to 5 elements — now(hook, body, tags, extracts, violations). Three test sites intest_generator.pywere updated; the change was localized and additive.
- Plain
<img>for asset preview, notnext/image. The PNG is served by the backend athttp://127.0.0.1:8000/static/assets/...— cross-origin from the Next.js dev server onlocalhost:3000. Usingnext/imagewould force aremotePatternsallowlist and route the request through Next.js's optimization pipeline, neither of which buys anything for a dev preview. Plain<img>works cross-origin (CORS doesn't restrict image loads), avoids the config friction, and keeps the preview as fast as the backend serves it.eslint-disable-next-line @next/next/no-img-elementannotates the choice. When production hosting lands and assets move to object storage, this either stays as plain<img>(signed URLs work the same) or migrates tonext/imageonce we configureremotePatterns. - One parallel fetch on mount via
Promise.allfor/posts/{id}+/posts/{id}/assets+/templates. Cheaper than three serial round-trips and keeps the loading state monolithic. New types live alongside the existingPost/PostStatus/PostCategorytypes in the same file — colocated because they're only consumed here. - "Auto for category" is the picker default. Backend's
_DEFAULT_TEMPLATE_BY_CATEGORYpolicy is the right answer most of the time, so the dropdown opens to "Auto" and only sendstemplate_idif the user explicitly picks an option. Empty string sentinel""in React state maps to "no template_id sent." - Each render keeps the previous asset visible, not replaced. The latest asset shows in the main preview slot; older renders collapse into a
<details>thumbnail grid below. Lets the user A/B-compare renders during voice/visual iteration. Matches the backend choice to never overwrite — each/rendercall creates a new file + row. - Render button is idempotent re-render once an asset exists ("Re-render"), distinct label from "Render" first-time. Tiny UI affordance that signals state without taking up extra screen space.
- Singleton Chromium process owned by the FastAPI lifespan, not per-request. Booting a fresh Chromium per render costs ~3 seconds.
app/rendering/playwright_runner.pyholds a module-level_browserstarted inlifespanand shut down on exit; everyhtml_to_png()call opens a fresh browser context (cookies/cache isolated) but reuses the browser process. Verified end-to-end:app.starting → scheduler.started → playwright.started → yield → playwright.stopped → scheduler shut down. - HTML + Tailwind via CDN, not a build step. Each of the 4 templates pulls Tailwind from
https://cdn.tailwindcss.cominside a<script>tag. Trade-off: render waits for the CDN response (set_content(html, wait_until="networkidle")handles that). Upside: adding a new template is one HTML file + onetemplatesrow, no Node toolchain in the backend, no build artifacts. If the CDN dependency ever becomes a problem (rate limits, offline dev), the swap is to a vendored Tailwind 4 CSS file — same class names work. - One visual identity across all 4 templates — "minimal dark." Black background, off-white serif headline, thin sans body, accent line above the brand strap. The four templates differ in how the hook is staged, not in palette/typography. Why: brand consistency in the LinkedIn feed beats per-card visual variety. Saved to memory as [[project-visual-style]] so future sessions know not to introduce per-category color accents.
- Brand name = "Nestwhirr." Distinct from "Sacomt" (user's email domain) and "AutoPost" (project name). Configured via
BRAND_NAMEenv var (default inapp/config.py+.env.example). Rendered as the footer strap on every visual. Saved to memory as [[project-brand-name]]. - Renderer split into pure + DB-orchestration layers.
render_html()andrender_png()are pure (no DB, no I/O).render_post(session, post_id, template_id?)is the DB orchestration. The pure layer is the testable seam — 17 tests intests/rendering/exercise it with no real Chromium and no real DB. The DB layer is verified end-to-end manually (smoke script renders a real draft from Supabase). - Default template = function of
post.category, not stored on the Post._DEFAULT_TEMPLATE_BY_CATEGORYlives inrenderer.pyas a Python dict (founder_insight→insight_card, engineering_lesson→quote_card, ai_automation→metric_card, startup_opinion→quote_card, technical_humor→insight_card, carousel→carousel_slide; Phase 12: dev_tooling→quote_card, pricing_business_model→metric_card, product_postmortem→quote_card, design_thinking→insight_card;.get()falls back to insight_card for anything unmapped). Caller can override with explicittemplate_idonPOST /render. Rationale: the mapping is policy that changes with the brand identity, not data — keeping it in code (instead of a DB column) means changing the rule is a one-line edit. If the mapping ever needs per-account customization (multi-brand future), it migrates to adefault_template_idcolumn onusersorlinkedin_accounts. - Each
/rendercall creates a newGeneratedAssetrow + new file. No upsert, no overwrite. Old assets stay so the user can compare prior renders during voice/visual iteration. Files live atbackend/storage/assets/{post_id}/{asset_id}.png. Cleanup deferred — disk pressure is not a real concern at MVP volume. - Storage paths are relative (
{post_id}/{asset_id}.png), not absolute. Stored that way inGeneratedAsset.image_pathso the dev/prod storage backend is swappable later — switching to S3/R2 only changes the URL prefix, not the column.GeneratedAssetRead.image_urlis computed as{APP_BASE_URL}/static/assets/{image_path}. - FastAPI
StaticFilesmount at/static/assetsis dev-only. Backend binds to 127.0.0.1 per CLAUDE.md so it's not internet-exposed, but the mount has no auth — anyone with localhost access can fetch any rendered PNG. For production, the mount comes out andimage_urlwill point at a signed object-storage URL. Comment inapp/main.pydocuments this trade. /api/v1/templateslisting endpoint added for future picker UI. Even though Chunk C (frontend) hasn't shipped yet, the endpoint is wired now so the frontend can fetch the list and render a dropdown when Chunk C lands. Auth-gated like every other route.
- MVP shape: full vertical slice — every subsystem present but thin, so the whole pipeline is validated end-to-end before any subsystem is deepened.
- Approval flow: manual approval + scheduled auto-publish — humans approve drafts in dashboard; APScheduler auto-publishes at the chosen
scheduled_at. No fully autonomous publishing in MVP. - Hosting: local-only during MVP — Supabase free tier for managed Postgres; FastAPI, Next.js, Playwright, APScheduler all on localhost. Deploy later.
- Retrieval sources: RSS + HackerNews + Reddit — GitHub Trending deferred (no official API, scraping fragile).
- Dashboard auth: Supabase Auth (email magic link) — maps cleanly to multi-user later; minimal scaffolding for single-user now.
- LinkedIn auth: full 3-legged OAuth + refresh — production-shape from start; works for any future company.
- Repo layout: monorepo with
/backend+/frontend— one developer, single coordinated lifecycle. - Scheduler: APScheduler in-process with Postgres jobstore — survives restarts via the jobstore; no separate worker process needed yet.
- Drafts UI lives at
/draftsand/drafts/[id]. Generation is fired from the list page via a small inline form (category dropdown + Generate button) — no separate "new draft" page. Detail is read-only; the full editor + approve/schedule flow is Phase 5. UsedReact.use(params)for Next.js 16's promise-shaped route params — confirmed against the bundlednode_modules/next/dist/docs/01-app/01-getting-started/03-layouts-and-pages.mdper the frontend AGENTS.md flag. - Voice tuning iterated v1 → v2 → v3 → v4 in three migration rounds, no code redeploys. The versioned
promptstable is doing the work it was designed to do: every voice change isop.execute("UPDATE … is_active=false") + op.bulk_insert(new_version_rows). Each round fixed a distinct failure mode (seeCHECKLIST.mdPhase 3 voice-iteration history for the full diff). High-level: v1's anchors taught the model to fabricate autobiography; v2 fixed that but went tweet-shaped; v3 fixed shape but went explainer-mode; v4 anchored against real founder positions (Fried, DHH, Lavingia, Willison) and added a "REACT, DON'T EXPLAIN" budget rule. - The expanded
voice.pybanned-phrase list now catches 12 additional tells ("is also worth noting", "sparked my interest", "this got me thinking", "one notable aspect", "valuable lesson for anyone", "i've seen many teams" + fake-authority prefix variants). These all appeared in real v3 output. Added in lockstep with the v4 prompt's NEVER-USE block so both layers agree. - Length is now controlled at infrastructure level, not prompt level. v4 prompts ask for 140-220 words and the model ignores it (~280-430 actual). Cut
VOICE_MAX_TOKENS800 → 350 ingenerator.pyto enforce ceiling. CutMAX_CHARS8000 → 2500 inarticle.pyso the model has less raw article material to recapitulate. Belt-and-suspenders: the prompt asks, the token cap enforces. - Shipping at ~65-70% voice quality is intentional. Further prompt iteration without real positive/negative training samples hits diminishing returns. Deferring v5 to Phase 8 (Hardening), where we'll have actual
postshistory and can mine "this one I liked, this one I didn't" pairs from the user's real review behavior. The mechanics (versioned prompts, audit trail inai_generations, the two-model split) are exactly what we need to feed that loop later. - Sample-driven future loop (deferred Phase 8 work): when there are ~10 user-marked "good" drafts, write a v5 migration that swaps the public-founder anchors for the user's actual liked posts. This is the highest-leverage future lever — LLMs follow examples ~10x harder than abstract rules.
- Two-model split in the generator pipeline. Stage 1 (angle synthesis) and Stage 2 (founder-voice rewrite) run on
GROQ_MODEL_PRIMARY(llama-3.3-70b-versatile) because both require reasoning + nuance. Stage 3 (structured extract →{hook, body, tags}JSON) runs onGROQ_MODEL_FAST(llama-3.1-8b-instant) because it's mechanical pattern-matching withresponse_format={"type":"json_object"}. Per draft: 2 primary + 1 fast call (~10× cheaper than running everything on primary). The optionalpolish_voice()pass also uses fast. run_pipeline()is pure (no DB),generate_draft()is the orchestration. This split is the testable seam —tests/intelligence/test_generator.pymocksgroq_client.chatand asserts the stage/model/prompt_id propagation without touching Postgres. The DB orchestration (load active prompt, pick trends, fetch articles, persist Post, mark consumed) lives ingenerate_draft()and gets exercised live in Chunk C and beyond.- Voice direction baked into 4 seeded prompts via anchor examples (LLM follows examples >> abstract rules). Each prompt body is ~2K chars and contains: a one-line role definition, signature pattern (contrarian/confessional hook → ONE concrete specific → punchy reframe), 3 anchor example posts written in that pattern, and a strict rule list (banned phrases + structural constraints). Anchors are content-distinct from the trends so the model copies rhythm, not substance. Bumping a prompt = insert new row with same
name,version+1, set oldis_active=false— no code deploy needed. - AI-tell strip runs between Stage 2 and Stage 3, not after Stage 3. This is intentional: the deterministic stripper cleans the rewrite output before the extract model sees it, so the extracted
bodyis already clean. Running it after Stage 3 would mean re-parsing post-JSON text and risking inconsistency betweenhook/body. - Bad-JSON fallback in
_parse_extract. If Stage 3 returns non-JSON despiteresponse_format=json_object, we fall back to(first_line_of_voiced, voiced_full_text, [])so a generation never hard-fails on a model glitch. Caller still gets a usable draft — at worst with empty tags. /posts/generateis synchronous (returns the new draft inline, ~5-10s). Fine for MVP single-user. If user-perceived latency becomes a problem in Phase 5, the wrap is "queue as APScheduler one-shot job, return 202 + poll". No refactor needed in the generator itself.
- Groq SDK bumped 0.15.0 → 1.4.0 (web-verified latest on PyPI). The async surface —
AsyncGroq(...).chat.completions.create(model=, messages=, ...)— is unchanged from 0.15, so no call-site rewrites later; we get current bug-fixes and the May 2026 API for free. trafilatura==2.0.0pinned for article extraction. 2.0.0 has been the stable release since Dec 2024 — no major release pending. Used withfavor_precision=True,include_comments=False,include_tables=Falseso output is dense narrative prose, not boilerplate or scraped UI fragments.groq_client.chat()is the single LLM entry point. Every call writes oneai_generationsrow withstage(pipeline-phase label), fullinputandoutputtext, token counts, and latency, regardless of success/failure. The audit layer's own failures are logged not raised — never block a generation on an audit-write hiccup. Retries:tenacity.AsyncRetryingwith 4 attempts and exponential backoff (1–10s), filtered toAPIConnectionErrorandRateLimitError. 4xx errors propagate immediately — retrying a malformed request or a bad API key is wasted spend.- Article fetch is on-demand and isolated.
fetch_article(url)is the only public surface. Network:httpx.AsyncClientwith 10s total timeout, polite UA, follow redirects. Extraction:trafilatura.extractruns inasyncio.to_threadbecause it's sync. Any failure (network, non-200, empty extract) returnsNone— generator falls back totitle + summaryfor that trend. 8K char cap on the returned text (~2K tokens), so a long-form essay can't blow the context budget when we pass 3 picked trends at once. - Tests don't touch the network or DB. Groq client tests stub
_client()and replace_log_generationwith a recorder; article tests usehttpx.MockTransportlike the HN tests. 12 new tests inbackend/tests/intelligence/, all passing in <2s.
- Article body fetched on-demand at generation, not pre-fetched at retrieval. Why: we generate 1-3 posts/day but retrieve ~50 new trends/day. Pre-fetching every trend's body wastes effort on ~95% we'll never use, plus storage bloat. Going on-demand keeps the
trendstable small and avoids paywall complications for items we never touch. Will usetrafilatura(battle-tested HTML→clean-text library) with a graceful fallback to title+summary on fetch failure. - LLM receives clean text, never raw HTML. Parsing on our side (
app/intelligence/article.py). LLMs are bad at HTML and the tokens are wasteful. - Voice direction: contrarian + builder-grounded. No samples from user; voice anchored via 2-3 fabricated example posts inside each prompt template (LLM follows examples >> abstract rules). Signature pattern: contrarian/confessional hook → one specific concrete detail → punchy takeaway. Prompts versioned in DB → no code deploys for voice tuning. Iteration loop: ship draft → user marks "generic" vs "good" → tune prompt → bump version.
- Phase 3 chunk split: A (infra: Groq client + article extractor), B (voice + prompts + generator + endpoints), C (drafts queue UI). Full breakdown in
CHECKLIST.md.
PATCH /api/v1/trends/{id}added with body{consumed: bool}— sets/clearsconsumed_at. Used by the frontend "Mark consumed / Mark unread" toggle. Phase 3's intelligence layer will also write toconsumed_atwhen it picks a trend for a draft (programmatic, no API needed).- Frontend
/trendspage is the first non-trivial dashboard view: status filter (unconsumed / consumed / all), source filter (rss / hackernews / reddit), manual "Refresh now" →POST /trends/refreshwith a fetch-summary banner, and optimistic-update consume toggle that removes the row from the active view on success. Page limit hardcoded at 100 (backend max 200) — pagination deferred until the unconsumed pool consistently exceeds 100 in practice. - Long-term flooding is not a real concern, contrary to the first-run impression. After the initial fetch, the
(source, source_id)UniqueConstraint drops re-fetched items at the DB layer; realistic per-cron inserts are 15-35 (~50/day). Storage growth: ~75MB/year at 50K rows. Cleanup job (DELETE WHERE consumed_at IS NULL AND fetched_at < NOW() - INTERVAL '180 days') deferred — add when there's actually something to clean up. - Test suite added at
backend/tests/retrieval/: 27 tests, all passing.test_dedupe.pycovers URL normalization (utm/fbclid stripping, case, trailing slash), title-similarity behavior, and dedupe order-preservation + cross-source collisions.test_rss.pyandtest_hackernews.pyusehttpx.MockTransportto stub the network — happy path, age cutoff, per-feed cap, 304 not-modified, single-feed failure isolation, dead/deleted HN items, fallback URL for text-post stories. Runs in <0.4s with no network/DB contact. backend/pyproject.tomlcreated for pytest config (asyncio_mode = "auto",asyncio_default_fixture_loop_scope = "function"). First pyproject.toml in the project — keeps test config out ofrequirements.txt.
- Two RSS feeds added to fill the only real signal gaps: Simon Willison (
simonwillison.net/atom/everything/) — independent AI commentary, multiple posts per day, the closest thing to "very current" without going to Twitter. Latent Space (latent.space/feed) — AI engineering newsletter by swyx, 2-3×/week, fills the "how AI actually works" angle that the corporate feeds (OpenAI, Hugging Face) and the engineering-ops feed (Pragmatic Engineer) miss. End state: 7 RSS feeds + HN = 180 fresh items per cron, verified. - Trends table truncated to remove the 1,837-row flood from the pre-cap first orchestrator run. Safe — no FK constraints reference
trends(posts.source_trend_idsis aUUID[]array, not an FK). Phase 3 will start from a clean unconsumed pool.
- First Round Review dropped from RSS list — both candidate URLs (
/rssand/feed.xml) return 404; their RSS appears discontinued. RSS list now 5 feeds: Stratechery, Stripe, Pragmatic Engineer, OpenAI, Hugging Face. Verified live end-to-end. - RSS per-feed cap + age cutoff — Stratechery alone returned ~1,500 entries (full backlog) on first fetch, which flooded the unconsumed pool with years-old posts. Caps now:
MAX_ENTRIES_PER_FEED=25(most recent) andMAX_ENTRY_AGE=90 days(skip stale). Why: the Intelligence layer (Phase 3) selects from unconsumed trends, so retrieval freshness matters. - APScheduler started in FastAPI lifespan — single AsyncIOScheduler, Postgres
SQLAlchemyJobStore(sync, psycopg driver — the asyncpg URL is coerced viaapp/scheduler/scheduler.py::_sync_url). One job registered:retrievalon cronRETRIEVAL_CRON(default0 */6 * * *),replace_existing=True,max_instances=1,coalesce=Trueso a missed firing during downtime collapses into one catch-up run rather than backlogging. - Orchestrator uses Postgres
INSERT ... ON CONFLICT DO NOTHING ... RETURNING id— gives accurate insert count across runs (rowcount on ON CONFLICT is driver-specific; RETURNING is portable). - Trends API auth-gated — both
POST /api/v1/trends/refreshandGET /api/v1/trendsrequire a Supabase JWT (same dep as the LinkedIn endpoints). Confirmed: unauthenticated requests return 403.
- Web-search-before-third-party-integration rule added to CLAUDE.md — training-data drift on vendor APIs is the dominant source of waste; a 30-second search beats a 30-minute wrong path.
- RSS feed list locked at 6, post-verification: Stratechery, First Round Review, Stripe blog, The Pragmatic Engineer (newsletter), OpenAI news, Hugging Face blog. Each URL confirmed live in May 2026.
- Anthropic dropped from RSS — no official feed exists; only community GitHub scrapers (fragile). Replaced with Hugging Face blog (
huggingface.co/blog/feed.xml, official RSS 2.0). Same "primary AI source" role, builder-flavored. - Indie Hackers dropped — no official RSS. HackerNews top/best stories already surface indie/builder signal, so we'd be double-counting. Easy to re-add later via a third-party feed if voice feels thin.
- Paul Graham dropped — PG is a style reference, not a trend source (publishes ~4-6 essays/year). Per CLAUDE.md "retrieval = what's new" rule, his framing belongs in prompts (Phase 3), not in the retrieval pipeline.
- HackerNews source mix: top + best, not new. Top 30 + best 20, ID-deduped.
newstoriesis too noisy for a 6h cron andbeststoriesis quality-weighted over 24-48h, which is the right window for a content pipeline that publishes daily. - Reddit fetcher written but DISABLED in orchestrator — Reddit Data API approval still pending. File ships fully implemented; activation = uncomment one import line in
orchestrator.py(Chunk B) once approval lands. - Dedupe = two layers: DB
UniqueConstraint(source, source_id)for across-run dedup; in-processdedupe.pyfor within-batch cross-source dedup (URL normalization + trigram-Jaccard title similarity at threshold 0.85). Order-sensitive — orchestrator must pass items in source-priority order (highest-quality first).
- Pivot to personal posting (single LinkedIn app) — Community Management API requires a registered legal entity (LLC/Pvt Ltd/Corp), which we don't have. LinkedIn's algorithm favors personal posts over company-page posts anyway (5-10× reach), and the user has set up a LinkedIn account with the company name, which posts under
urn:li:person:...but presents the company brand. Single LinkedIn dev app, scopesopenid profile email w_member_social. Thelinkedin_accounts.account_kindcolumn stays in the schema as an inert future-state for zero-rework cutover if we ever register a legal entity later. - Database connection: Supabase Session Pooler URL (port 5432) for both migrations and app queries — Transaction Pooler (port 6543) causes Alembic advisory-lock hangs; Direct connection is IPv6-only by default. Session Pooler is IPv4-safe, supports prepared statements, and works for persistent FastAPI connections.
- Two
.envfiles, not one root file —backend/.envfor backend secrets,frontend/.env.localforNEXT_PUBLIC_*only. Matches both pydantic-settings defaults and Next.js conventions; prevents accidental leak of backend secrets to the browser bundle. - LinkedIn endpoint =
/rest/postswithLinkedIn-Version: YYYYMMheader, not the deprecated/v2/ugcPosts. Pinned viaLINKEDIN_API_VERSIONenv var (currently202604).
All tables: id UUID PRIMARY KEY DEFAULT gen_random_uuid(), created_at, updated_at. Migrations via Alembic in backend/alembic/.
| Table | Purpose | Key columns |
|---|---|---|
users |
Dashboard users (mirrors Supabase Auth) | id, email, role |
linkedin_accounts |
OAuth token storage (Fernet-encrypted) | id, user_id, account_kind ('personal'/'organization'), member_sub, org_urn (nullable), access_token, refresh_token, expires_at, scopes |
bluesky_accounts (Phase 11) |
App-Password credential storage (Fernet-encrypted) | id, user_id, handle, did, app_password, display_name |
trends |
Ingested raw items | id, source (rss/hn/reddit), source_id, title, url, summary, score, raw_payload jsonb, fetched_at, consumed_at |
prompts |
Versioned prompt templates | id, name, category, body, version, is_active |
templates |
Visual templates registry | id, name, category, html_path, thumbnail_url |
posts |
Drafts → scheduled (terminal published/failed aggregated from targets) | id, category, body, hook, tags jsonb, source_trend_ids uuid[], status, scheduled_at, published_at, linkedin_urn (legacy, kept nullable), error |
post_targets (Phase 11) |
Per-platform fan-out for a Post — drives the publisher | id, post_id, platform (linkedin/bluesky/mastodon/threads/instagram), status (pending/publishing/published/failed), scheduled_at, published_at, external_urn, error, raw_request jsonb, commentary (nullable per-platform body override, Phase 11 iter). UNIQUE (post_id, platform) |
generated_assets |
Rendered images per post | id, post_id, template_id, image_path, width, height, linkedin_asset_urn, render_kind |
ai_generations |
Audit trail of every LLM call | id, post_id, prompt_id, model, input, output, tokens_in, tokens_out, latency_ms, cost |
publishing_logs |
Every publish attempt | id, post_id, target_id (nullable, Phase 11+), attempt, status, linkedin_response jsonb, error, attempted_at |
analytics |
Time-series engagement snapshots | id, post_id, impressions, likes, comments, shares, clicks, snapshot_at |
posts.status lifecycle (Phase 11+): draft → approved → scheduled → (published | failed). The terminal states are aggregated from post_targets — published once every target settles with ≥1 success, failed when all targets fail. Per-platform delivery lives on post_targets.status (pending → publishing → published|failed). No state-skipping.
All under /api/v1. Auth: Authorization: Bearer <supabase-jwt> verified via Supabase JWKS in a FastAPI dependency.
GET /auth/linkedin/start → redirect to LinkedIn OAuth
GET /auth/linkedin/callback → exchange code, store token, redirect to frontend
GET /auth/linkedin/status → connected org info
POST /auth/bluesky/connect → {handle, app_password} → validates via atproto, stores Fernet-encrypted (Phase 11)
GET /auth/bluesky/status → connected Bluesky handle + DID
DELETE /auth/bluesky/disconnect → remove all Bluesky accounts for user
GET /trends?status=unconsumed&limit= → list ingested trends
POST /trends/refresh → kick off retrieval on demand
PATCH /trends/{id} → mark consumed / unread
DELETE /trends/{id} → hard-delete one trend
POST /trends/bulk-delete → {ids: [...]} → hard-delete up to 500
POST /posts/generate → {category, trend_ids?, platforms?} → creates draft + one PostTarget per platform (Phase 11 Chunk A iteration)
GET /posts?status=draft|scheduled|published
GET /posts/{id}
PATCH /posts/{id} → edit body/hook/tags
POST /posts/{id}/approve → status=approved. Targets now pre-attached by /generate; legacy `{targets}` body still accepted as backfill
POST /posts/{id}/unapprove → approved → draft, drops all PostTargets (Phase 9 + 11)
POST /posts/{id}/schedule → {scheduled_at} → fans out one APScheduler one-shot per target
POST /posts/{id}/unschedule → scheduled → approved, cancels every per-target job (Phase 9 + 11)
POST /posts/{id}/render → {template_id?, slide_index?, kind?} regenerate image; kind='ai_image' → Flux + brand-wrap
POST /posts/{id}/publish-now → fan out inline to every PostTarget; 502 only if ALL targets failed
POST /posts/{id}/retry → re-attempt every failed target; already-published targets untouched
GET /posts/{id}/targets → list PostTarget rows (Phase 11)
DELETE /posts/{id} → gated on {draft, failed}
GET /templates → list visual templates
GET /analytics/posts/{id} → time-series snapshots
Retrieval (backend/app/retrieval/)
Abstract Fetcher.fetch() -> list[TrendItem]. Orchestrator calls every fetcher, runs dedupe.py (URL hash + title trigram similarity), bulk-upserts into trends. Triggered by APScheduler cron + manual /trends/refresh.
Generation (backend/app/intelligence/generator.py)
- Pick unconsumed trends (filtered by category if requested).
- Stage 1 — Groq
llama-3.3-70b-versatile: synthesize an opinionated angle. - Stage 2 —
voice.pyrewrite pass: founder voice, strip AI tells. - Stage 3 — FAST model: extract
hook,body,tagsas structured JSON. - Stage 4 — FAST model: extract per-template content (
pull_quote,metric_value/label,carousel_slides) →post.extractsJSONB. - Stage 5 — FAST model (Phase 10): extract a single 12-25 word visual prompt for Flux →
post.extracts.ai_image_prompt. Brand-safe fallback string used if JSON is bad/empty. - Persist
post(status=draft) +ai_generationsrow. Mark source trendsconsumed_at.
Rendering (backend/app/rendering/renderer.py)
Two backends behind one entry: render_post(..., kind: RenderKind = template).
kind=template(default) — pick template bypost.categoryor explicittemplate_id→ Jinja2 → Playwright PNG @ 1200×1200 → Supabase upload →generated_assetsrow (render_kind='template'). Carousel posts go throughrender_post_carouselfor N slides in one transaction.kind=ai_image(Phase 10) — resolve Stage-5 prompt (lazy-backfill if missing) →app.rendering.ai_image.generate_image(prompt)calls Cloudflare flux-1-schnell primary, Pollinations fallback → base64 data URI → renderai_image_wrap.html(1200×1200 brand-wrap with 800×800 inset) → Playwright PNG → Supabase upload →generated_assetsrow (render_kind='ai_image').
Scheduler (backend/app/scheduler/)
APScheduler AsyncIOScheduler with SQLAlchemyJobStore(url=DATABASE_URL) — jobs survive process restarts. Started in FastAPI lifespan hook. Job types:
publish_target_job(target_id)— Phase 11+, one one-shot perPostTarget. Stable idpublish-target:<uuid>. Replaces the pre-Phase-11publish_post_job(post_id).retrieval_job()— cron (default every 6h)analytics_collection_job()— cron (hourly)cleanup_consumed_trends_job()— cron (default daily 03:30); sweepstrendsrows whereconsumed_at < now() - TRENDS_CONSUMED_RETENTION_DAYS(default 60)
Publishing (backend/app/publishing/)
Platform-agnostic orchestrator + one Publisher ABC subclass per platform.
base.py—PublisherABC (platform,char_limit,max_images,supports_documents+async def publish(...)). Registry self-populated via@registerdecorator. Exception taxonomy:PublishError → {Permanent, Transient, Auth}.orchestrator.py—publish_target(session, target_id)is the single entry. Owns status flips (pending → publishing → published|failed), retry classification (auth → forced refresh + 1 more; transient → backoff 2/4s up to MAX_ATTEMPTS=3; permanent → stop), char-limit gate (fails target with clear error before callingpublish), per-attemptPublishingLogrows, and aggregation back up toposts.status. Also exposespublish_all_targets(post_id)for the inlinepublish-now/retryflows.linkedin.py—LinkedInPublisher(platform=linkedin,char_limit=3000,max_images=20,supports_documents=True). Token refresh + author URN logic moved out of the old orchestrator into here. Carousel slides ≥2 → PDF document via/rest/documents.bluesky.py—BlueskyPublisher(platform=bluesky,char_limit=300,max_images=4,supports_documents=False). Auth = Fernet-decrypted app password +Client.login(handle, pwd)per publish (no session caching).send_post/send_imagesviaasyncio.to_threadsince SDK is sync. Heuristic string-match error classification until atproto ships typed errors.
Day-one abstractions to keep future expansion cheap:
FetcherABC inbackend/app/retrieval/base.py— adding GitHub Trending later = one new filePublisherABC inbackend/app/publishing/— same interface for LinkedIn now, Twitter/X laterTemplateregistry keyed by category — adding a new visual style = drop HTML + DB row- Versioned
promptstable — prompt iteration without code changes linkedin_accounts.org_urn— schema already supports multiple connected orgs
Two files (never a root .env):
backend/.env— seebackend/.env.examplefor the full list with empty values.frontend/.env.local— seefrontend/.env.local.example.
Key backend groups:
- Database:
DATABASE_URL— Supabase Session Pooler URL on port 5432 (not Transaction Pooler 6543). - Supabase:
SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY - Groq:
GROQ_API_KEY,GROQ_MODEL_PRIMARY,GROQ_MODEL_FAST - LinkedIn (single app, personal posting):
LINKEDIN_CLIENT_ID,LINKEDIN_CLIENT_SECRET,LINKEDIN_REDIRECT_URI,LINKEDIN_SCOPES(openid,profile,email,w_member_social),LINKEDIN_API_VERSION(202604) - Reddit:
REDDIT_CLIENT_ID,REDDIT_CLIENT_SECRET,REDDIT_USER_AGENT - App:
APP_SECRET_KEY(Fernet),APP_BASE_URL,FRONTEND_BASE_URL,TIMEZONE,LOG_LEVEL - Cron:
RETRIEVAL_CRON,ANALYTICS_CRON,TRENDS_CLEANUP_CRON(default30 3 * * *) - Retention:
TRENDS_CONSUMED_RETENTION_DAYS(default60) - AI image (Phase 10):
CLOUDFLARE_ACCOUNT_ID,CLOUDFLARE_AI_TOKEN; tunable knobsAI_IMAGE_MODEL(default@cf/black-forest-labs/flux-1-schnell),AI_IMAGE_STEPS=4,AI_IMAGE_WIDTH/HEIGHT=800,AI_IMAGE_TIMEOUT_SECONDS=30. Pollinations fallback requires no auth. - Bluesky (Phase 11 Chunk A): connect-time only — handle + app password are submitted to
POST /auth/bluesky/connect, stored Fernet-encrypted inbluesky_accounts. No env vars; per-account, not per-deployment. SDK pin lives inrequirements.txtasatproto==0.0.62.
Frontend keys (NEXT_PUBLIC_* — safe to bundle into browser JS):
NEXT_PUBLIC_SUPABASE_URL,NEXT_PUBLIC_SUPABASE_ANON_KEY,NEXT_PUBLIC_API_BASE_URL
| API | Purpose | Auth | Rate notes |
|---|---|---|---|
| Groq | LLM generation | API key | Generous free tier |
| LinkedIn UGC Posts | Publishing + media upload | 3-legged OAuth | Standard LinkedIn limits |
| Subreddit trend ingestion | OAuth (app + script) | 60 req/min | |
| HackerNews | Trend ingestion | None (public) | Polite scraping |
| Cloudflare Workers AI | Flux image generation (Phase 10 primary) | Bearer token | 10k neurons/day free (~260 images) |
| Pollinations | Flux image fallback (Phase 10) | None | Occasional outages — soft fallback only |
| Bluesky (AT Protocol) | Publishing (Phase 11 Chunk A) | App Password via atproto==0.0.62 Client.login |
No documented rate limits; SDK is pre-1.0, exact-pinned |
| RSS feeds | Trend ingestion | None | Per-source |
| Supabase | Auth + Postgres | URL + anon/service-role keys | Free tier |
app/api/posts.py:MIN_SCHEDULE_LEAD_SECONDS = 60module constant.ScheduleRequest(BaseModel)withscheduled_at: datetime.POST /posts/{post_id}/schedule— loads post → 404 if missing → 409 ifstatus not in {approved, scheduled}→ tags naive datetimes as UTC → 400 if within 60s of now → setsscheduled_at+ flips toscheduled→ commits → returns refreshedPostRead. Idempotent re-scheduling.
frontend/components/SchedulePicker.tsx— new client component. Props:initialScheduledAt,scheduling,onSchedule(isoUtc),onCancel.<input type="datetime-local">in local TZ; default value isnow + 15minrounded up to 5-min mark; mirrors the server's 60s gate with a 70s client buffer; shows the user's TZ label + a live relative-time hint; submit emitsDate(local).toISOString().frontend/app/drafts/[id]/page.tsx:- New state:
scheduling,schedulePickerOpen,scheduleError. schedulePost(isoUtc)callsapiPost<Post>('/posts/{id}/schedule', {scheduled_at: isoUtc})→ swaps page state.- New Schedule button on the action bar (indigo primary) — enabled when status in
{approved, scheduled}, label flips to Reschedule when already scheduled, opens the picker inline above the action row. - Scheduled-time banner ("Scheduled for X. Publishes once Phase 6 worker is wired.") renders above the action bar whenever
status === 'scheduled'.
- New state:
frontend/app/scheduled/page.tsx— new page. FetchesGET /posts?status=scheduled, sorts ASC byscheduled_at, shows category badge + scheduled-status badge + datetime +relativeFromNow()chip, click-through to detail. Empty state guides the user back to drafts.frontend/app/published/page.tsx— new page. FetchesGET /posts?status=published, sorts DESC bypublished_at. Each row links to a LinkedIn post URL constructed vialinkedinUrlFromUrn()(null-safe for missing/malformed URNs). Empty state points at/scheduledfor the publish dependency.frontend/app/page.tsx— dashboard nav: replaced the "Coming soon" placeholder with separate Scheduled + Published cards, kept a thinner "Coming soon" stub pointing at Phases 6 + 7.- Test suite still 118 passing in 1.93s. No new backend tests — the schedule endpoint has one branch (the status gate) plus a time-validity check, both small enough that the repo's manual smoke pattern is the right fit. TypeScript clean. Live OpenAPI confirms PATCH /posts/{id}, POST approve, POST schedule all registered + auth-gated (unauthenticated returns 403).
/scheduledand/publishedserve 200. - Browser e2e pending — user-driven check. Approve a draft → Schedule (~5 min from now) → status flips to
scheduled+ banner shows + /scheduled lists it next-up. Reschedule changes the time. /published is empty until Phase 6 ships the worker.
app/api/posts.py::POST /posts/{post_id}/approve— loads post → 404 if missing → 409 ifstatus != 'draft'→ flips toPostStatus.approved→ commits → returns refreshedPostRead. Router enumeration confirms registration (8 routes total now: generate, list, get, patch, approve, render, render-carousel, list-assets).frontend/components/LinkedInPreview.tsx— new client component. Props:body,tags,imageUrl,carousel?,authorName?,authorTitle?. LocalAvatarhelper renders initials in a sky→indigo gradient circle. Body in a<p>withwhitespace-pre-wrap break-wordsto preserve generator line breaks. Hashtags inline in LinkedIn blue (#0a66c2), space-separated. Hero image is the single asset OR slide 1 of a carousel with a "1 / N" badge top-right + "swipe →" bottom-right. Stub reactions row (👍❤ + count + "X comments · Y reposts") and a 4-column Like/Comment/Repost/Send action bar for chrome.frontend/app/drafts/[id]/page.tsx:- New imports:
LinkedInPreview. - New state:
approving,approveError. approveDraft()callsapiPost<Post>('/posts/{id}/approve'), swaps page state from the server response.- New "LinkedIn preview" section between Visual and Hook. Reuses
latest?.image_urland the existingcurrentCarouselmemo (when N>1) to populate the preview. Author name derives fromsession.user.email. - Action bar now has Edit | Approve | Generate another. Approve is the blue primary CTA. Both Edit and Approve gate on
status='draft'with status-aware tooltips; Approve label transitions to "Approved ✓" post-flip. Approve errors render in a red banner above the action row.
- New imports:
- Test suite still 118 passing in 1.92s (no new tests — approve endpoint has a single one-branch status gate, covered by the manual smoke pattern this repo uses for thin endpoints). TypeScript clean.
- Browser e2e pending — user-driven check: open draft → preview shows latest image + full body + blue hashtags → click Approve → status flips to "approved", Edit + Approve both disable with new tooltips, "Approved ✓" label sticks.
app/api/posts.py:normalize_tags(raw) -> list[str]— module-level helper (lowercase, strip leading#, trim, dedupe order-preserving, cap atMAX_TAGS=6, truncate atMAX_TAG_LENGTH=40, tolerates non-strings).PostUpdate(BaseModel)— partial-update body withhook?: str (1..512),body?: str (≥1),tags?: list[str]. Pydantic raises 422 for invalid types/lengths.PATCH /posts/{post_id}— fetches post → 404 if missing → 409 ifstatus != 'draft'(lifecycle gate) → applies non-None updates → commits → returns refreshedPostRead.
frontend/components/DraftEditor.tsx— new client component. Props:initialHook,initialBody,initialTags,saving,onSave({hook, body, tags}),onCancel. Internals:useStatefor each field, shareduseAutoResizehook for textarea sizing, tag-chip input (Enter / comma to add, Backspace-on-empty pops last, × to remove),useMemofor dirty tracking, Save disabled until dirty + non-empty + not saving.frontend/app/drafts/[id]/page.tsx:- New state:
editing,saving,editError. - New imports:
DraftEditor,apiPatch. saveDraft(updates)callsapiPatch<Post>('/posts/{id}', updates), thensetPost(updated)+ exits edit mode; errors render above the editor.- Read-only Hook + Body sections swap for
<DraftEditor>whenediting=true. Visual section + metadata always visible. - New Edit button on the action bar — disabled with a lock-tooltip when
post.status !== 'draft'.
- New state:
backend/tests/api/test_posts.py— new file. 8 tests fornormalize_tags: lowercase + hash strip, empty/whitespace drop, dedupe preserving first order, dedupe-after-hash-strip, cap atMAX_TAGS, truncate atMAX_TAG_LENGTH, skip non-strings, empty-input.- Test suite: 118 passing in 1.91s (up from 110 pre-Chunk-A). TypeScript clean (
npx tsc --noEmit). Router enumeration confirmsPATCH /posts/{post_id}registered. - Browser e2e pending — user-driven check: open a draft → Edit → tweak → Save → page refreshes with the changes. Non-draft post: Edit button visibly disabled.
app/rendering/renderer.py::render_post_carousel(session, *, post_id, template_id?, width?, height?) -> list[GeneratedAsset]— loads Post, lazy-fills Stage-4 extracts if needed (same backfill semantics asrender_post), validates the picked template iscarousel_slide, loops overextracts.carousel_slidesrendering + uploading each, persists N rows in a single commit, returns the list slide-1-first. Logsrenderer.carousel_renderedwith all asset IDs.app/api/posts.py::POST /posts/{id}/render-carousel— new endpoint. Body{template_id?: UUID}(noslide_index— this is the all-slides path). Returnslist[GeneratedAssetRead](newest-slide-first within the carousel, i.e. slide-1-first). ValueError → 400 (covers missing post, missing template, wrong-template-family).frontend/app/drafts/[id]/page.tsx:- New
PostExtractstype wired intoPost. isCarouselRenderderived:trueif selected template's name iscarousel_slideOR Auto-pick on acarousel-category post.renderVisualdispatches betweenapiPost<GeneratedAsset[]>("/posts/{id}/render-carousel", ...)andapiPost<GeneratedAsset>("/posts/{id}/render", ...). Carousel response is prepended as a list; the latest asset stays atassets[0].- Button label adapts: "Render carousel (5 slides)" / "Rendering 5 slides…" / "Re-render carousel (5 slides)" for the carousel path; original "Render" / "Rendering…" / "Re-render" for single-image.
- New
- End-to-end verified live: rendered the 5-slide
ai_automationpost viarender_post_carouseldirect. 5 Supabase Storage POSTs all returned 200; one DB commit at the end; row-count delta = 5; total wall-clock ≈ 6 s for 5 slides. New routePOST /api/v1/posts/{post_id}/render-carouselconfirmed registered alongside the existing/render. - Tests: no changes needed (orchestration is verified by end-to-end smoke, not unit tests). Full suite still 110 passing in 1.78s.
requirements.txt—supabase==2.13.0added; pulls instorage3==0.11.3,gotrue==2.12.4,postgrest==0.19.3,realtime==2.30.1,supafunc==0.9.4. Existingpydanticwas bumped from 2.10.4 → 2.13.4 as a transitive — no API changes for our use.app/config.py+backend/.env.example— newSUPABASE_STORAGE_BUCKET: str = "autopost-assets"setting (one new env var; bucket created by user in Supabase dashboard as a public bucket).app/rendering/storage.py— new module. Lazy module-levelClientguarded bythreading.Lock; created fromSUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY. Public surface:async upload_png(path, png_bytes) -> str— uploads PNG, returns public URL, logssupabase_storage.uploadedpublic_url(path) -> str— constructs URL (no network) for the API schema layer
app/rendering/renderer.py— removedASSETS_DIR, removedmkdir+write_bytes, addedfrom app.rendering import playwright_runner, storage, replaced local write withawait storage.upload_png(rel_path, png_bytes). Asset row schema unchanged.app/api/posts.py—GeneratedAssetRead.image_urlnow returnsstorage.public_url(self.image_path); removed the now-unusedfrom app.config import settingsimport.app/main.py— droppedStaticFilesimport,ASSETS_DIRimport,ASSETS_DIR.mkdir(...), and theapp.mount("/static/assets", ...)block. Cleaner main.- End-to-end verified live: rendered the latest draft via the new pipeline (boot → Stage 4 cached → Playwright HTML→PNG → upload to Supabase). Storage POST returned 200; public URL HEAD returned 200 with
Content-Length: 145777,Content-Type: image/png. CDN is serving frommwgfigntlqmhtzcuslym.supabase.co. - Cleanup:
DELETE FROM generated_assets(17 stale rows) +rm -rf backend/storage/assets(2.2 MB). No data we cared about — pure smoke artifacts. - Test suite still 110 passing in 1.80s. No test changes needed; the pure-Jinja2 + context-shape tests don't touch storage, and
render_post()orchestration is verified by end-to-end smoke rather than unit tests.
alembic/versions/e9f3c5b81d72_add_extracts_to_posts.py— addsposts.extracts JSONB NULL. Applied to Supabase.app/models/post.py—extracts: Mapped[dict[str, Any] | None]mapped column.app/intelligence/generator.py:- New constants:
VISUAL_EXTRACT_TEMPERATURE=0.2,VISUAL_EXTRACT_MAX_TOKENS=600,MAX_CAROUSEL_SLIDES=5. - New
_VISUAL_EXTRACT_SYSTEMprompt (strict JSON output, "do NOT invent numbers", "each slide ≤14 words", "self-contained"). - New
_parse_visual_extract(raw) -> dict(tolerant: bad JSON / non-dict / missing fields / bare-string slides / orphaned metric_value). - New
_str_or_none()helper. - New
empty_extracts()public helper (canonical empty shape). - New
extract_visual_fields(*, voiced_text, prompt_id?, post_id?)— public Stage 4 entry; used by bothrun_pipeline()andrender_post()lazy backfill. run_pipeline()runs all 4 stages and returns(hook, body, tags, extracts, violations).generate_draft()persistsextractson the new Post.
- New constants:
app/rendering/renderer.py:- Imports
extract_visual_fieldsfrom the generator (no circular import — generator doesn't import renderer). default_context_for_post(post, *, slide_index=1)now emitspull_quote / metric_value / metric_label / slide_text / slide_number / slide_totalalongside the existing fields. Hook fallback for every missing extract.default_template_name_for_post()smart fallback: auto-picksinsight_card(notmetric_card) when the post has no real metric.render_post()acceptsslide_index: int = 1; lazy-fillsextractsif NULL by callingextract_visual_fields(voiced_text=post.body)and persisting before render.
- Imports
- Templates (all keep minimal-dark identity; only the content variable changes):
insight_card.html— unchanged (already uses{{ hook }}, the correct centerpiece).quote_card.html—{{ pull_quote|default(hook) }}in the italic-serif h1.metric_card.html—{% if metric_value %}branch shows value at 160px + label at 30px; fallback shows hook at 64px with|truncate(140).carousel_slide.html—{{ slide_text|default(hook) }}+{{ slide_number }} / {{ slide_total }}indicator + conditional "swipe →" affordance when not last slide.
app/api/posts.py:PostReadexposesextracts: dict | None.RenderRequestgainsslide_index: int = Field(default=1, ge=1, le=20).POST /posts/{id}/renderforwardsslide_indexto the renderer.
tests/intelligence/test_generator.py— 13 new tests:TestRunPipelineupdated (3 sites) for 5-tuple shape + asserts Stage 4 stage label + FAST model +response_format=json_object.- New
TestParseVisualExtract(9 tests): happy-path, bad JSON → empty, non-dict → empty, missing metric_label → drops metric_value, missing metric_value → drops metric_label, caps slides at MAX_CAROUSEL_SLIDES, tolerates bare-string slides, empty pull_quote → None, canonical empty shape. - New
TestExtractVisualFields(1 test): calls FAST model with JSON response_format and correct stage label.
tests/rendering/test_renderer.py— 10 new tests:TestDefaultTemplateNameextended: with-metric → metric_card, without-metric → insight_card (both auto-pick paths), null-metric → insight_card.- New
TestExtractsInContext(7 tests): pull_quote fallback, metric passthrough, slide_text fallback, slide_index picking + clamping above/below. TestRenderHtmlextended: metric_card usestext-[160px]branch when value present, falls back totext-[64px]when null, quote_card uses pull_quote in the centerpiece<h1>, carousel_slide uses slide_text + correct indicator.
- Test suite total: 110 passing in 1.71s (up from 87 pre-Chunk-D, 0 network/DB/Chromium contact).
- Verified end-to-end against a fresh
ai_automationdraft. Stage 4 returned a usable extracts JSON in ~700ms; auto-picker correctly degraded toinsight_cardbecausemetric_valuewas null; carousel rendered 5 distinct slides via 5 separate/rendercalls; lazy backfill path was exercised on the prior (Chunk-B) draft that predated the column.
frontend/app/drafts/[id]/page.tsxextended with a "Visual" section above the Hook section:- Three-fetch parallel mount:
apiGet<Post>('/posts/{id}')+apiGet<GeneratedAsset[]>('/posts/{id}/assets')+apiGet<Template[]>('/templates')viaPromise.all. - New types in-file:
TemplateCategory,Template,GeneratedAsset(image_urlfield comes from the backend'scomputed_field). - Layout: left column = square
<img>preview of the latest asset (or dashed-border placeholder if none); right column = template picker dropdown (default "Auto for category", then 4 named options) + Render/Re-render button + meta line (template name · WxH · created_at) + inline error. - "Open full size →" link beside the meta line opens the underlying URL in a new tab.
- Older renders collapse into a
<details>strip with a thumbnail grid (3 cols mobile, 5 cols sm+).
- Three-fetch parallel mount:
- TypeScript clean (
npx tsc --noEmit). - Reachability: backend three new routes return 403 unauthenticated (auth gating works);
/drafts/{id}page compiles + returns 200; static asset URL serves 200. - Phase 4 complete pending user browser e2e — clicking Render in the browser is the last unverified step.
app/rendering/playwright_runner.py— module-level singleton (_playwright,_browser) plus anasyncio.Lockguarding start/stop.start()is idempotent;stop()is safe ifstart()never ran.html_to_png(html, *, width, height, device_scale_factor=2)opens a throwaway context, sets HTML, waits fornetworkidle(covers the Tailwind CDN response), screenshots, closes context. Browser process reused.app/rendering/templates/— 4 HTML files, all minimal-dark, Tailwind via CDN inline.insight_card.html— centered serif headline, eyebrow + accent line + brand strapquote_card.html— italic serif with oversized“anchormetric_card.html— top-aligned uppercase bold sans display typecarousel_slide.html— slide indicator ({{ slide_number|default(1) }} / {{ slide_total|default(1) }}) + serif headline + "swipe →" affordance when not last slide
app/rendering/renderer.py:TEMPLATES_DIR=backend/app/rendering/templates/,ASSETS_DIR=backend/storage/assets/(both resolved relative to file, CWD-independent)- Jinja2 env with
autoescapeon (XSS-safe — hook with<script>becomes<script>) _CATEGORY_LABELS: dict[PostCategory, str]— human-readable eyebrow strings_DEFAULT_TEMPLATE_BY_CATEGORY: dict[PostCategory, str]— category → template-name fallbackdefault_template_name_for_post(post),default_context_for_post(post)— pure helpersrender_html(name, ctx),render_png(name, ctx, *, width, height)— pure (no DB)_pick_template(session, post, template_id)— explicit-id-or-default lookuprender_post(session, *, post_id, template_id?, width?, height?) -> GeneratedAsset— full orchestration; writes file underASSETS_DIR/{post_id}/{asset_id}.png, persists row, commits, refreshes, returns the row
alembic/versions/d8e2f4a91c56_seed_templates.py— bulk-inserts 4 templates (insight_card / quote_card / metric_card / carousel_slide). Applied to Supabase; verified 4 rows visible.app/api/posts.py— two new endpoints (both auth-gated, error-translated to HTTP 400):POST /api/v1/posts/{post_id}/render(body{template_id?}) →GeneratedAssetReadGET /api/v1/posts/{post_id}/assets→ newest-first list
app/api/templates.py—GET /api/v1/templatesfor the future picker (auth-gated).app/api/__init__.py— templates router mounted.app/main.py—StaticFilesmount at/static/assets→ASSETS_DIR, comment notes dev-only nature. Lifespan now boots/shutsplaywright_runneralongside scheduler.app/config.py+backend/.env.example—BRAND_NAME=Nestwhirrconfigured.tests/rendering/test_renderer.py— 17 tests in 5 classes:TestDefaultTemplateName(3) — every PostCategory maps to an existing template fileTestDefaultContext(4) — required keys, brand setting, category label, None-tags → []TestRenderHtml(5) — each of the 4 templates renders + hook + brand appear + autoescape catches<script>TestRenderPng(1) — monkeypatchedhtml_to_pngreceives the rendered HTML + dimsTestPickTemplate(4) — explicit id, default lookup, missing id → ValueError, missing default → ValueError
- Verified live:
playwright install chromiuminstalled the headless shell (1.1MB ffmpeg + 77.5MB chromium); FastAPI lifespan boot order isscheduler.started → playwright.started → yield → playwright.stopped → scheduler shut down; all 4 new routes (POST /render,GET /assets,GET /templates, plus the static mount) live; end-to-end render of latest founder_insight draft produced a 153KB 1200×1200 PNG via the auto-picked insight_card template. - Test suite total: 87 passing in 1.78s (27 retrieval + 5 article + 7 groq_client + 14 voice + 13 generator + 17 rendering). No network, no DB, no real Chromium in any test.
frontend/app/drafts/page.tsx— protected client component. Status filter (draft / approved / scheduled / published / failed / all), inline "Generate a draft" form with category dropdown (4 generatable categories: founder_insight / engineering_lesson / ai_automation / startup_opinion), green flash banner with hook preview on successful generation, list of post cards with category + status colored badges + hook + hashtags. Empty state nudges toward the inline generate form.frontend/app/drafts/[id]/page.tsx— read-only detail. Hook + body in pre-wrap (preserves the founder-voice line breaks), tags row below a divider, metadata block (source-trend count, post ID). "Generate another" CTA routes to a fresh draft in the same category. Uses Next.js 16'sparams: Promise<{id}>unwrapped viaReact.use().frontend/app/page.tsx— Drafts card replaces "Coming soon" placeholder; "Coming soon" downgraded to point at Phases 4-7.- 3 voice-iteration migrations at
backend/alembic/versions/:f3b8a72c9d41_prompts_v2_reaction_style.py— v1 (autobiography-prone) → v2 (reaction-style anchors + "you READ these, didn't DO them" guardrail)a5d2b9e4f637_prompts_v3_length_sharpness.py— v2 (tweet-shaped 40 words) → v3 (140-220 words, full paragraphs, sharp-takeaway rule)b4e7c1f293a8_prompts_v4_real_anchors.py— v3 (explainer drift) → v4 (real-voice anchors built from Jason Fried / DHH / Sahil Lavingia / Simon Willison's actual positions + "REACT, DON'T EXPLAIN" budget)
voice.pybanned-phrase list expanded from 10 entries to 22, catching everything that slipped through v3 ("is also worth noting", "sparked my interest", "this got me thinking", "i've seen many teams", "one notable aspect", "valuable lesson for anyone", fake-authority prefix variants).- Infrastructure-level length controls.
generator.pyVOICE_MAX_TOKENS800 → 350 enforces a ~220-word ceiling at the model level.article.pyMAX_CHARS8000 → 2500 stops the model from recapitulating long articles. - Verified end-to-end by user in browser across all 4 categories. Voice quality ~65-70% of target; further tuning deferred to Phase 8 with real generation samples.
app/intelligence/voice.py—strip_ai_tells(text)deterministic regex pass (em-dash separator → period, closing-summary paragraph drop, 10 banned phrases stripped case-insensitively, whitespace tidying). Idempotent. Returns(cleaned, violations)so callers can decide whether to escalate.polish_voice(text)optional second LLM pass onGROQ_MODEL_FAST— called explicitly, not auto-triggered.alembic/versions/c7a4f1e8b2d3_seed_prompts.py— data migration inserting 4 active v1 prompts (founder_insight,engineering_lesson,ai_automation,startup_opinion). Each body ~2K chars: signature pattern + 3 anchor examples (content-distinct from any real trend) + strict voice rules. Applied to Supabase; all 4 rows present.app/intelligence/generator.py— 3-stage pipeline.run_pipeline()is pure LLM (no DB): Stage 1 angle (primary, T=0.85) → Stage 2 voice rewrite (primary, T=0.7) →strip_ai_tells→ Stage 3 extract (FAST, T=0, json_object response_format) →_parse_extractwith bad-JSON fallback.generate_draft()wraps it: load active prompt for category, pick up to 3 trends (caller-supplied IDs preserve order, otherwise unconsumed by score+freshness), enrich witharticle.fetch_articlein parallel, persistPost(status=draft), mark source trendsconsumed_at.app/api/posts.py— three endpoints mounted at/api/v1/posts(all auth-gated):POST /generate(body{category, trend_ids?}) → runs generator, returns the new draft asPostRead.GET ?status=&limit=→ newest-first list (max 200).GET /{post_id}→ fetch one.
app/api/__init__.py— posts router added.- Tests: 27 new tests in
tests/intelligence/(14 voice + 13 generator). Full suite 66 passing in 1.67s (27 retrieval + 5 article + 7 groq_client + 14 voice + 13 generator). No network or DB contact in any test. - Verified live: backend boots, scheduler logs
jobs=['retrieval'], all 3/postsroutes appear in OpenAPI, unauthenticatedGET /api/v1/postsreturns 403.
backend/requirements.txt—groqbumped to1.4.0,trafilatura==2.0.0added.app/intelligence/groq_client.py— singlechat(stage, messages, model?, temperature?, max_tokens?, response_format?, post_id?, prompt_id?)async entry point. Usestenacity.AsyncRetrying(4 attempts, exp backoff 1–10s) restricted toAPIConnectionError+RateLimitError; other errors propagate immediately. Every call (success or failure) writes oneai_generationsrow via_log_generation, which is wrapped in its own try/except so audit failures never break a generation. Token counts + latency captured from the Groq usage payload.app/intelligence/article.py—fetch_article(url) -> str | Nonevia httpx (10s timeout, polite UA, follow redirects) +trafilatura.extract(favor_precision, no comments/tables) executed inasyncio.to_thread. 8K char cap. ReturnsNoneon any failure mode so the Chunk B generator can fall back cleanly to title+summary.backend/tests/intelligence/— 12 tests, all passing in <2s, no network or DB contact.test_groq_client.py(7) — happy path, default model fallback, response_format pass-through, response_format omission, non-retryable error propagates + still audits, retryable error then success (counts attempts + one audit row), missing API key raises.test_article.py(5) — extracts + strips boilerplate, non-200 → None, network error → None, unextractable HTML → None, truncation to MAX_CHARS.
- Verified: full suite is 39 passing in <2s (27 Phase 2 + 12 Chunk A).
- Monorepo directory tree with all subsystem folders + tests
- Four root docs (
CLAUDE.md,CONTEXT.md,CHECKLIST.md,INSTRUCTIONS.md) - Root configs (
.gitignore,README.md);.env.examplelives inbackend/ - Backend (
backend/): Python 3.12 venv,requirements.txt, FastAPI hello-world serving/health+/,app/config.py(pydantic-settings),app/core/logging.py(structlog),app/db.py(SQLAlchemy async engine +Base), Alembic asyncenv.pyconfigured - Frontend (
frontend/): Next.js 16.2.6 + React 19.2 + Tailwind 4 + TypeScript + App Router scaffolded viacreate-next-app..env.local.examplewritten.
Verified: backend boots and serves 200 on /health; frontend boots and serves 200 on /.
- All 10 SQLAlchemy ORM models written under
backend/app/models/, one file per table, exported viaapp/models/__init__.py app/models/base.pydefinesTimestampMixin,uuid_pk(), and 7 enum types (UserRole, AccountKind, TrendSource, PostCategory, PostStatus, TemplateCategory, PublishingStatus)- Alembic autogenerate migration
ba1ce855d31a_initial_schema.pycreated alembic upgrade headapplied successfully to Supabase via Session Pooler- Verified in Supabase: 11 tables (10 app +
alembic_version) and 7 Postgres ENUM types inpublicschema, all empty
app/core/security.py— Fernetencrypt/decrypt+ TTL-aware variant. APP_SECRET_KEY enforced as valid Fernet key.app/core/auth.py— Supabase JWT verification via JWKS. PyJWKClient fetches and caches{SUPABASE_URL}/auth/v1/.well-known/jwks.json(TTL 1h) and verifies tokens with ES256/RS256/EdDSA depending on the project's signing key. Verifies signature, expiry, andaud="authenticated". Optional HS256 fallback viaSUPABASE_JWT_SECRETfor legacy projects only. JIT-provisions the localusersrow on first call usingsubandemailclaims.app/publishing/oauth.py— LinkedIn OAuth 2.0 + OIDC client. State CSRF protection via Fernet-encrypted{user_id, nonce, timestamp}with 10-min TTL. Authorize URL builder + code exchange + refresh + userinfo fetch.app/api/auth_linkedin.py— three endpoints under/api/v1/auth/linkedin/:GET /start(auth required) → returns{authorize_url}for the frontend to redirect toGET /callback?code&state(public, LinkedIn redirect target) → exchanges code, fetches userinfo, upsertslinkedin_accountsrow withaccount_kind='personal', encrypted tokens; redirects to frontend with?linkedin=connectedor?linkedin=error&message=...GET /status(auth required) → returns connection state for the dashboard
- API router mounted at
/api/v1fromapp/api/__init__.py - Verified: backend boots; OpenAPI shows the 3 new paths; unauthenticated
/startreturns 403; Fernet + state round-trip + tamper-detection all pass with the real APP_SECRET_KEY
frontend/lib/supabase.ts— Supabase JS client (anon key, session persistence, URL hash detection for magic-link redirect)frontend/lib/api.ts—apiGet/apiPostwrappers that attach the current Supabase access token as a Bearer header;ApiErrortyped exceptionfrontend/components/auth-provider.tsx— React context with session +signOut, subscribes tosupabase.auth.onAuthStateChangefrontend/app/login/page.tsx— magic-link form usingsignInWithOtp, redirects to/once session arrivesfrontend/app/page.tsx— protected dashboard wrapped in<Suspense>(foruseSearchParams). Shows email, sign-out, LinkedIn connection card with status / Connect button, and success/error banner based on?linkedin=query paramfrontend/app/layout.tsx— wraps the tree inAuthProvider, setsbg-black text-zinc-100, sets metadata
Magic link sign-in → dashboard → click Connect LinkedIn → LinkedIn consent → callback → linkedin_accounts row populated in Supabase with account_kind='personal', encrypted tokens, member_sub, display_name, expires_at, scopes.
app/api/trends.py—PATCH /api/v1/trends/{trend_id}added (consume toggle). Body:TrendPatch(consumed: bool). Auth-gated like the other trends routes.frontend/lib/api.ts—apiPatch<T>(path, body)helper added (Bearer auth + JSON Content-Type, error wrapping).frontend/app/trends/page.tsx— protected client component. Two filter dropdowns (status, source), refresh button with summary banner, per-row consume toggle with optimistic UI. Source-coded badges (rss=blue, hackernews=orange, reddit=red). Empty state nudges the user toward "Refresh now."frontend/app/page.tsx— dashboard now has a "Trends" card linking to/trends(replacing the old "Coming soon" placeholder for trends).backend/pyproject.toml— pytest config (asyncio_mode=auto,asyncio_default_fixture_loop_scope=function,testpaths=["tests"]).backend/tests/retrieval/— three test files, 27 tests, all passing in <0.4s, no network or DB contact (httpx.MockTransport for HTTP stubs).test_dedupe.py— URL normalization, hash equivalence, title-similarity behavior, dedupe order + cross-source collision handling, configurable thresholdtest_rss.py— happy-path parse, age cutoff, per-feed cap, 304 not-modified, one-feed-failing-doesn't-break-batchtest_hackernews.py— top + best dedup, skip dead/deleted/non-story, fallback URL for text-post stories, score capture
- Verified end-to-end: backend boots with scheduler started + 3 trends routes in OpenAPI; frontend builds clean;
/and/trendsboth serve 200; auth gate returns 403 unauthenticated; user manually verified the UI works (refresh, filter, consume toggle).
app/retrieval/orchestrator.py—run_retrieval()calls enabled fetchers in source-priority order (RSS → HN), runs the batch throughdedupe(), then bulk-inserts viaINSERT ... ON CONFLICT DO NOTHING ... RETURNING id. Reddit fetcher import left commented out pending Data API approval. Returns{fetched, deduped, inserted, per_source}summary.app/scheduler/scheduler.py— singletonAsyncIOSchedulerwithSQLAlchemyJobStore(sync URL coerced from the async one).start_scheduler()/shutdown_scheduler()called fromapp/main.py::lifespan. Tables auto-created in Supabasepublicon first run (apscheduler_jobs).app/scheduler/jobs.py—retrieval_job()coroutine +register_recurring_jobs()that wires it toCronTrigger.from_crontab(settings.RETRIEVAL_CRON). Job ID"retrieval", replace_existing, max_instances=1, coalesce=True, misfire_grace_time=600s.app/main.py::lifespan— now boots/shuts the scheduler around the app lifecycle. Startup log shows registered job IDs + timezone.app/api/trends.py— two endpoints, both auth-gated viaget_current_user:POST /api/v1/trends/refresh→ runs the orchestrator synchronously, returnsRefreshResponse(fetched/deduped/inserted/per_source).GET /api/v1/trends?status={unconsumed|consumed|all}&source={rss|hackernews|reddit}&limit=N→ newest-first list, returnslist[TrendRead].
- Verified end-to-end: orchestrator runs cleanly against real feeds → 90 RSS items (5 feeds × ~18 avg, post-cap) + 45 HN items (top30 + best20 dedup'd) → 135 items, 135 deduped (no cross-source collisions in this batch), 0 inserted on second run (DB dedup working). Backend boots; lifespan logs scheduler-started with
jobs=['retrieval']; both endpoints return 403 unauthenticated and appear in OpenAPI. - Known follow-up: the first run (before the per-feed cap landed) flooded the
trendstable with ~1,837 rows including ancient backlog. They're harmless (will be left unconsumed) but pollute Phase 3 trend selection. Safe toTRUNCATE trendsbefore Phase 3 to start from a clean slate.
app/retrieval/base.py—FetcherABC +TrendItemdataclass. Subclasses setsource: TrendSourceand implementasync fetch() -> list[TrendItem]. Sources never touch the DB.app/retrieval/rss.py—RSSFetcherusinghttpx(async fetch) +feedparser(parse, run inasyncio.to_thread). PoliteUser-Agent, in-memory per-feed ETag / Last-Modified cache, 6 feeds fetched in parallel viaasyncio.gather. Locked list lives inDEFAULT_FEEDS.app/retrieval/hackernews.py—HackerNewsFetcheragainsthacker-news.firebaseio.com/v0. Pulls top 30 + best 20 IDs, dedups by ID, fetches each item in parallel. Skipsdead/deleteditems and non-storytypes. Falls back tonews.ycombinator.com/item?id=…when item has no external URL.app/retrieval/reddit.py—RedditFetchervia PRAW inasyncio.to_thread. Written but DISABLED — to be left out of the Chunk B orchestrator import until Reddit Data API access is approved. 8 starter subreddits inDEFAULT_SUBREDDITS.app/retrieval/dedupe.py—dedupe(items)collapses duplicates within a batch using normalized-URL hash + title trigram Jaccard. URL normalization strips utm_*/fbclid/gclid/ref/etc., lowercases host, drops trailing slash.- Verified: all five modules import cleanly; dedupe collapses 3 → 2 in a smoke test (same OpenAI URL with utm + trailing-slash variants vs distinct Stripe URL); URL normalization and title similarity behave as expected.
- Phases 5-8 (dashboard core, publishing+scheduler, analytics, hardening). See
CHECKLIST.md. - Voice prompt v5 (sample-driven, anchored on actual user-liked drafts) is queued for Phase 8 Hardening — needs real generation history first.
- Frontend UI for picking carousel slide N — Chunk C ships with template picker but always renders slide 1 for carousel. Phase 5 (editor) is the natural home: a prev/next or 1/2/3 slide selector beneath the preview. Backend already accepts
slide_index.
| Layer | Choice | Version |
|---|---|---|
| Python | 3.12.12 | backend/.python-version |
| FastAPI | 0.115.6 | |
| SQLAlchemy | 2.0.36 (async) | |
| Alembic | 1.14.0 | |
| psycopg | 3.2.3 (binary) | |
| asyncpg | 0.30.0 | used by Alembic async migrations |
| APScheduler | 3.11.0 | |
| Groq SDK | 1.4.0 | bumped from 0.15.0 at Phase 3 Chunk A |
| trafilatura | 2.0.0 | article body extraction |
| Playwright | 1.49.1 | chromium (installed via playwright install) |
| structlog | 24.4.0 | |
| cryptography | 44.0.0 | Fernet for token encryption |
| Next.js | 16.2.6 | App Router |
| React | 19.2.4 | |
| TailwindCSS | 4.x | PostCSS-based (no tailwind.config.js) |
| TypeScript | ^5 |
- No company-page posting in MVP — posts go to the user's personal account (which is branded with the company name). Re-evaluate after registering a legal entity + Community Management API approval; see Decisions / 2026-05-28 (post-planning).
- Single LinkedIn dev app and single connected member account at MVP (schema supports multiple connections per user via
account_kind). - No retry policy on retrieval failures yet (will add in Phase 2).
- No image diffusion fallback (deferred — HTML/Playwright handles all MVP visuals).
- Local-only — not yet deployed.
- Why APScheduler in-process, not Celery? APScheduler with a Postgres jobstore satisfies "scheduled publish" without adding a broker, worker process, or new container. The brief explicitly forbids overengineering and Celery here would be exactly that. Migrate later if job volume demands it.
- Why HTML/Playwright, not Stable Diffusion? Brand consistency. Diffusion outputs drift visually post-to-post; templates render identical typography, spacing, and color palette every time. Also: free, fast, deterministic.
- Why Groq, not OpenAI/Anthropic? Free tier covers expected MVP volume. Inference speed is excellent for high-frequency automation. Llama-3.3-70b is strong enough for founder-voice content with good prompting.
- Why Supabase, not self-hosted Postgres? Managed backups, auth-as-a-service, and free tier remove infra work that doesn't differentiate the product.
- Why subsystems isolated as folders, not microservices? Conway's law in reverse — folder boundaries enforce loose coupling without operational cost. If a subsystem needs to become a service later, the boundary already exists.