Skip to content

Latest commit

 

History

History
995 lines (848 loc) · 246 KB

CONTEXT.md

File metadata and controls

995 lines (848 loc) · 246 KB

CONTEXT.md — Persistent Engineering Memory

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).


What This Project Is

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.


High-Level Architecture

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

Decisions Made

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 table trend_consumptions(trend_id, workspace_id, consumed_at) — composite PK, both FKs ondelete=CASCADE. New model app/models/trend_consumption.py. Migration 2f27bf499527 creates the table and drops trends.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:
    1. 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. Old POST /trends/bulk-delete (id-list hard-delete) is gone.
    2. 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_job renamed to trend_retention_job: deletes any Trend row where fetched_at is older than settings.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_ids only ever renders as a count).
    3. "Delete a single trend" is now admin-only (require_admin on DELETE /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).
  • Retention window = 7 days, user-confirmed, and it now governs BOTH ends (ingest and cleanup) from one setting (settings.TREND_RETENTION_DAYS, replaces TRENDS_CONSUMED_RETENTION_DAYS=60): (a) rss.py::MAX_ENTRY_AGE — was hardcoded timedelta(days=90), now reads the same setting; (b) hackernews.py gained an age filter it never had before (HN's top/best endpoints can surface an older evergreen story; RSS already had a cutoff but HN had none) — filters on the item's time field. One number, one meaning, ingest and sweep can't drift apart.
  • generator.py picks + marks consumption per-workspace: _pick_trends(session, workspace_id, trend_ids, limit) now LEFT JOINs TrendConsumption scoped to the caller's workspace for the auto-pick path (explicit trend_ids unaffected — those bypass the unconsumed filter entirely, unchanged). generate_draft marks the picked trends consumed via an upsert into trend_consumptions for its workspace_id instead of writing Trend.consumed_at directly.
  • Live-verified end to end against real Supabase (not just mocked): consume/un-consume round-trip, per-workspace isolation (the actual bug), dismiss-all bulk-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.py rewritten (dropped the BulkDeleteRequest schema 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.py gained 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.py one 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 --noEmit clean, next build green (11 routes, unchanged).

2026-08-16 (BrandSwitcher honesty fix + workspace rename — small, user-requested)

  • BrandSwitcher.tsx no 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 real workspaces table — it displayed a brand name with zero data behind it, implying a working multi-brand switcher that doesn't exist. Now reads workspace_name off GET /me via the existing shared useMe() 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 a workspace_members join table + an active-workspace-selection mechanism and is out of scope here.
  • GET /me response gained workspace_name (app/api/me.py): loads the caller's Workspace row by user.workspace_id (a direct session.get, not the lazy User.workspace relationship — accessing that lazily inside an async request would raise MissingGreenlet). 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 beyond user.workspace_id itself (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.ts gained a small pub/sub (setCachedMe) so a rename propagates live to every mounted useMe() 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 --noEmit clean.

2026-07-20 (Phase 14 Chunk I — Docs / housekeeping; Phase 14 closed)

  • 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 sets workspace_id; any id-based fetch filters by the caller's workspace and 404s (not 403s) on mismatch; invited users default editor; the only auth-bypassing routes are POST /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 the SUPABASE_SERVICE_ROLE_KEY requirement, the /admin panel walkthrough (requests queue · users table · direct invite), and the seed-via-curl trick for smoke-testing approve against a real inbox (with the @example.com-rejected caveat). DESIGN.md §2 gained the PostCardMock/login-door artifact note. CHECKLIST.md Phase 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.

2026-07-20 (Phase 14 Chunk H — Marketing landing + request-access form; the public front door)

  • Root route now session-gated. app/page.tsx gained a HomeGate: no session → the public marketing landing, session → Today (unchanged). The old / → /login redirect is gone. AppShell already renders bare (no rail) without a session, so the landing is full-bleed; /login stays the invited-user door.
  • Landing wears 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.tsx posts to the unauthenticated POST /access-requests through a new lib/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 /admin queue (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 --noEmit clean; next build compiles (/ is now static). Live smoke (curl mirroring the form's payload): honeypot submit dropped (no row), real submit created a pending row 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.tsx replicates the rendered quote_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.tsx was rebuilt off the leftover Next.js boilerplate (zinc-*, "AutoPost") into the token/serif identity — a split artifact-panel/form layout reusing PostCardMock, 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.

2026-07-20 (Phase 14 Chunk G — Admin panel frontend; the operator console for the invite funnel)

  • New role-gated /admin route. 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-3xl column, minimal-dark tokens — so it adds a surface without inventing a visual language.
  • Role plumbing: new lib/useMe.ts useMe() hook fetches GET /me once (module-level cache + inflight-dedupe, so the rail, mobile bar, and admin page share a single request). The NavRail gains an admin-only "Admin" entry (shield glyph, below Settings behind a divider) shown only when me.role === "admin"; MobileTopBar mirrors it in the account menu. NavRail was refactored to a shared RailLink sub-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) and UsersTable (role <select> → PATCH, Revoke → DELETE behind a confirm(); self-revoke disabled client-side; last-admin/self guards surfaced from the backend). Both loaders follow the repo's useCallback+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 --noEmit clean; next build compiles /admin (11 routes). The repo-wide react-hooks/set-state-in-effect lint (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.

2026-07-20 (Phase 14 Chunk F — Admin API; the invite/approve/roles control surface)

  • New admin surface at /api/v1/admin/*, every route behind require_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 /admin panel (Chunk G).
  • Approve = Supabase invite + local pre-provision. POST /admin/access-requests/{id}/approve calls supabase.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 local users+workspaces rows 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 always editor — admin is granted only via the role-change endpoint or the bootstrap-admin config. Idempotent: an already-approved request 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 mirroring rendering/storage.py (module client + lock, asyncio.to_thread around the sync SDK), exposing invite_user_by_email(...) -> UUID / SupabaseAdminError. And a shared core/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 → denied with a real reviewed_by FK → 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.

2026-07-20 (Phase 14 Chunk E — Access requests; public intake for the invite-only funnel)

  • New public intake: POST /api/v1/access-requests — the single unauthenticated write in the whole API. A logged-out visitor (marketing landing, Chunk H) submits email + optional name/company/reason; the row lands status=pending for an admin to approve in /admin (Chunk F). Backed by the new access_requests table (app/models/access_request.py, migration 70117d5b9596) with a reviewed_at/reviewed_by audit pair (reviewed_by FK→users.id ondelete=SET NULL so 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 website field; any value ⇒ bot ⇒ silent drop; (2) per-IP fixed-window rate limit — 5/hour via a new app/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 the email-validator dependency (magic-link delivery proves the address anyway); (4) dedupe — a repeat email with an open pending row is a no-op. client_ip honors the first X-Forwarded-For hop.
  • 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→1 pending row, dedupe→still 1; cleaned up.

2026-07-20 (Phase 14 Chunk D — Publisher + account-connect scoping; the tenant loop is closed)

  • Publishing can no longer cross workspaces. All 6 per-platform load_account(session, post) loaders now filter WHERE <Account>.workspace_id == post.workspace_id. The orchestrator already passed post into every loader (the # noqa: ARG001 — parity param), so this was a one-line .where(...) per file; the noqa came off since post is 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_id at connect time. The Bluesky connect endpoint is authenticated (user in scope) so it sets workspace_id=user.workspace_id directly. The other 5 are browser-redirect callbacks that only carry user_id in the OAuth state, so each now does db_user = await session.get(User, user_uuid) after decoding state and sets workspace_id=db_user.workspace_id on the new account row (redirecting ?<platform>=error&message=unknown_user if the user is gone). This restores account-connect, which Chunk B's NOT-NULL column broke.
  • status/disconnect remain user_id-scoped — identical to workspace isolation while it's 1 user/workspace, and the security-relevant boundary is the publish-path load_account (now workspace-scoped). Flagged for the team-workspace future: switch those two to workspace_id so a second member sees shared connections.
  • Verification: 4 publisher load_account unit tests updated to pass a workspace-bearing post (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, called bluesky.load_account with 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).

2026-07-20 (Phase 14 Chunk C — Query scoping; the IDOR pass, posts are now tenant-isolated)

  • Every post-scoped API path now filters by the caller's workspace. New get_current_workspace dependency (core/auth.py) returns user.workspace_id. In posts.py, a single choke point _load_owned_post(session, post_id, workspace_id) does session.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_posts and post_stats add WHERE Post.workspace_id == ws. All 15 endpoints moved from _: User = Depends(get_current_user) to workspace_id: UUID = Depends(get_current_workspace); the now-unused get_current_user/User imports were dropped from posts.py.
  • generate_draft now requires workspace_id (keyword-only) and stamps it on the new Post — this restores the post-generation path that Chunk B's NOT-NULL column broke. The /generate endpoint passes the caller's workspace through. Verified live: a workspace_id-scoped Post insert→commit→delete against real Supabase succeeds.
  • Not yet scoped (Chunk D): the auth_*.py OAuth callbacks still create social-account rows without workspace_id, so connecting an account still fails; and the publisher's _load_personal_account still 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.py covers the IDOR guard directly with a mock session; the full httpx+test-DB integration harness stays the deferred Technical-Debt item).

2026-07-20 (Phase 14 Chunk B — Workspace schema + data wipe; multi-tenancy foundation)

  • workspaces is now the tenant boundary. New Workspace model (id, name, timestamps). users.workspace_id (NOT NULL, FK ondelete=RESTRICT) — one user per workspace today; the seam supports members later without re-migrating scoped tables. workspace_id (NOT NULL, FK ondelete=CASCADE, indexed) added to posts and 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's workspace_id — no own column (they already ondelete=CASCADE off posts). ai_generations left as-is (audit, SET NULL off 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, migration d9a1c4e7f012 truncates posts + 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.py now creates a Workspace (flush → id) before the User so a brand-new bootstrap admin lands in their own workspace. GET /me returns workspace_id (frontend will use it in Chunk G).
  • Interim breakage is expected between B and C: writes now require workspace_id (NOT NULL), but generator.generate_draft and the auth_*.py OAuth 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).

2026-07-19 (Phase 14 Chunk A — Lock the door; invite-only access gate shipped)

  • The tool is now invite-only at the code layer. core/auth.py::get_current_user no longer JIT-provisions every valid token as admin. A valid Supabase token whose email is not already in users and not a configured bootstrap admin now raises AccessDeniedError (403). Bootstrap admins (settings.BOOTSTRAP_ADMIN_EMAILS, comma-separated/case-insensitive, default nestwhirr@gmail.com) are the only self-provisioning path — JIT-created as admin — and self-heal to admin on 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. New require_admin dependency added for the /admin panel (Chunks F–G). User.role model default flipped admin → editor (safe-by-default; Python-side default only, no migration/no server_default).
  • Frontend login is shouldCreateUser: false. login/page.tsx no 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 (new app/api/me.py) returns {id, email, role} for gating the admin UI (Chunk G); workspace_id joins 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 as admin on first magic-link login. The two pre-existing accounts found in users (shivammaheshwari658@gmail.com created 2026-05-28, computer.basics.600@gmail.com created 2026-06-01 — both admin purely from the old hardcoded default) were demoted to editor via 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:false is only the client half); (2) create the nestwhirr@gmail.com auth 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 (provisioned editor on 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_user JIT-creates the users row on first JWT and hardcodes role=admin for everyone; the UserRole enum (admin/editor/viewer) exists but is unused (no route checks it); frontend gate is purely "has a Supabase session" (AppShell renders bare on /login or 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):
    1. Access model = isolated per-user workspaces (NOT a shared team workspace). Real multi-tenancy — chosen over the lighter "everyone works one Nestwhirr page" model.
    2. Gate mechanism = hybrid. Disable Supabase public signup (identities can't self-create) + app-side access_requests table + activate the UserRole enum; approving a request fires Supabase inviteUserByEmail (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).
    3. 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.
    4. Admin panel = dedicated /admin route (admin-only), not a Settings sub-tab.
    5. 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.
    6. Isolation key = workspace_id seam, 1 user per workspace for now. Scope data by workspace_id (each new invited user gets their own workspace); chosen over raw user_id on 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 carry user_id FKs; posts/trends do not.
  • This intentionally lifts the CLAUDE.md "no multi-tenant logic beyond the existing org_urn column" 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_account currently 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 default role=admin must become non-admin (invited users default editor; only the bootstrap user shivam@sacomt.com is admin); (d) the public POST /access-requests endpoint is the only unauthenticated write path — needs a basic spam guard (honeypot + IP rate-limit; captcha deferred); (e) web-verify the current Supabase auth.admin.invite_user_by_email API + 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), sets shivam role=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 /admin frontend → H marketing landing → I docs/housekeeping.

2026-07-13 (Custom-image upload — raw passthrough, a third visual path)

  • 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 (multipart UploadFile): validates the content-type against _UPLOAD_CONTENT_TYPES (png/jpeg/webp/gif), enforces the 5 MB Supabase object cap, reads real width/height, uploads the bytes to the same public bucket under <post_id>/<asset_id>.<ext>, and inserts one GeneratedAsset. From there it flows through publishing identically to a rendered asset — _select_publish_assets picks 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_kind gains a third value 'upload'; generated_assets.template_id is now NULLABLE. A raw upload has no HTML template, so forcing a template_id (via a sentinel template row) would pollute the studio's template picker and misrepresent the row. Made template_id nullable 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. Migration c7f1a4d90e23: ALTER TYPE renderkind ADD VALUE 'upload' inside an autocommit_block (Postgres can't use a same-transaction enum value), then template_id DROP NOT NULL. Downgrade deletes upload rows (they'd violate the restored NOT NULL) and re-tightens; the enum value is left (Postgres can't cleanly DROP one). is_carousel_slide in the orchestrator already handles template_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_dimensions reads 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 by TestImageDimensions (4 cases). Frontend: the cockpit Visual panel gets an "Upload custom image" button (hidden file input, apiUpload multipart helper in lib/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.

2026-07-08 (Phase 11 Chunk F — X / Twitter publisher, full X API v2 integration)

  • X is a full sixth platform, wired through the existing per-platform seams — no new pattern. New Platform.x enum value (migration d7e4a1f9c2b8, added inside an autocommit_block so the follow-up seed can reference it), x_accounts table, XPublisher (registered → orchestrator picks it up with one _load_account branch), x_oauth.py, auth_x.py routes, and 8 seeded X rewrite prompts (migration e1f8b3c6a4d9, one per generatable category). char_limit=280, max_images=4. The X preview reuses the shared MicroblogCard (X is Bluesky-shaped). 379 backend tests (+27 in test_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 — /start generates a code_verifier/S256 code_challenge, carries the verifier inside the encrypted state (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 via offline.access — each refresh returns a NEW access AND refresh token, so x_accounts stores both Fernet-encrypted and _ensure_fresh_token re-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 (multipart media+media_category=tweet_image, well under the 5 MB cap so no chunked INIT/APPEND/FINALIZE) → collect data.id media ids → POST /2/tweets with {text, media:{media_ids}} → 201, data.id = tweet id stored as external_urn. Requires the media.write scope for upload + tweet.write for the post. Published-page link uses https://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_rewrite now, when the first pass is over cap, RE-ASKS the model (up to MAX_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 (migration a2c5e8f1b4d7): 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 (migration b3d6f9a2c5e8): 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 latent KeyError: generator.PLATFORM_CHAR_LIMITS was missing Platform.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 terminal TargetStatus.skipped (enum migration f4a9c2e7b1d6): in publish_target, a _load_account PermanentPublishError for a _is_manual_skip_platform (currently just X) → the leg is marked skipped (not failed), so the fan-out stays green while the operator posts X by hand. _maybe_aggregate_post_status treats skipped as terminal but excludes it from the published/failed decision (all-skipped drafts leave posts.status untouched). Once a real X account is connected, _load_account returns it and X publishes normally — the skip only triggers on a missing account. Frontend: draft cockpit + Published render skipped as a neutral "manual" chip. The generate-and-paste affordances live in the draft cockpit: PlatformPreview has 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 plain download attr is ignored on our cross-origin Supabase CDN URLs).

2026-07-03 (LLM migration — qwen3.6-27b primary / llama-4-scout fast, off deprecated llamas)

  • GROQ_MODEL_PRIMARY → qwen/qwen3.6-27b, GROQ_MODEL_FAST → meta-llama/llama-4-scout-17b-16e-instruct. Groq deprecated llama-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 old 8b-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 against max_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-attaches reasoning_effort by 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 optional reasoning_effort override. Verified end-to-end: full run_pipeline through the real client, all 5 stages clean, no AI-tells, valid Stage-4/5 JSON.

2026-07-01 (AI images upload as JPEG, not PNG)

  • 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_png gained image_format + quality; _render_ai_image renders image_format="jpeg", writes a .jpg key, and uploads content_type="image/jpeg". storage.upload_png → renamed storage.upload_image(path, data, *, content_type="image/png"). Bucket note: if the operator restricted the bucket's allowed MIME types to image/png, they must add image/jpeg (see INSTRUCTIONS.md) or AI uploads 400.

2026-07-01 (AI image — 3-way brand frame, per-render)

  • 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 through render_post(frame=...) → _render_ai_image(frame=...), which maps it via AI_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 to card. Studio shows a 3-way segmented control (Brand card / Brand mark / Full bleed) only when "AI image (Flux)" is selected. (Started as a wrap: bool; generalized to frame the 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 making generated_assets.template_id nullable 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 keeps template_id a valid FK. New frames are just an HTML file + a seed row (f2c9a7d41b60 bare, a3d1e6f92c74 mark) under the existing ai_image category — no enum change. An explicit template_id still overrides the frame pick. The studio's regular template dropdown filters out everything named ai_image_*.

2026-07-01 (Stage 5 AI-image prompt — 70B model, subject/style split, optional brand framing)

  • 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_image appends AI_IMAGE_BRAND_STYLE_SUFFIX (near-black bg, muted palette, low-key light, film grain) to the Stage-5 subject before calling generate_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.

2026-07-01 (Mobile top app bar — rail affordances were unreachable on phones)

  • Fixed a real mobile dead-end. The whole <aside> is hidden md:block and MobileTabBar only 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). New components/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). AppShell renders it and adds pt-14 md:pt-0 to the shell row so the fixed bar doesn't cover content (mirrors the existing pb-20 for the bottom bar). tsc + eslint clean. DESIGN.md §6 mobile row updated.

2026-07-01 (Rail collapse — user-controlled, persisted + hover-peek)

  • The left rail is now user-collapsible, not just breakpoint-driven. Previously AppShell forced 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 with useRail: a persisted collapse pin (localStorage key autopost: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). Lazy useState initializer (no setState-in-effect — the shell only mounts client-side behind a resolved session, so window is safe).
  • Hover-peek overlay. When collapsed, the <aside> reserves only w-16; onMouseEnter sets a peeking flag that renders the rail panel as an absolute opaque w-60 overlay (bg-surface-1 + shadow) so content never shifts under it. A single expanded = !collapsed || peeking flag drives all children (BrandSwitcher/NavRail/TokenHealthPill take collapsed={!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).

2026-06-23 (Phase 13 follow-up 2 — bounded scroll + "Load more" paging)

  • Scroll is now contained, not whole-page. The shell's content column became the single scroll container (AppShell: outer h-screen overflow-hidden, content column overflow-y-auto). Board/list pages (drafts, published, trends, scheduled) restructured to flex h-full flex-col with a shrink-0 header/chrome region + a min-h-0 flex-1 overflow-y-auto body, 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's sticky ActionBar 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: offset query param added to GET /posts and GET /trends (ge=0, default 0). Frontend pages fetch a page (LIST_PAGE/PAGE/TREND_PAGE = 30/30/50), append on "Load more", and infer hasMore from page.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=5 fetches) with the true total from /posts/stats in 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: DraftsRouter picks BoardOverview vs StatusListView off ?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-effect lint remains.

2026-06-23 (Phase 13 follow-up — count aggregates, stop over-fetching for numbers)

  • 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/TrendRead rows (targets eager-loaded) purely to .length them for badges/funnel counts — and Today silently capped at "100+". Added GET /posts/stats (one GROUP BY status + one filtered count → {draft, approved, scheduled, publishing, published, failed, published_7d}) and GET /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 bounded scheduled fetch only to find the next slot). Counts are now exact, not capped.
  • GET /posts takes 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 fetching limit=200 of 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. (Both func imports added; status query param shadows the fastapi status module inside list_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.

2026-06-23 (Phase 13 Chunk I — Settings sub-surfaces; Phase 13 complete)

  • Settings is now tabbed (DESIGN.md §5G): Connections · Prompts · Templates · Voice, with the active tab in ?tab= (Suspense-wrapped for useSearchParams) 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 in app/api/__init__.py): GET /prompts returns the full versioned matrix; POST /prompts/{id}/activate makes a version live by setting is_active=False on every sibling sharing its (name, platform) key, then is_active=True on 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 with IS 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 (the is_active/version columns already exist).
  • Three panels: Prompts.tsx groups versions by (name, platform), highlights the live one, Activate on the rest (optimistic local flip + toast), collapsible body. Templates.tsx is a read-only grid over the existing GET /templates — there is no per-template is_active because 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.tsx is a read-only reference rendering BANNED_PHRASES (imported from lib/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-effect lint remains across the new files.

2026-06-23 (Phase 13 Chunk H — Published)

  • /published now renders the full fan-out, not just LinkedIn (DESIGN.md §5F). Each row reads post.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" with target.error in 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_urn row couldn't express.
  • Per-platform public links via targetUrl(platform, external_urn) (frontend-only, derived from the stored urn): LinkedIn /feed/update/{urn}, Bluesky at://did/coll/rkey → bsky.app/profile/<did>/post/<rkey>, Mastodon's stored uri/url if 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 (only linkedin_urn, no PostTarget rows) 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_social closed 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. now snapshotted once via lazy useState. tsc clean; only the baseline data-fetch set-state-in-effect lint; no backend change.

2026-06-23 (Phase 13 Chunk G — Inbox / trend triage)

  • /trends is 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 keydown listener (ignores form-control focus + modifier keys): j/k/arrows move a cursor (active row tinted + scrollIntoView), e toggle consume, g generate-from-row, ⏎ open the source URL. The cursor is a useState index 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 now Suspense-wrapped (for useSearchParams), reads the pinned trend, shows a dismissible "Generating from this trend" Banner, threads trend_ids:[id] into POST /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.
  • now snapshotted once via useState(() => Date.now()) for pure relative-age formatting. tsc clean; only the project-wide data-fetch set-state-in-effect lint remains; no backend change (the generate endpoint already accepted trend_ids).

2026-06-23 (Phase 13 Chunk F — Calendar)

  • The flat /scheduled list 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_DOT exported from PlatformBadge for this). Dropping a block on a new day reschedules preserving the original time-of-day (only the date moves) — optimistic local update, then POST /posts/{id}/schedule with the new ISO (idempotent replace_existing per target), revert + error banner on failure. Client guards slots earlier than now + 60s (mirrors backend MIN_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=200 once, filters to scheduled+publishing client-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). now snapshotted once via useState(() => Date.now()) for pure past/future comparisons; anchor (the viewed period) is lazy-init state. tsc clean; only the project-wide data-fetch set-state-in-effect lint remains; no backend change.

2026-06-23 (Phase 13 Chunk E — Drafts pipeline board)

  • The flat status-filtered /drafts list 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: publishing rides the Scheduled column (it's in-flight from a schedule), failed rides the Published column rendered red with a Retry button (POST /posts/{id}/retry). All six PostStatus values 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 useMemo over posts computes each card's voice-violation count via the client scanVoiceTells mirror (lib/voice.ts) and sorts newest-first within each column. Card = serif hook (line-clamp-3) + CategoryChip + de-duped PlatformBadge per 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 behind enabled). 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.

2026-06-23 (Phase 13 Chunk D — Today command center)

  • Home is now the work, not the plumbing. app/page.tsx was 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, reusing lib/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=50 fetches, 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 in backend/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 + kit Banner) into components/settings/Connections.tsx, rendered by app/settings/page.tsx behind 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 calling Date.now()/bare new Date() during render, so the only lint residue is the project-wide data-fetch set-state-in-effect baseline. tsc clean; no backend change.

2026-06-23 (Phase 13 Chunk C — Draft Review Cockpit)

  • 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-aware ActionBar. 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.ts mirrors backend/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 deduplicated count matching the backend's len(violations) (em-dash once, summary once, each distinct phrase once) — that count feeds VoiceQualityChip. Keep lib/voice.ts and voice.py phrase 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 via applyPost). PlatformPreview renders 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 (and Approve warns on unsaved edits).
  • PlatformPreview refactored onto the kit, not reinvented. Always-on PlatformTab strip (even single-target) + CharBudgetMeter replace its inline tab buttons and hand-rolled char counter; over-cap shows a Banner pointing 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/ActionButton compose 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 a useToast notification. 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.

2026-06-23 (Phase 13 Chunk B — component kit)

  • One domain vocabulary, one place: frontend/lib/domain.ts. PostStatus/PostCategory/Platform types + CATEGORY_LABEL/GENERATABLE/PLATFORM_LABEL/PLATFORM_LIMITS were 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_LIMITS stays the documented mirror of each backend Publisher.char_limit.
  • Status owns colour; categories go neutral. StatusPill renders 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). CategoryChip is deliberately a neutral hairline chip, not the old rainbow CATEGORY_COLORS map — editorial restraint per DESIGN.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/PlatformTab carry a small platform-colored dot on otherwise-neutral chrome.
  • Consolidation executed on the three list pages; drafts/[id] deferred by design. drafts, scheduled, published now import from lib/domain and render StatusPill/CategoryChip; their local CATEGORY_COLORS/STATUS_COLORS rainbow maps are deleted. drafts/[id]/page.tsx still 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. Banner is the single inline-alert component (4 tones on the semantic tokens) that replaces the ~8 hand-rolled colored <div>s; ToastProvider/useToast render transient Banners and are mounted once by AppShell. ActionBar/ActionButton are 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 in AppShell) is keyboard-first nav + safe actions (generate routes to drafts; "refresh trends" hits the existing manual /trends/refresh endpoint and toasts the result). VoiceQualityChip ships presentational this chunk; the live client-side strip_ai_tells scanner that feeds it is Chunk C's job. tsc clean; no backend change.

2026-06-22 (Phase 13 Chunk A — app shell + design tokens)

  • The studio inherits the artifact's identity, literally. New design system (DESIGN.md is the locked spec) replaces the generic dark card-wall. frontend/app/globals.css is rewritten from the Next.js light/dark + Arial boilerplate into an intentional Tailwind-v4 @theme token 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 via next/font).
  • App shell wraps every authenticated route via the root layout, gated client-side — no route-group file moves. components/shell/AppShell.tsx renders the left rail + content column and is mounted once in app/layout.tsx inside AuthProvider. It renders bare (<>{children}</>) on /login or 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+failed posts — fetched once per shell mount via three parallel apiGets (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). BrandSwitcher is 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 from page.tsx into lib/tokenExpiry.ts (shared source for the pill and the future Today screen); TokenHealthPill fetches /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, session null on server), so the rail is never server-rendered — the hook is safe here. MobileTabBar drops Settings to keep five thumb targets. A /settings stub 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).

2026-06-23 (Scheduled multi-target posts never rolled up to published — cross-session race)

  • Bug: a scheduled post with ≥2 targets published every target on time, but posts.status stayed scheduled forever (and scheduled_at stayed set), so the dashboard showed it as never-published. publish-now was unaffected. Root cause: the scheduler runs one APScheduler one-shot per target, each in its OWN DB session. Post.targets is lazy='selectin', so each job eager-loaded all sibling targets when it fetched the Post — while they were still pending/publishing. _maybe_aggregate_post_status then 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 with execution_options(populate_existing=True) on both the Post and PostTarget selects, and SELECT ... FOR UPDATE the 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 to status=published, published_at=earliest target stamp, scheduled_at=NULL via a one-off idempotent UPDATE (all-terminal posts in scheduled/publishing). Requires a backend restart to load the orchestrator fix for future schedules.

2026-06-23 (Per-platform rewrite moved to the 70B; over-cap banner reworded)

  • Stage-2 per-platform rewrites now run on GROQ_MODEL_PRIMARY (70B), not GROQ_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 both run_platform_rewrite (the DB-matrix path) and the legacy compress_for_bluesky safety 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. PlatformPreview now branches on commentary === 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."

2026-06-17 (Bluesky hashtag facets)

  • Bluesky #tags are promoted to richtext facets at publish time; the other platforms keep plain text. On atproto a #tag in the post text is inert grey text unless it's attached as an app.bsky.richtext.facet#tag facet (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_richtext reparses the generator's commentary through client_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 with résumé). No-tag commentary still sends the plain string (path unchanged); pure-numeric #1 is left as text (invalid tag). PostTarget.raw_request.faceted_tags records 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.)

2026-06-17 (Phase 12 Chunks B–D — categories + fresh feeds)

  • LinkedIn prompt = Stage-1 writer; the other four = Stage-2 rewriters — asymmetric on purpose. A category's LinkedIn row is consumed by run_pipeline as the angle/writer system prompt (input = trends). The Bluesky/Threads/Mastodon/Instagram rows are consumed by run_platform_rewrite as 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 by ALTER TYPE ... ADD VALUE in the same transaction. Alembic runs the whole upgrade in one transaction (env.py wraps run_migrations in a single begin_transaction), so the four post_category additions run inside op.get_context().autocommit_block() — committing them before the seed bulk_insert references 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_bluesky is now a pure safety net — no active category relies on it as the primary path. (technical_humor + carousel PostCategory values still have NO prompts and would raise No active prompt if generated; they're intentionally absent from the UI's GENERATABLE list, so they're latent, not reachable.)

2026-06-16 (Phase 12 Chunk A — per-platform prompt matrix)

  • The canonical Post stays the LinkedIn artifact; per-platform variation lives on PostTarget.commentary, not on Post.body. Stage 3 (extract), Stage 4 (visual fields), Stage 5 (AI image prompt) and the whole rendering layer are LinkedIn-shaped. Re-parametrizing run_pipeline per platform would mean running those LinkedIn-specific stages for Bluesky/Threads (wasted, wrong shape). Instead run_pipeline produces the canonical LinkedIn Post once (loading platform='linkedin', NULL fallback), and each non-LinkedIn target gets a DB-prompt-driven Stage-2 rewrite stored on commentary. 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 new platform arg + the matrix lookup, not on a deep run_pipeline rewrite.
  • run_platform_rewrite (DB-driven) retires the inline compress_for_bluesky stopgap per [[project-per-platform-generation-strategy]]. Each platform's voice/shape prompt is a row in prompts (seeded in Chunk B), so iterating a platform's tone is an is_active flip, not a code deploy, and Instagram's longer 2200-char caption is modeled as a rewrite (not a "compress"). compress_for_bluesky survives only as the no-prompt fallback so the existing 4 categories keep publishing to Bluesky before their full matrix is seeded.
  • _load_platform_prompt has NO NULL fallback (unlike _load_active_prompt). A platform IS NULL prompt 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.

2026-06-16 (Phase 11 Chunk E — per-platform preview + E2E)

  • One PlatformPreview dispatching to card variants, not five standalone previews. The component takes the draft's targets and renders a tab per target platform, dispatching the active tab to a card variant. LinkedIn reuses the existing rich LinkedInPreview verbatim (no rewrite — it's imported and wrapped); the three microblogs (Bluesky/Mastodon/Threads) collapse into one MicroblogCard with a platform-accented handle since their feed shape is near-identical; Instagram is the one structurally different surface (image-first) so it gets InstagramCard. This keeps the "five variants" the checklist asked for without five near-duplicate files.
  • The preview reads target.commentary ?? canonical — exactly the orchestrator's build_commentary fallback. 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 (no clean_body port 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_LIMITS duplicates each backend Publisher.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_platform covers the same risk at the unit layer: two targets (LinkedIn + Bluesky), per-platform publishers, asserting each persists its own distinct external_urn with no cross-contamination. That's the dispatch + persistence guarantee the live test would confirm.

2026-06-16 (Phase 11 Chunk D — Instagram publisher, Meta-gated)

  • 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: authorize https://www.instagram.com/oauth/authorize, code exchange POST https://api.instagram.com/oauth/access_token, long-lived GET https://graph.instagram.com/access_token?grant_type=ig_exchange_token, refresh GET .../refresh_access_token?grant_type=ig_refresh_token, profile GET .../{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_id directly — it's the publish path segment. Unlike Threads (where /me resolves the id), the IG short-lived token response carries user_id alongside access_token. That id is what goes in graph.instagram.com/{ver}/{user_id}/media. We persist it on instagram_accounts.instagram_user_id. The response has shipped two body shapes historically (flat {access_token,user_id} and wrapped {data:[{…}]}); _token_from_body handles both so a Meta format flip doesn't break connect.
  • instagram_accounts has expires_at but NO refresh_token — identical in-place refresh story to Threads ([[#2026-06-16-phase-11-chunk-c-threads-publisher-meta-gated]]). ig_refresh_token returns a fresh 60-day access token; _ensure_fresh_token refreshes proactively inside a 2-day lead; the orchestrator's hasattr(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.publish raises PermanentPublishError on 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 (no media_type); carousel = N× is_carousel_item=true image containers (each polled FINISHED) → parent media_type=CAROUSEL+children. Caption cap 2200, carousel 2–10 items.
  • Container status field is status_code, not status. The poll helper queries ?fields=status_code,status and matches FINISHED/ERROR/EXPIRED on status_code (Threads used status). Caption param is caption (Threads used text). 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_url itself + PLATFORM_INSTAGRAM_ENABLED single-source-of-truth flag + zero orchestrator changes — all identical to Threads. Platform.instagram already existed in the enum (scaffolded Chunk A); wiring was one register-import + one _load_account branch. Scopes live in INSTAGRAM_SCOPES env so the instagram_business_* vs instagram_* 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 legacy id-vs-user_id profile field, token staleness + proactive refresh, the requires-image gate, single-image (asserts absence of media_type + caption param), carousel children assembly, truncate-to-max, container ERROR status_code→permanent, the 7-case error matrix, account loader. 320 total (was 290).

2026-06-16 (Phase 11 Chunk C — Threads publisher, Meta-gated)

  • Single fixed Meta app, LinkedIn-shaped OAuth (not federated). Unlike Mastodon's per-instance dynamic POST /api/v1/apps registration, Threads is one central Meta app: client_id/client_secret live in THREADS_APP_ID/THREADS_APP_SECRET, the state only carries the user id + nonce + ts (no client creds to smuggle). Web-verified endpoints against developers.facebook.com (2026-06-16): authorize https://threads.net/oauth/authorize, code exchange POST https://graph.threads.net/oauth/access_token, long-lived GET .../access_token?grant_type=th_exchange_token, refresh GET .../refresh_access_token?grant_type=th_refresh_token, profile GET .../{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_lived to 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_accounts has expires_at but NO refresh_token. Threads refreshes the long-lived token in place — GET /refresh_access_token returns a fresh access_token, not a separate refresh token. So ThreadsPublisher._ensure_fresh_token refreshes 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 on hasattr(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 its status field until FINISHED (media is processed asynchronously, ERROR/EXPIRED → permanent fail), then publish (POST .../threads_publish with creation_id). Carousels are N× is_carousel_item=true IMAGE containers (each polled to FINISHED) assembled into a parent media_type=CAROUSEL container with comma-joined children. Polling is bounded (15 attempts × 2s) — exhaustion → transient (retry the whole target).
  • Threads fetches image_url itself — no byte download. Unlike LinkedIn (upload bytes to DMS) and Mastodon (POST /api/v2/media multipart), Threads ingests a public URL. We pass storage.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 an OAuthException body for a dead/expired token. The HTTP-status-only taxonomy (401/403→Auth) would misfile that as permanent, so _classify also matches oauthexception / "code":190 in the body → Auth. Otherwise 429/5xx→Transient, other 4xx→Permanent.
  • PLATFORM_THREADS_ENABLED flag, backend is the single source of truth. Default false (Meta App Review gates threads_basic + threads_content_publish — precedent [[project_linkedin_org_posting]]). /auth/threads/start returns 503 when off; /status returns enabled: false. The frontend reads that enabled field to hide BOTH the dashboard connect section AND the /generate platform picker option — no duplicated NEXT_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.threads already 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) + one if platform == Platform.threads branch 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.

2026-06-16 (Phase 11 Chunk B — Mastodon publisher)

  • 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, no mastodon_apps table. The callback needs client_id/client_secret again for the token exchange. Rather than persist a per-instance app table, they're carried inside the Fernet-encrypted, TTL-checked state (mastodon_oauth.encode_state / decode_state) and discarded after callback. Keeps the schema to one table (mastodon_accounts).
  • mastodon_accounts has no expires_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 on hasattr(account, "refresh_token") and account.refresh_token, so a 401 just becomes terminal after retries (operator reconnects) — degrades cleanly with no special-casing. Migration e2a7c4b96d18, no backfill.
  • Raw httpx publisher, no SDK (publishing/mastodon.py), consistent with linkedin_client. Two-step media flow: POST /api/v2/media (multipart) per image → handle 200-sync vs 202-async (poll GET /api/v1/media/{id} until url non-null, bounded by MEDIA_POLL_ATTEMPTS), then POST /api/v1/statuses with media_ids[] + an Idempotency-Key header 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_account branch — 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 get commentary=None → orchestrator falls back to the canonical LinkedIn-shaped Post.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 in test_mastodon_publisher.py: normalize_instance, state round-trip, register_app/exchange_code/verify_credentials, media sync+async-poll, carousel truncation, error classification, account loader).

2026-06-16 (Phase 11 Chunk A iteration — pre-generation platform picking + per-target commentary)

  • 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 /drafts generate form. /generate body now accepts platforms: [Platform] (default [linkedin]). Approve becomes a pure status flip; legacy targets field on the approve body is kept as a back-fill path so curl-against-old-shape doesn't 409.
  • PostTarget.commentary carries per-platform rewrites (nullable TEXT, migration d4f6b8e1029c). NULL = canonical, orchestrator falls back to Post.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 with None so they pick up the canonical body until their publishers land.
  • Stage 2.5: compress_for_bluesky runs 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_SYSTEM to 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_commentary per target. LinkedIn targets keep commentary=None (orchestrator falls back to Post.body which is LinkedIn-shaped — correct); Bluesky targets always get compressed text on commentary. _canonical_platform utility 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 by generate_draft.
  • Approve body kept legacy-tolerant. ApproveRequest.targets is now default=[] (was min_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 empty post.error. New: 502-on-all-failed unchanged; 200 with post.error = "<platform>: <error> | ..." on partial. The dashboard's failed-banner now surfaces per-target errors via the post's targets[] array AND falls back to post.error for 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.platform matrix 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.

2026-06-15 (Phase 11 Chunk A — Multi-platform abstraction + Bluesky publisher)

  • posts.status caps at scheduled; per-platform delivery moves to post_targets. Editorial lifecycle (draft → approved → scheduled) stays on posts. Each PostTarget row carries its own pending → publishing → published|failed. posts.status aggregates back to published (≥1 target succeeded, all settled) or failed (all failed) when every target terminates. Aggregation runs inside publish_target after 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).
  • Publisher ABC + registry, one impl per platform. New publishing/base.py defines the abstract Publisher.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-only publisher.py deleted.
  • 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/connect form, no redirect dance.
  • atproto==0.0.62 pinned 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.login runs fresh per publish (in a thread via asyncio.to_thread since 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 AtProtocolError for everything. Until typed errors land, BlueskyPublisher.publish matches 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 Publisher exposes a char_limit class constant; orchestrator builds commentary and fails the target fast with a PermanentPublishError ("commentary {len} chars exceeds {platform} limit of {limit}") before calling publish(). 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_images is 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.targets is lazy='selectin', ordered by created_at. Eager-loaded so PostRead serialization (from_attributes=True) doesn't trip the async lazy-load guard. Same selectin strategy 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 id publish-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=True keeps reschedule idempotent.
  • Legacy posts.linkedin_urn still 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: every published, failed, scheduled post got one post_targets row (platform=linkedin, status carried over, external_urn/published_at/error/scheduled_at preserved). 12 rows in the dev DB. draft/approved posts 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_id is nullable. Existing rows (pre-Phase-11) carry only post_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.py deleted, 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).

2026-06-13 (Phase 10 — AI image generation add-on)

  • 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_kind is a native Postgres enum, server-defaulted to 'template'. Backfills every pre-Phase-10 row to 'template' via server_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 Python RenderKind enum (consistent with PostStatus, 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-schnell returns {result.image: <base64>}; free tier 10k neurons/day ≈ 260 images. Pollinations image.pollinations.ai/prompt/{q}?model=flux is no-auth but had documented Flux outages in 2026 (pollinations#8201) — that's exactly why it's the fallback, not primary. Composer generate_image(prompt, primary?, fallback?) swallows any exception from primary (including CloudflareConfigError for missing creds) → logs ai_image.primary_failed with 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 uses settings.X if param is None else param instead of param or settings.X so tests can force the config-error branch by passing "".
  • Stage 5 (AI-image prompt) lives in the same Post.extracts JSONB, not a new column. Per the Phase 4 Chunk D precedent — JSONB absorbs new fields without a migration. Stage 5 runs in run_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 as extracts["ai_image_prompt"].
  • Brand-safe fallback prompt for Stage 5 failures. When the model returns bad JSON or an empty string, extract_ai_image_prompt returns 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 in render_post: if post.extracts.ai_image_prompt is missing (any draft generated before Phase 10), run Stage 5 inline against post.body and persist onto the JSONB. Subsequent AI renders skip the LLM call. Older drafts upgrade silently — no batch backfill needed.
  • ai_image_wrap template is the only TemplateCategory.ai_image entry, picked by name in the renderer. The category default-pick mapping (PostCategory → template name) intentionally never resolves to ai_image_wrap — picking it requires either the operator selecting "AI image (Flux)" in the UI or calling render_post(kind=ai_image) directly. The frontend dropdown filters ai_image_wrap out 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_kind on GeneratedAssetRead so 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 (mirroring linkedin_client pattern), Stage 5 mocks groq_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.

2026-06-11 (Phase 9 — Lifecycle reversals + trend/draft deletion)

  • Reversals are first-class endpoints, not query-string flags on existing ones. POST /posts/{id}/unapprove and POST /posts/{id}/unschedule mirror the forward verbs (/approve, /schedule). Tried: a single PATCH /posts/{id} with status: "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_id keeps ON 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_assets and publishing_logs correctly CASCADE because they're tied to the post's lifecycle (no point keeping a rendered PNG for a deleted draft, and a publishing_log with no post is just noise).
  • cancel_publish lives inside the unschedule endpoint, not in a model hook. Considered: SQLAlchemy before_update on Post.status that drops the APScheduler job when transitioning out of scheduled. 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 /trends list 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 /trends and /drafts list 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 a confirm() prompt. The rollback on ApiError is 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.

2026-06-02 (Phase 6 Chunk D — End-to-end smoke against real LinkedIn)

  • Live publish-now verified. Draft → approve → render → click Publish now → ~14s later post is live on LinkedIn. URN urn:li:share:7467618890898919424 came back in the x-restli-id header 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 with linkedin_urn + published_at. Single PublishingLog row written with status=success.
  • Live scheduled fire verified. APScheduler job persisted via SQLAlchemyJobStore fired at the exact run_date; the publisher ran end-to-end under its own session (not the FastAPI request session) and the post transitioned scheduled → publishing → published automatically with no operator action. The publish:<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() + 1min via 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 NULL on the linkedin_accounts row. The ensure_fresh_token guard (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 our expires_at column — 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 storing refresh_token if 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_accounts discovered. Both account_kind='personal', distinct user_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's LIMIT 1 ORDER BY created_at DESC picks 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> because account_kind='personal'. Pivoting to account_kind='organization' requires (a) Community Management API approval (legal entity, business email, screencast review, multi-week wait), (b) re-OAuth with w_organization_social scope, (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.

2026-06-01 (Phase 6 Chunk C — Scheduler wiring + endpoints)

  • publish_post_job owns its own DB session. APScheduler runs jobs outside the FastAPI lifespan, so Depends(get_session) doesn't apply. The job opens AsyncSessionLocal() as a context manager and threads it through publish_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 so replace_existing=True on reschedule swaps the trigger atomically, and remove_job(jid) on publish-now can pre-empt the queued one-shot without scanning. APScheduler's SQLAlchemyJobStore persists 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 = 3600 on 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_post inside publish_post_job. app/scheduler/jobs.py is imported at app startup (via register_recurring_jobs), and publish_post drags in httpx, supabase-py, the LinkedInAccount/PublishingLog models — 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-now cancels 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 and publish_post correctly 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_publish returns 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 carries post.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 to status=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 inspect status/error instead 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.

2026-06-01 (Phase 6 Chunk B — Publisher orchestration)

  • 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) returns auth | transient | permanent. auth (401/403): the access token was rejected — force a refresh (set expires_at = now so ensure_fresh_token re-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 to MAX_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 publishing commits before any HTTP work. The lifecycle gate (status in {approved, scheduled}) is checked, then the row commits with status=publishing before 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 in publishing past N minutes — that's a Phase 8 hardening task, not MVP.
  • publishing_logs is append-only, one row per attempt regardless of outcome. Success rows carry linkedin_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. Putting ensure_fresh_token at 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 LinkedInPreview decides which slides are "the current batch" using a 60-second window between consecutive carousel_slide assets. The publisher uses the same window so what the operator approved is what ships. The alternative (a batch_id column on GeneratedAsset) is cleaner but premature — the timestamp heuristic is exact in practice because batches commit in a single transaction with sub-second timestamps. Add batch_id only 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 latest account_kind=personal row. Post has no user_id column today; in the single-user MVP that's fine. Phase 8 (or whenever multi-tenant lands) will add Post.user_id and switch this lookup to Post.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_request persisted on the Post on success. The exact payload that we POSTed to /rest/posts is stored in post.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.
  • PermanentPublishError raised 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 a Post row with status=failed and error=.... Caller (the scheduler job or publish-now endpoint) can branch on post.status without try/except gymnastics for the normal failure paths, only for the unrecoverable pre-flight cases.

2026-06-01 (Phase 6 Chunk A — LinkedIn HTTP client)

  • Pure HTTP module, no DB. app/publishing/linkedin_client.py is the seam where every byte that touches LinkedIn passes through. It knows nothing about Post, GeneratedAsset, or LinkedInAccount. 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 via httpx.MockTransport without any DB fixtures. Same split that worked for groq_client and article in Phase 3.
  • One client surface, three endpoint shapes. Image init (POST /rest/images?action=initializeUpload), binary upload (PUT {uploadUrl} with application/octet-stream), and post create (POST /rest/posts returning URN in the x-restli-id response header). The Posts API replaces the deprecated /v2/ugcPosts and accepts three content shapes: text-only (no content key), single image (content.media.id), and MultiImage (content.multiImage.images[], 2-20 entries). Three matching payload builders, all sharing one _post_envelope so the published shape stays consistent across post types.
  • Web-verified specs before writing code, per CLAUDE.md rule. Microsoft Learn li-lms-2026-05 is 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 (201 for create_post, 200 for initialize, 201 for binary PUT), URN response location (x-restli-id header, not body), and MultiImage organic support (sponsored carousels are a different content.carousel shape 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.carousel payload shape with cards[].landingPage. Organic multi-image posts (what we want for our slide decks) use content.multiImage.images[], max 20, no landing pages. Our PostCategory.carousel resolves 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-id is the only place the URN appears. A 201 body from /rest/posts is empty; the URN comes back as the x-restli-id response header (case-insensitive — httpx normalizes). Surface the shape urn:li:share:NNN (or urn:li:ugcPost:NNN for legacy responses) directly to the caller; the existing frontend linkedinUrlFromUrn() 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 commentary field uses a "little text format" where \<>#~_*[](){}@| are reserved for things like @[Mention](urn:...) and {hashtag|#|x} templates. Plain #hashtag works 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 (mark failed, 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 of oauth.refresh_token and _is_token_stale(expires_at) checks against a 5-min refresh lead. Chunk B adds the DB-side ensure_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-uploads endpoint 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.

2026-06-01 (Phase 5 Chunk C — schedule path + queue pages)

  • One endpoint for both initial schedule and reschedule. POST /posts/{id}/schedule accepts status in {approved, scheduled}, sets scheduled_at, flips to scheduled (no-op on second call). Considered a separate PATCH /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 unschedule endpoint in Chunk C. Reverting scheduled → approved is 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 = 60s server-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 min rounded 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=X always returns created_at DESC per the existing contract. For /scheduled we want next-up-first (scheduled_at ASC), for /published most-recent-first (published_at DESC). Adding a sort= 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 canonical feed/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_at when 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 — initialScheduledAt prop 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.

2026-06-01 (Phase 5 Chunk B — approve flow + LinkedIn preview)

  • Approve is a one-line status transition, not a heavier "review packet" flow. The endpoint loads, gates on status='draft', flips to approved, commits. No reviewer comment, no checklist enforcement, no "approved by user X" audit beyond the existing updated_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 an approvals table — 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_accounts keeps only member_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> with whitespace-pre-wrap, not split on \n and 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.

2026-06-01 (Phase 5 Chunk A — editing path)

  • Editing is gated on status='draft', no exceptions. The CLAUDE.md lifecycle is draft → approved → scheduled → publishing → (published | failed) with no skipping. If editing were allowed on approved, 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 DraftEditor for nice live UX (chip preview matches what'll be saved), but the canonical sanitization runs in app/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.
  • PATCH is 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 via Field(default=None, min_length=1, max_length=512) on hook and min_length=1 on 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.tsx keeps the page tractable and sets the pattern for the upcoming LinkedInPreview (Chunk B) and SchedulePicker (Chunk C) components. Editor receives only what it needs (initial values + saving flag + callbacks) — no coupling to the full Post type or to the API helpers.
  • Auto-resizing textareas via ref + scrollHeight effect, not a CSS-only solution. The pure-CSS field-sizing: content is too new for safe cross-browser use yet, and <input> doesn't support multiline. The ref approach is one shared useAutoResize hook in DraftEditor.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) after apiPatch ensures updated_at and 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.

2026-06-01 (Phase 4 Chunk G — Carousel slides strip)

  • 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/N thumbnail grid.
    • "Earlier renders" (collapsed <details>, existing): only prior render attempts — older single renders, older carousel batches.
  • Front-of-list cluster detection with a 60s window, not explicit state. Considered tracking currentCarouselIds as React state set by the /render-carousel response, but that loses the carousel on page reload (user navigates away, comes back, strip is empty even though the batch is still in assets). Instead, useMemo over assets walks from index 0 forward, grouping consecutive carousel_slide assets whose created_at timestamps 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 > 1 is 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.
  • older slice starts at currentCarousel.length || 1 so slides 2..N never double-list. When no carousel is active, falls back to slice(1) — identical to pre-Chunk-G behavior. The change is purely additive for non-carousel post detail pages.

2026-06-01 (Phase 4 Chunk F — one-click carousel rendering)

  • 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=1 to /render and there was no surface to produce the other 4. New endpoint POST /posts/{id}/render-carousel loops every slide, uploads each, persists N GeneratedAsset rows in a single SQLAlchemy commit. If render_png or storage.upload_png raises 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 /render return GeneratedAssetRead | list[GeneratedAssetRead] based on template type, but that's an ambiguous response shape and forces callers to switch on type. Cleaner to split: /render always returns exactly one asset (single template OR explicit slide of a carousel), /render-carousel always returns the full list. Frontend dispatches based on the selected template's name. This also leaves room to re-render just one bad slide (/render with slide_index=N) without forcing a full carousel re-roll.
  • render_post_carousel validates the template is carousel_slide. Auto-pick on a non-carousel post would resolve to a single-image template (e.g. ai_automation without metric → insight_card via Chunk D's smart fallback), and looping a single-image template across slide indexes 1..N would produce N identical PNGs. The validator raises ValueError → 400. Callers either explicitly pass the carousel_slide template_id or call from a carousel-category post where auto-pick resolves to it.
  • Frontend dispatch is purely view-state, not server round-trip. The isCarouselRender boolean is computed from selectedTemplateId + the in-memory templates list + 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_carousel appends slides 1..N in render order, so assets[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).

2026-05-30 (Phase 4 Chunk E — Supabase Storage migration)

  • Rendered assets moved from local disk to Supabase Storage. backend/storage/assets/ is gone; new PNGs land in the autopost-assets Supabase 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. Local 127.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 via SUPABASE_SERVICE_ROLE_KEY (which bypasses storage RLS — same key already used for the DB).
  • supabase-py (sync) wrapped in asyncio.to_thread(). The official Python SDK is sync. Same pattern we already use for trafilatura in intelligence/article.py. No async-supabase needed; the upload is a single HTTP POST under the hood (no chained awaits to serialize).
  • image_path shape unchanged on GeneratedAsset — still the within-bucket key ({post_id}/{asset_id}.png). The bucket name lives in SUPABASE_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_url is computed in the Pydantic schema, not stored.
  • StaticFiles mount removed from app/main.py. Backend no longer serves images. The dev-only /static/assets route 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-level Client is 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 GeneratedAsset rows + 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. Post table untouched (drafts persist; only the render-history table was reset).
  • Phase 6 setup cost: zero. LinkedIn's /rest/images?action=initializeUpload will accept these public URLs directly. No image-server proxy, no signed-URL refresh logic, no separate bucket policy work.

2026-05-30 (Phase 4 Chunk D — Stage 4 visual extraction)

  • 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 on Post.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 on llama-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_extract accepts: 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 orphaned metric_value without metric_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 runs extract_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 as renderer.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_slides and surfaces it as slide_total; slide_index (1-based) picks the active one. MAX_CAROUSEL_SLIDES = 5 is enforced in the parser to guard against runaway model output. The /render endpoint validates slide_index as 1 ≤ 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() checks extracts.metric_value before auto-picking metric_card; if null, returns insight_card instead. (b) The metric_card fallback typography was tightened from text-[112px] uppercase to text-[64px] sans bold plus |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 in test_generator.py were updated; the change was localized and additive.

2026-05-30 (Phase 4 Chunk C — frontend image preview)

  • Plain <img> for asset preview, not next/image. The PNG is served by the backend at http://127.0.0.1:8000/static/assets/... — cross-origin from the Next.js dev server on localhost:3000. Using next/image would force a remotePatterns allowlist 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-element annotates 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 to next/image once we configure remotePatterns.
  • One parallel fetch on mount via Promise.all for /posts/{id} + /posts/{id}/assets + /templates. Cheaper than three serial round-trips and keeps the loading state monolithic. New types live alongside the existing Post/PostStatus/PostCategory types in the same file — colocated because they're only consumed here.
  • "Auto for category" is the picker default. Backend's _DEFAULT_TEMPLATE_BY_CATEGORY policy is the right answer most of the time, so the dropdown opens to "Auto" and only sends template_id if 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 /render call 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.

2026-05-30 (Phase 4 Chunks A + B — rendering layer)

  • Singleton Chromium process owned by the FastAPI lifespan, not per-request. Booting a fresh Chromium per render costs ~3 seconds. app/rendering/playwright_runner.py holds a module-level _browser started in lifespan and shut down on exit; every html_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.com inside 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 + one templates row, 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_NAME env var (default in app/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() and render_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 in tests/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_CATEGORY lives in renderer.py as 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 explicit template_id on POST /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 a default_template_id column on users or linkedin_accounts.
  • Each /render call creates a new GeneratedAsset row + new file. No upsert, no overwrite. Old assets stay so the user can compare prior renders during voice/visual iteration. Files live at backend/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 in GeneratedAsset.image_path so the dev/prod storage backend is swappable later — switching to S3/R2 only changes the URL prefix, not the column. GeneratedAssetRead.image_url is computed as {APP_BASE_URL}/static/assets/{image_path}.
  • FastAPI StaticFiles mount at /static/assets is 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 and image_url will point at a signed object-storage URL. Comment in app/main.py documents this trade.
  • /api/v1/templates listing 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.

2026-05-28 (planning)

  • 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.

2026-05-30 (Phase 3 Chunk C + voice iteration v2-v4 + length caps)

  • Drafts UI lives at /drafts and /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. Used React.use(params) for Next.js 16's promise-shaped route params — confirmed against the bundled node_modules/next/dist/docs/01-app/01-getting-started/03-layouts-and-pages.md per the frontend AGENTS.md flag.
  • Voice tuning iterated v1 → v2 → v3 → v4 in three migration rounds, no code redeploys. The versioned prompts table is doing the work it was designed to do: every voice change is op.execute("UPDATE … is_active=false") + op.bulk_insert(new_version_rows). Each round fixed a distinct failure mode (see CHECKLIST.md Phase 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.py banned-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_TOKENS 800 → 350 in generator.py to enforce ceiling. Cut MAX_CHARS 8000 → 2500 in article.py so 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 posts history 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 in ai_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.

2026-05-30 (Phase 3 Chunk B)

  • 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 on GROQ_MODEL_FAST (llama-3.1-8b-instant) because it's mechanical pattern-matching with response_format={"type":"json_object"}. Per draft: 2 primary + 1 fast call (~10× cheaper than running everything on primary). The optional polish_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.py mocks groq_client.chat and 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 in generate_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 old is_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 body is already clean. Running it after Stage 3 would mean re-parsing post-JSON text and risking inconsistency between hook/body.
  • Bad-JSON fallback in _parse_extract. If Stage 3 returns non-JSON despite response_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/generate is 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.

2026-05-30 (Phase 3 Chunk A)

  • 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.0 pinned for article extraction. 2.0.0 has been the stable release since Dec 2024 — no major release pending. Used with favor_precision=True, include_comments=False, include_tables=False so output is dense narrative prose, not boilerplate or scraped UI fragments.
  • groq_client.chat() is the single LLM entry point. Every call writes one ai_generations row with stage (pipeline-phase label), full input and output text, 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.AsyncRetrying with 4 attempts and exponential backoff (1–10s), filtered to APIConnectionError and RateLimitError. 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.AsyncClient with 10s total timeout, polite UA, follow redirects. Extraction: trafilatura.extract runs in asyncio.to_thread because it's sync. Any failure (network, non-200, empty extract) returns None — generator falls back to title + summary for 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_generation with a recorder; article tests use httpx.MockTransport like the HN tests. 12 new tests in backend/tests/intelligence/, all passing in <2s.

2026-05-30 (Phase 3 pre-implementation design decisions)

  • 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 trends table small and avoids paywall complications for items we never touch. Will use trafilatura (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.

2026-05-30 (Phase 2 Chunk C)

  • PATCH /api/v1/trends/{id} added with body {consumed: bool} — sets/clears consumed_at. Used by the frontend "Mark consumed / Mark unread" toggle. Phase 3's intelligence layer will also write to consumed_at when it picks a trend for a draft (programmatic, no API needed).
  • Frontend /trends page is the first non-trivial dashboard view: status filter (unconsumed / consumed / all), source filter (rss / hackernews / reddit), manual "Refresh now" → POST /trends/refresh with 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.py covers URL normalization (utm/fbclid stripping, case, trailing slash), title-similarity behavior, and dedupe order-preservation + cross-source collisions. test_rss.py and test_hackernews.py use httpx.MockTransport to 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.toml created for pytest config (asyncio_mode = "auto", asyncio_default_fixture_loop_scope = "function"). First pyproject.toml in the project — keeps test config out of requirements.txt.

2026-05-30 (post-Chunk B, RSS expansion)

  • 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_ids is a UUID[] array, not an FK). Phase 3 will start from a clean unconsumed pool.

2026-05-30 (Phase 2 Chunk B)

  • First Round Review dropped from RSS list — both candidate URLs (/rss and /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) and MAX_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 via app/scheduler/scheduler.py::_sync_url). One job registered: retrieval on cron RETRIEVAL_CRON (default 0 */6 * * *), replace_existing=True, max_instances=1, coalesce=True so 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/refresh and GET /api/v1/trends require a Supabase JWT (same dep as the LinkedIn endpoints). Confirmed: unauthenticated requests return 403.

2026-05-30 (Phase 2 Chunk A)

  • 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. newstories is too noisy for a 6h cron and beststories is 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-process dedupe.py for 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).

2026-05-28 (post-planning, pre-Phase 1)

  • 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, scopes openid profile email w_member_social. The linkedin_accounts.account_kind column 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 .env files, not one root file — backend/.env for backend secrets, frontend/.env.local for NEXT_PUBLIC_* only. Matches both pydantic-settings defaults and Next.js conventions; prevents accidental leak of backend secrets to the browser bundle.
  • LinkedIn endpoint = /rest/posts with LinkedIn-Version: YYYYMM header, not the deprecated /v2/ugcPosts. Pinned via LINKEDIN_API_VERSION env var (currently 202604).

Database Schema

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.


API Contracts

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

Pipeline Definitions

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)

  1. Pick unconsumed trends (filtered by category if requested).
  2. Stage 1 — Groq llama-3.3-70b-versatile: synthesize an opinionated angle.
  3. Stage 2 — voice.py rewrite pass: founder voice, strip AI tells.
  4. Stage 3 — FAST model: extract hook, body, tags as structured JSON.
  5. Stage 4 — FAST model: extract per-template content (pull_quote, metric_value/label, carousel_slides) → post.extracts JSONB.
  6. 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.
  7. Persist post (status=draft) + ai_generations row. Mark source trends consumed_at.

Rendering (backend/app/rendering/renderer.py) Two backends behind one entry: render_post(..., kind: RenderKind = template).

  • kind=template (default) — pick template by post.category or explicit template_id → Jinja2 → Playwright PNG @ 1200×1200 → Supabase upload → generated_assets row (render_kind='template'). Carousel posts go through render_post_carousel for 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 → render ai_image_wrap.html (1200×1200 brand-wrap with 800×800 inset) → Playwright PNG → Supabase upload → generated_assets row (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 per PostTarget. Stable id publish-target:<uuid>. Replaces the pre-Phase-11 publish_post_job(post_id).
  • retrieval_job() — cron (default every 6h)
  • analytics_collection_job() — cron (hourly)
  • cleanup_consumed_trends_job() — cron (default daily 03:30); sweeps trends rows where consumed_at < now() - TRENDS_CONSUMED_RETENTION_DAYS (default 60)

Publishing (backend/app/publishing/) Platform-agnostic orchestrator + one Publisher ABC subclass per platform.

  • base.py — Publisher ABC (platform, char_limit, max_images, supports_documents + async def publish(...)). Registry self-populated via @register decorator. 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 calling publish), per-attempt PublishingLog rows, and aggregation back up to posts.status. Also exposes publish_all_targets(post_id) for the inline publish-now/retry flows.
  • 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_images via asyncio.to_thread since SDK is sync. Heuristic string-match error classification until atproto ships typed errors.

Reusable Abstractions

Day-one abstractions to keep future expansion cheap:

  • Fetcher ABC in backend/app/retrieval/base.py — adding GitHub Trending later = one new file
  • Publisher ABC in backend/app/publishing/ — same interface for LinkedIn now, Twitter/X later
  • Template registry keyed by category — adding a new visual style = drop HTML + DB row
  • Versioned prompts table — prompt iteration without code changes
  • linkedin_accounts.org_urn — schema already supports multiple connected orgs

Environment Variables

Two files (never a root .env):

  • backend/.env — see backend/.env.example for the full list with empty values.
  • frontend/.env.local — see frontend/.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 (default 30 3 * * *)
  • Retention: TRENDS_CONSUMED_RETENTION_DAYS (default 60)
  • AI image (Phase 10): CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_AI_TOKEN; tunable knobs AI_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 in bluesky_accounts. No env vars; per-account, not per-deployment. SDK pin lives in requirements.txt as atproto==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

External APIs Used

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
Reddit 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

Completed Modules

Phase 5 Chunk C — Schedule path + queue pages ✅

  • app/api/posts.py:
    • MIN_SCHEDULE_LEAD_SECONDS = 60 module constant.
    • ScheduleRequest(BaseModel) with scheduled_at: datetime.
    • POST /posts/{post_id}/schedule — loads post → 404 if missing → 409 if status not in {approved, scheduled} → tags naive datetimes as UTC → 400 if within 60s of now → sets scheduled_at + flips to scheduled → commits → returns refreshed PostRead. 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 is now + 15min rounded 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 emits Date(local).toISOString().
  • frontend/app/drafts/[id]/page.tsx:
    • New state: scheduling, schedulePickerOpen, scheduleError.
    • schedulePost(isoUtc) calls apiPost<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'.
  • frontend/app/scheduled/page.tsx — new page. Fetches GET /posts?status=scheduled, sorts ASC by scheduled_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. Fetches GET /posts?status=published, sorts DESC by published_at. Each row links to a LinkedIn post URL constructed via linkedinUrlFromUrn() (null-safe for missing/malformed URNs). Empty state points at /scheduled for 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). /scheduled and /published serve 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.

Phase 5 Chunk B — Approve flow + LinkedIn preview ✅

  • app/api/posts.py::POST /posts/{post_id}/approve — loads post → 404 if missing → 409 if status != 'draft' → flips to PostStatus.approved → commits → returns refreshed PostRead. 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?. Local Avatar helper renders initials in a sky→indigo gradient circle. Body in a <p> with whitespace-pre-wrap break-words to 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() calls apiPost<Post>('/posts/{id}/approve'), swaps page state from the server response.
    • New "LinkedIn preview" section between Visual and Hook. Reuses latest?.image_url and the existing currentCarousel memo (when N>1) to populate the preview. Author name derives from session.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.
  • 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.

Phase 5 Chunk A — Editing path ✅

  • app/api/posts.py:
    • normalize_tags(raw) -> list[str] — module-level helper (lowercase, strip leading #, trim, dedupe order-preserving, cap at MAX_TAGS=6, truncate at MAX_TAG_LENGTH=40, tolerates non-strings).
    • PostUpdate(BaseModel) — partial-update body with hook?: 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 if status != 'draft' (lifecycle gate) → applies non-None updates → commits → returns refreshed PostRead.
  • frontend/components/DraftEditor.tsx — new client component. Props: initialHook, initialBody, initialTags, saving, onSave({hook, body, tags}), onCancel. Internals: useState for each field, shared useAutoResize hook for textarea sizing, tag-chip input (Enter / comma to add, Backspace-on-empty pops last, × to remove), useMemo for 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) calls apiPatch<Post>('/posts/{id}', updates), then setPost(updated) + exits edit mode; errors render above the editor.
    • Read-only Hook + Body sections swap for <DraftEditor> when editing=true. Visual section + metadata always visible.
    • New Edit button on the action bar — disabled with a lock-tooltip when post.status !== 'draft'.
  • backend/tests/api/test_posts.py — new file. 8 tests for normalize_tags: lowercase + hash strip, empty/whitespace drop, dedupe preserving first order, dedupe-after-hash-strip, cap at MAX_TAGS, truncate at MAX_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 confirms PATCH /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.

Phase 4 Chunk F — One-click carousel rendering ✅

  • 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 as render_post), validates the picked template is carousel_slide, loops over extracts.carousel_slides rendering + uploading each, persists N rows in a single commit, returns the list slide-1-first. Logs renderer.carousel_rendered with all asset IDs.
  • app/api/posts.py::POST /posts/{id}/render-carousel — new endpoint. Body {template_id?: UUID} (no slide_index — this is the all-slides path). Returns list[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 PostExtracts type wired into Post.
    • isCarouselRender derived: true if selected template's name is carousel_slide OR Auto-pick on a carousel-category post.
    • renderVisual dispatches between apiPost<GeneratedAsset[]>("/posts/{id}/render-carousel", ...) and apiPost<GeneratedAsset>("/posts/{id}/render", ...). Carousel response is prepended as a list; the latest asset stays at assets[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.
  • End-to-end verified live: rendered the 5-slide ai_automation post via render_post_carousel direct. 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 route POST /api/v1/posts/{post_id}/render-carousel confirmed 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.

Phase 4 Chunk E — Supabase Storage migration ✅

  • requirements.txt — supabase==2.13.0 added; pulls in storage3==0.11.3, gotrue==2.12.4, postgrest==0.19.3, realtime==2.30.1, supafunc==0.9.4. Existing pydantic was bumped from 2.10.4 → 2.13.4 as a transitive — no API changes for our use.
  • app/config.py + backend/.env.example — new SUPABASE_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-level Client guarded by threading.Lock; created from SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY. Public surface:
    • async upload_png(path, png_bytes) -> str — uploads PNG, returns public URL, logs supabase_storage.uploaded
    • public_url(path) -> str — constructs URL (no network) for the API schema layer
  • app/rendering/renderer.py — removed ASSETS_DIR, removed mkdir + write_bytes, added from app.rendering import playwright_runner, storage, replaced local write with await storage.upload_png(rel_path, png_bytes). Asset row schema unchanged.
  • app/api/posts.py — GeneratedAssetRead.image_url now returns storage.public_url(self.image_path); removed the now-unused from app.config import settings import.
  • app/main.py — dropped StaticFiles import, ASSETS_DIR import, ASSETS_DIR.mkdir(...), and the app.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 from mwgfigntlqmhtzcuslym.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.

Phase 4 Chunk D — Stage 4 visual extraction ✅

  • alembic/versions/e9f3c5b81d72_add_extracts_to_posts.py — adds posts.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_SYSTEM prompt (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 both run_pipeline() and render_post() lazy backfill.
    • run_pipeline() runs all 4 stages and returns (hook, body, tags, extracts, violations).
    • generate_draft() persists extracts on the new Post.
  • app/rendering/renderer.py:
    • Imports extract_visual_fields from the generator (no circular import — generator doesn't import renderer).
    • default_context_for_post(post, *, slide_index=1) now emits pull_quote / metric_value / metric_label / slide_text / slide_number / slide_total alongside the existing fields. Hook fallback for every missing extract.
    • default_template_name_for_post() smart fallback: auto-picks insight_card (not metric_card) when the post has no real metric.
    • render_post() accepts slide_index: int = 1; lazy-fills extracts if NULL by calling extract_visual_fields(voiced_text=post.body) and persisting before render.
  • 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:
    • PostRead exposes extracts: dict | None.
    • RenderRequest gains slide_index: int = Field(default=1, ge=1, le=20).
    • POST /posts/{id}/render forwards slide_index to the renderer.
  • tests/intelligence/test_generator.py — 13 new tests:
    • TestRunPipeline updated (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:
    • TestDefaultTemplateName extended: 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.
    • TestRenderHtml extended: metric_card uses text-[160px] branch when value present, falls back to text-[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_automation draft. Stage 4 returned a usable extracts JSON in ~700ms; auto-picker correctly degraded to insight_card because metric_value was null; carousel rendered 5 distinct slides via 5 separate /render calls; lazy backfill path was exercised on the prior (Chunk-B) draft that predated the column.

Phase 4 Chunk C — Frontend image preview ✅

  • frontend/app/drafts/[id]/page.tsx extended with a "Visual" section above the Hook section:
    • Three-fetch parallel mount: apiGet<Post>('/posts/{id}') + apiGet<GeneratedAsset[]>('/posts/{id}/assets') + apiGet<Template[]>('/templates') via Promise.all.
    • New types in-file: TemplateCategory, Template, GeneratedAsset (image_url field comes from the backend's computed_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+).
  • 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.

Phase 4 Chunks A + B — Rendering layer ✅

  • app/rendering/playwright_runner.py — module-level singleton (_playwright, _browser) plus an asyncio.Lock guarding start/stop. start() is idempotent; stop() is safe if start() never ran. html_to_png(html, *, width, height, device_scale_factor=2) opens a throwaway context, sets HTML, waits for networkidle (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 strap
    • quote_card.html — italic serif with oversized &ldquo; anchor
    • metric_card.html — top-aligned uppercase bold sans display type
    • carousel_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 autoescape on (XSS-safe — hook with <script> becomes &lt;script&gt;)
    • _CATEGORY_LABELS: dict[PostCategory, str] — human-readable eyebrow strings
    • _DEFAULT_TEMPLATE_BY_CATEGORY: dict[PostCategory, str] — category → template-name fallback
    • default_template_name_for_post(post), default_context_for_post(post) — pure helpers
    • render_html(name, ctx), render_png(name, ctx, *, width, height) — pure (no DB)
    • _pick_template(session, post, template_id) — explicit-id-or-default lookup
    • render_post(session, *, post_id, template_id?, width?, height?) -> GeneratedAsset — full orchestration; writes file under ASSETS_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?}) → GeneratedAssetRead
    • GET /api/v1/posts/{post_id}/assets → newest-first list
  • app/api/templates.py — GET /api/v1/templates for the future picker (auth-gated).
  • app/api/__init__.py — templates router mounted.
  • app/main.py — StaticFiles mount at /static/assets → ASSETS_DIR, comment notes dev-only nature. Lifespan now boots/shuts playwright_runner alongside scheduler.
  • app/config.py + backend/.env.example — BRAND_NAME=Nestwhirr configured.
  • tests/rendering/test_renderer.py — 17 tests in 5 classes:
    • TestDefaultTemplateName (3) — every PostCategory maps to an existing template file
    • TestDefaultContext (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) — monkeypatched html_to_png receives the rendered HTML + dims
    • TestPickTemplate (4) — explicit id, default lookup, missing id → ValueError, missing default → ValueError
  • Verified live: playwright install chromium installed the headless shell (1.1MB ffmpeg + 77.5MB chromium); FastAPI lifespan boot order is scheduler.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.

Phase 3 Chunk C + voice iteration v2-v4 ✅ (Phase 3 complete)

  • 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's params: Promise<{id}> unwrapped via React.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.py banned-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.py VOICE_MAX_TOKENS 800 → 350 enforces a ~220-word ceiling at the model level. article.py MAX_CHARS 8000 → 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.

Phase 3 Chunk B — Voice + prompts + generator + /posts API ✅

  • 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 on GROQ_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_extract with 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 with article.fetch_article in parallel, persist Post(status=draft), mark source trends consumed_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 as PostRead.
    • 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 /posts routes appear in OpenAPI, unauthenticated GET /api/v1/posts returns 403.

Phase 3 Chunk A — Intelligence infrastructure ✅

  • backend/requirements.txt — groq bumped to 1.4.0, trafilatura==2.0.0 added.
  • app/intelligence/groq_client.py — single chat(stage, messages, model?, temperature?, max_tokens?, response_format?, post_id?, prompt_id?) async entry point. Uses tenacity.AsyncRetrying (4 attempts, exp backoff 1–10s) restricted to APIConnectionError + RateLimitError; other errors propagate immediately. Every call (success or failure) writes one ai_generations row 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 | None via httpx (10s timeout, polite UA, follow redirects) + trafilatura.extract (favor_precision, no comments/tables) executed in asyncio.to_thread. 8K char cap. Returns None on 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).

Phase 0 — Scaffolding ✅

  • 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.example lives in backend/
  • 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 async env.py configured
  • Frontend (frontend/): Next.js 16.2.6 + React 19.2 + Tailwind 4 + TypeScript + App Router scaffolded via create-next-app. .env.local.example written.

Verified: backend boots and serves 200 on /health; frontend boots and serves 200 on /.

Phase 1 partial — Schema ✅

  • All 10 SQLAlchemy ORM models written under backend/app/models/, one file per table, exported via app/models/__init__.py
  • app/models/base.py defines TimestampMixin, uuid_pk(), and 7 enum types (UserRole, AccountKind, TrendSource, PostCategory, PostStatus, TemplateCategory, PublishingStatus)
  • Alembic autogenerate migration ba1ce855d31a_initial_schema.py created
  • alembic upgrade head applied successfully to Supabase via Session Pooler
  • Verified in Supabase: 11 tables (10 app + alembic_version) and 7 Postgres ENUM types in public schema, all empty

Phase 1 — Schema + Auth ✅ (complete, end-to-end verified)

Backend auth

  • app/core/security.py — Fernet encrypt/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, and aud="authenticated". Optional HS256 fallback via SUPABASE_JWT_SECRET for legacy projects only. JIT-provisions the local users row on first call using sub and email claims.
  • 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 to
    • GET /callback?code&state (public, LinkedIn redirect target) → exchanges code, fetches userinfo, upserts linkedin_accounts row with account_kind='personal', encrypted tokens; redirects to frontend with ?linkedin=connected or ?linkedin=error&message=...
    • GET /status (auth required) → returns connection state for the dashboard
  • API router mounted at /api/v1 from app/api/__init__.py
  • Verified: backend boots; OpenAPI shows the 3 new paths; unauthenticated /start returns 403; Fernet + state round-trip + tamper-detection all pass with the real APP_SECRET_KEY

Frontend dashboard + auth

  • frontend/lib/supabase.ts — Supabase JS client (anon key, session persistence, URL hash detection for magic-link redirect)
  • frontend/lib/api.ts — apiGet/apiPost wrappers that attach the current Supabase access token as a Bearer header; ApiError typed exception
  • frontend/components/auth-provider.tsx — React context with session + signOut, subscribes to supabase.auth.onAuthStateChange
  • frontend/app/login/page.tsx — magic-link form using signInWithOtp, redirects to / once session arrives
  • frontend/app/page.tsx — protected dashboard wrapped in <Suspense> (for useSearchParams). Shows email, sign-out, LinkedIn connection card with status / Connect button, and success/error banner based on ?linkedin= query param
  • frontend/app/layout.tsx — wraps the tree in AuthProvider, sets bg-black text-zinc-100, sets metadata

End-to-end verified

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.

Phase 2 Chunk C — Trends UI + tests ✅ (Phase 2 complete)

  • 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 threshold
    • test_rss.py — happy-path parse, age cutoff, per-feed cap, 304 not-modified, one-feed-failing-doesn't-break-batch
    • test_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 /trends both serve 200; auth gate returns 403 unauthenticated; user manually verified the UI works (refresh, filter, consume toggle).

Phase 2 Chunk B — Orchestrator + scheduler + /trends endpoints ✅

  • app/retrieval/orchestrator.py — run_retrieval() calls enabled fetchers in source-priority order (RSS → HN), runs the batch through dedupe(), then bulk-inserts via INSERT ... 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 — singleton AsyncIOScheduler with SQLAlchemyJobStore (sync URL coerced from the async one). start_scheduler() / shutdown_scheduler() called from app/main.py::lifespan. Tables auto-created in Supabase public on first run (apscheduler_jobs).
  • app/scheduler/jobs.py — retrieval_job() coroutine + register_recurring_jobs() that wires it to CronTrigger.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 via get_current_user:
    • POST /api/v1/trends/refresh → runs the orchestrator synchronously, returns RefreshResponse (fetched/deduped/inserted/per_source).
    • GET /api/v1/trends?status={unconsumed|consumed|all}&source={rss|hackernews|reddit}&limit=N → newest-first list, returns list[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 trends table with ~1,837 rows including ancient backlog. They're harmless (will be left unconsumed) but pollute Phase 3 trend selection. Safe to TRUNCATE trends before Phase 3 to start from a clean slate.

Phase 2 Chunk A — Retrieval fetchers + dedupe ✅

  • app/retrieval/base.py — Fetcher ABC + TrendItem dataclass. Subclasses set source: TrendSource and implement async fetch() -> list[TrendItem]. Sources never touch the DB.
  • app/retrieval/rss.py — RSSFetcher using httpx (async fetch) + feedparser (parse, run in asyncio.to_thread). Polite User-Agent, in-memory per-feed ETag / Last-Modified cache, 6 feeds fetched in parallel via asyncio.gather. Locked list lives in DEFAULT_FEEDS.
  • app/retrieval/hackernews.py — HackerNewsFetcher against hacker-news.firebaseio.com/v0. Pulls top 30 + best 20 IDs, dedups by ID, fetches each item in parallel. Skips dead/deleted items and non-story types. Falls back to news.ycombinator.com/item?id=… when item has no external URL.
  • app/retrieval/reddit.py — RedditFetcher via PRAW in asyncio.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 in DEFAULT_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.

Pending Modules

  • 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.

Stack Versions (locked at Phase 0)

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

Known Limitations

  • 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.

Architectural Rationale (key tradeoffs)

  • 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.