Ai-Mee Help Centre
Home
Features
How-To Guides
FAQ
Need Help?
Home
Features
How-To Guides
FAQ
Need Help?

Post generation pipeline review — memory, reliability and scale

Review for #1169. Written against master at the merge of #1193.

The issue asks:

Instead of feeding in raw posts, should we not create a document that updates? Like knowledgebase?

Short answer: that document already exists. client_content_memory has been a per-customer, LLM-maintained markdown document since #1131. The pipeline does not feed raw posts instead of a document — it feeds both, plus two more views of the same rows, and they contradict each other. The memory is duplicated, not missing, so adding documents is the wrong direction.

The wider review turned up three things that outrank the memory design:

  1. A failing entitlement query silently skips every brand and records the job as a success.
  2. The quality gate opens when the critic fails — and the fabricated score it returns is exactly the pass threshold and the modal score, so a fabricated pass is camouflaged by construction.
  3. Throughput is capped near 60 brands/hour, product-wide, by two unset pg-boss options — a constraint to fix before growth rather than an incident at today's 9 active brands.

Parts 1 and 2 cover those. Part 3 answers the memory question. Part 4 is the diagnosis underneath both. Part 5 is a sequenced roadmap.

Everything below is cited to file:line and was verified against the working tree. Claims needing production data were measured against jsdqlwsfmvlfbqfjzzzi on 2026-09-02 and are marked [measured], with the queries and full results in Appendix A.

Read the measurements before scheduling any of this. They moved three findings materially: two of the memory defects are latent rather than live, the throughput ceiling is roughly 3× less severe than the code-only estimate, and a new live bug surfaced that is not in the code-reading above — one of the two approval paths never reaches the content memory at all (§3.2a). The measurements also revealed that the trap in §3.2 is worse than described: fixing that new bug the obvious way would switch on the echo loop.


Part 1 — reliability: gates that do not hold

1.1 A failing entitlement query silently skips the entire fleet

api/src/services/plans.service.ts:360:

if (error) return new Set()

No log, no Sentry, no rethrow. getAutomationAllowedUserIds is the gate the daily dispatcher consults for every customer, so on a transient query failure the dispatcher treats every brand as not-automation-allowed, increments its skipped counter, logs a normal completion line, and withCronJobRun records cron_job_run as success.

Nobody generates anything that day, and nothing anywhere says so. The failure is indistinguishable from "no customer was entitled today".

This is the highest severity-to-cost ratio finding in the review: the fix is to log, alert, and fail the job rather than return an empty set. An empty set is a legitimate value here, which is exactly why it must not double as the error value.

1.2 The quality gate opens when the critic fails, and hides the fact

critiqueContent returns a fabricated score on any failure — api/src/agents/pipeline/critic.ts:193:

return { score: 7, issues: [], critical_failures: [], suggested_actions: [], skipped: true }

And api/src/agents/pipeline/run.ts:161:

const PASS_THRESHOLD = 7

score >= PASS_THRESHOLD is therefore true. A total critique failure passes the gate on pass 0 and the post is persisted with ai_review_score = 7 and ai_review_explanation = 'No major issues detected.' — indistinguishable in the database from a genuine pass. The publish path gates on quality_gate_failed only (api/src/services/schedule.service.ts, api/src/services/content.service.ts), never on critique_skipped, so a never-critiqued post can auto-publish.

Three compounding factors:

  • Two of the fabrications carry no marker at all. critic.ts:158 and critic.ts:181 return the same 7 with skipped: false when the model returns a non-numeric score. Nothing downstream can tell these from a real pass.
  • normalizeReviewScore is a third path to the same value — critic.ts:21, if (!Number.isFinite(score)) return 7.
  • The fabricated scores poison the alarm meant to catch this. api/src/modules/reviewScoreMonitoring.ts alerts on a ≥2-point drop in the rolling 7-day average. Fabricated 7s enter that average and, on any platform currently averaging below 7 — instagram 5.75, email 6.0, facebook 6.2 in .claude/skills/pipeline-health/baseline.json — they raise it. The worse the critic outage, the healthier the metric looks.

A quality gate must fail closed. The sentinel value must sit outside the pass band, or the skip must route to human review.

[measured — inconclusive, and that is itself the finding]. Over 90 days: 225 platform posts, 0 with critique_skipped = true. But the marker is only written on 4 rows, all dated 2026-09-01, all false — the instrumentation is one day old, so this measures nothing about the preceding 90 days. Worse, the two unmarked fabrications (critic.ts:158, :181, which return skipped: false) are invisible to any query by construction.

What the data does show is the shape of the problem. Score distribution over the same 225 posts:

Score2345678910null
Posts21516122011136616
Gate failed11314121910002

111 of 225 posts (49%) score exactly 7 — the pass threshold and the fabricated fallback value. The revise loop halting as soon as a pass reaches 7 is a legitimate mechanism for a spike there, so this is not evidence of fabrication. It is evidence of something arguably worse: a fabricated pass is camouflaged inside the single largest bucket in the dataset, and is undetectable by construction. That is the strongest argument for moving the sentinel out of the pass band — not the current frequency, which cannot be known.

Incidentally, 62 of 225 posts (27.6%) failed the quality gate, and 9 posts scoring 3–6 were sent anyway via human approval override.

1.3 A stale customer note can permanently disable the review-backlog gate

Verified end to end:

  1. The planner reads pending notes oldest-first — api/src/modules/auto-generate-context.ts:936, .order('created_at', { ascending: true }).limit(10).
  2. The generator reads them newest-first — auto-generate-context.ts:226, .order('created_at', { ascending: false }).limit(5).
  3. synthesizeTopic consumes pendingNotes[0] — the newest (:2211) — records it in notes_used (:2218), and only that note is marked converted (:2095).
  4. So while newer notes keep arriving, the oldest is never selected and never converts.
  5. applyUnreviewedBacklogGate returns early — gate disabled — whenever any pending note exists (:1715):
if (hasUrgentCampaign || context.pending_notes.length > 0) return

A starved note therefore permanently disables the safety valve that stops generation piling into an unreviewed backlog. That valve exists in code rather than in the prompt precisely because, per the comment above it, "an LLM decision isn't reliable enough to promise the user generation pauses" — and it is defeated by a note nobody can clear.

The same mismatch has a second effect: the planner's fallback item is built from pending_notes[0] = the oldest note (:1552), while the generator writes about the newest. For any brand with more than one pending note, the plan and the post are about different things.

[measured — latent, not live]. Zero brands currently carry more than one unconverted note, so the starvation condition has not been reached and the backlog gate is not currently disabled by it. The defect is real and verified in code, but it is a landmine rather than an active fault: it fires the moment any brand accumulates a second pending note. Rank it accordingly — fix it before the note feature gets more use, not as an incident.

1.4 Two failure modes that make the score go up

A missing generation brief removes 40% of the rubric. api/src/agents/pipeline/run.ts:360 — a PlannerOutputError is deliberately not sent to Sentry, generationPlan stays null, and generation proceeds. Downstream, critiqueContent receives null and drops the entire PLAN ADHERENCE block, which for long-form is documented at critic.ts:91 as "40% of total score". A post generated without a brief is critiqued without brief adherence and scores higher on what remains. Nothing is written to metadata to record that the brief was missing.

Email is generated blind and then scored against what it never saw. api/src/channels/email.ts calls none of buildPromptContext, formatBrandVoiceSection, formatGenerationPlanBlock, formatGlobalWritingStyleRules, formatRecentPatternsSection or formatPromotionalLinkSection — verified by grep, zero matches. buildPromptContext has exactly two call sites, social.ts:76 and blog.ts:328. So brand voice, the brief, locale guidance, recent-pattern warnings and the link requirement are all dropped for email — yet critiqueContent scores email against brand voice and plan adherence (critic.ts:48, and the short-form PLAN ADHERENCE (secondary — …) block at :81, which email always receives because isShortForm includes it at :65).

Email also ignores previousDraft/revisionIssues, so a revision pass regenerates from scratch rather than revising: critic feedback can never land.

Email's 7-day average is 6.0, second-lowest of any platform in baseline.json. That is a plausible mechanism for a number already being tracked — stated at that confidence, not stronger.

1.5 Degradation produces a post and reports success

runSingleGeneration's catch-all (auto-generate-context.ts:2118) logs 'context enrichment failed, falling back' and re-runs generation with finalPrompt, which at that point is still the planner's one-line seed hint — no brand knowledge, no news, no notes, no dedup. It returns a post id: a success to every caller, and to the cron row.

synthesizeTopic has its own version at :2395, falling back to the literal string 'Create an engaging post for this campaign.'. Both paths still write context_sources describing the context that was gathered, not the fact that none of it reached the model.

The same shape recurs throughout the pipeline: all five per-platform context fetches degrade independently to empty (run.ts:267-312), gatherContext's seven-way allSettled turns every rejection into [], the topic-relevance gate fails open, a failed embedding silently disables topic dedup, and fetchIndustryNews returns null both on error and when the provider isn't Gemini — so on an OpenRouter deployment the news signal is permanently off and indistinguishable from a failure.

Individually each of these is a defensible "don't block a post on a soft signal". Collectively they mean a post can be generated with essentially no context and be reported, stored and measured as a healthy one.

1.6 The health metric goes quiet rather than red

From .claude/skills/pipeline-health/baseline.json, in the operators' own words after the #1175 credential outage:

"ABSENCE OF FAILURES WAS ABSENCE OF EXECUTION" — strategic_plan failed 42 times and every downstream step showed no failures only because it never ran.

strategic_plan is the first step in generation, so when it dies nothing downstream executes, and a window aggregate reads that as "only the planner is broken". The fix generalises well beyond that one incident: compute per-day ok/error before any window aggregate, and treat a step with zero calls today as a red flag rather than a pass.

Two related observability gaps:

  • ~13 LLM call sites are untagged and log as step: 'unknown' — image description, image prompt and image title generation, crawl, forum, site-page, visual identity, email segments, smart-mapper and others. They opt themselves out of the budget-headroom warning and the per-step spend-drift check that already exist. This is the exact failure mode api/src/utils/llm-models.ts documents for the campaign planner, which drifted from 0.55 to 0.72 of its output budget before anyone looked.
  • There is no alert on prompt growth at all — only on output budget. The known bloat vectors are all input-side.

1.7 Nothing in the system can see mode collapse — including the evals

This is why a customer found the tone problem before we did, and it is still true after #1141–#1144.

  • The critic scores one post in isolation.
  • pipeline-health tracks quality score, prompt size, skip codes and delivery. queries.sql has no repetition or memory metric, so style-repetition.ts's thresholds exist but are never trended.
  • The eval harness (evals/, 3 brands × 9 scenarios, pnpm evals) measures per-post structure via validators.ts and the critic score. Nothing measures variety across a set. It is opt-in and not in CI.

The sharpest version of this: the harness's only semantic layer is critiqueContent — the same call that fails open at §1.2. An eval run in which every critique fabricated a 7 would report a flawless average score. The harness also covers nothing in the planner, topic synthesis or the relevance gate, and asserts no cost, latency or prompt-size budget.


Part 2 — scale: the ceiling is two unset options

api/src/bot/cron.ts:501 is the only worker configuration in the system, applied to all 25 workers:

const workerOptions = { pollingIntervalSeconds: env.NODE_ENV === 'development' ? 1 : 60 }

pg-boss defaults batchSize = 1 and localConcurrency = 1 (verified in [email protected]/dist/manager.js:537), and production runs a single api service. Therefore:

  • Exactly one customer's post generation runs at a time, product-wide, with a ~1/minute floor from the poll interval alone.
  • Each customer costs 8–25 LLM calls (best case ~8; realistic bad case ~22–26 with three quality passes, length rewrites, a relevance retry and structured-output fallbacks).

[measured — real, but ~3× less severe than the code-only estimate]. Over 14 days of cron_job_run, a typical sweep is 10 brands in 9–13 minutes, with per-customer generation averaging 6–43 seconds (max 222s). The binding constraint today is therefore the 60-second poll interval, not generation time — the queue drains at roughly 1 brand/minute, i.e. ~60 brands/hour, not the 10–20/hour that per-customer LLM latency alone would imply.

That correction matters for prioritisation: at the current 9 active brands there is no throughput problem, and the ceiling only begins to bite in the low hundreds. This is a constraint to fix before growth, not an incident. The compounding factors below are the more urgent half of this section, because they are correctness problems rather than throughput ones.

(One outlier: 2026-09-01 shows 30 runs over an 11h55m wall clock with 21 degraded — the #1175 credential outage plus manual re-runs, not normal operation.)

Compounding factors:

  • expireInSeconds does not abort the handler. customerPostGenerationQueueOptions (cron.ts:366-371) sets expireInSeconds: 1800, retryLimit: 2, retryDelay: 300. pg-boss marks the job expired and retries; it does not cancel the running async function. So a hung customer blocks the single slot and, at 30 minutes, gets a second copy running concurrently with the first. The singleton key was consumed at send time, and the one-post-per-day cap is a read-then-write with no locking, so duplicate posts are possible.
  • No per-customer time budget, no LLM timeout, no maxRetries cap. api/src/utils/llm-models.ts sets none of them, so LangChain's AsyncCaller default of 6 retries applies to every 429 and 5xx. llmFailedAttemptHandler correctly short-circuits length-limit, abort and most 4xx errors, but not rate limits or timeouts.
  • retryBackoff is not set on customer-post-generation, so all three attempts land inside 10 minutes. During a provider outage all three fail. The sibling post-publish queue does set it.
  • No LLM concurrency limit anywhere. Platform fan-out is an unbounded Promise.allSettled; 8 enabled platforms means 8 concurrent pipelines. Today the single-slot worker accidentally caps this.
  • The dispatcher is a serial O(customers) loop inside a 120-second expiry (cron.ts:1404-1429), with autoGenerateDispatcherQueueOptions setting expireInSeconds: 120 (cron.ts:347-352). It silently truncates the sweep somewhere in the hundreds of customers — customers past the expiry point simply never get dispatched that day.

Sequencing matters more than any individual fix here. Raising worker concurrency first converts the accidental cap into a rate-limit storm that is then amplified 6× by the uncapped retries. The correct order is in Part 5.


Part 3 — the memory question

3.1 The document already exists

client_content_memory (front-end/supabase/migrations/20260712120000_client_content_memory.sql) is a per-customer markdown document — 3,000-char cap, versioned, one row per customer — rewritten nightly by a reasoning-tier LLM pass from a client_content_memory_signal queue (api/src/services/content-memory.service.ts, cron at api/src/bot/cron.ts:3371, 0 3 * * *). It is the same reflect-and-rewrite pattern as its twin client_knowledge_base, which holds brand facts.

It has three fixed sections (content-memory.service.ts:25-28): ## What Works Well, ## Rejected Patterns (with reasons), ## Recently Covered Themes.

3.2 The echo loop #1152 closed is still open in the memory

#1142 named this explicitly and it was never fixed:

Reinforced by client_content_memory's "What Works Well" section, which is consolidated nightly from the same approvals.

The loop:

StepLocation
Approval enqueues a signal, text = "<title>: <prompt>"api/src/services/posts.service.ts:354, api/src/routes/posts.ts:1264
Consolidation prompt maps [approved] → ## What Works Wellcontent-memory.service.ts:19
Nightly rewritecron.ts:3371 → content-memory.service.ts
Read back with an imitation instructionfour sites, below
Output generated → approved → re-enters the queue—

The read sites, three of them saying repeat what works verbatim:

  • api/src/modules/auto-generate-context.ts:2238 — CONTENT LEARNINGS (repeat what works, avoid rejected patterns, don't repeat covered themes):, .slice(0, 900)
  • api/src/agents/generation-planner.ts:114 — identical string
  • api/src/modules/campaign-planner.ts:414 — identical string
  • api/src/agents/pipeline/context-builder.ts:51 — ## Content Learnings (what works, ...)

So the loop runs at three altitudes: campaign planning, topic synthesis, and per-post generation.

One important qualification. The approved signal text is "<title>: <prompt>" — the topic and angle, not the post copy. This loop converges topics; #1152's opener/closer fragment fix addressed sentence structure. They are different failure modes, both real. #1152 closed one half of what #1142 described; this is the other half.

[measured — the loop is latent, because the input never arrives]. 8 documents exist, averaging 554 of the 3,000-char budget (18% full), max version 3. All 8 carry ## What Works Well\nNot enough information yet. — and the reason is decisive: across the entire life of the queue there are 40 signals, every one of them rejected. Not a single approved signal has ever been written. So the echo loop described above is currently latent: the code path exists at all four read sites, but the section it would poison has no input. The memory today is, by accident, exactly the purely-negative document §3.3 recommends it become. See §3.2a — this is a live bug in its own right, and the trap it sets.

3.2a One of the two approval paths never reaches the memory — and fixing it naively arms the loop

Found by measurement, not by reading the code. There are exactly two places that set review_status = 'approved':

  • api/src/routes/posts.ts:1231 — the web review wizard. Does append a content signal (:1264).
  • api/src/services/approval.service.ts:62 (recordManualApproval) — does not. Verified by grep: the file never imports appendContentSignal.

recordManualApproval is called from api/src/bot/tools.ts and api/src/routes/content.ts — the Aimee bot's approval tool. So approvals made through the bot never reach the content memory.

The data says this is not theoretical: 98 platform-post approvals exist, 15 of them since the signal queue began on 2026-08-07, and zero approved signals were written. All 15 had a usable parent title, so the empty-text early return in appendContentSignal does not explain it.

This is the #1126 class of bug recurring on a second path: the comment above routes/posts.ts:1264 says that call was added because it "was the only approval path in the app that skipped it" — but recordManualApproval was skipping it too, and still is.

The trap. The obvious fix — add an appendContentSignal(..., 'approved', ...) call to recordManualApproval — would take the echo loop in §3.2 from latent to live in one line, because ## What Works Well is empty only for want of that input. Land the §3.3 design decision first. If approvals should not feed ## What Works Well at all, the right fix routes bot approvals to the avoid-list rather than replicating the existing signal call.

3.3 Should each section have its own document?

Separate the sections, yes — but the answer is not three documents. Two of the three are not documents at all, and already exist elsewhere in better form. Splitting into three tables would add schema, cron and prompt surface while preserving the duplication that is doing the damage.

SectionWhat it actually isAlready exists asVerdict
## Recently Covered Themesa recency / dedup avoid-listrecentTitles — 20 rows, auto-generate-context.ts:2248 — and the pgvector RPC check_post_similarity — 30 rows, :2254. Both exact, both already in the same prompt.Delete. It is a lossy LLM paraphrase of two exact queries sitting next to it.
## Rejected Patterns (with reasons)brand constraints learned from rejectionsclient_brand_voice rows with source='feedback_learned' from #1144 — 5-rule cap, FIFO eviction, a UI, and user override.Merge into brand voice. Two systems learn from the same post_feedback rows and both inject into the same prompt.
## What Works Welloutcome evidencenowhere — and it is the one that is brokenKeep, but re-ground. The only section that genuinely needs a maintained document.

That collapses memory from three overlapping stores plus four raw injections into three things with non-overlapping jobs:

  1. Constraints — client_brand_voice rules. Capped, evictable, visible to the user, overridable by them.
  2. An avoid-list — derived at query time from customer_posts (titles plus content_embedding). No LLM, no drift, and it decays by construction because it is a windowed query.
  3. Learnings — one small, outcome-grounded document.

This is a net reduction in code and schema, not an addition.

Re-grounding "What Works Well"

Bare approvals are the weakest evidence available: they say acceptable, not worked. And because approvals are drawn from generated output, feeding them back is precisely what closes the loop. The section should be fed only by signals the generator did not author:

  • post_analytics engagement, joined per platform_post_id. The rows already exist and are already read — aggregated to platform level — at auto-generate-context.ts:885-893.
  • Rejection reasons, which are human-authored.
  • edit_instruction signals — a user rewriting a post is the strongest quality signal in the product, and today it only reaches the knowledge base.

Approvals feed the avoid-list, and nothing else.

[measured — actionable]. 8 of 9 active brands have post_analytics rows in the last 30 days, across 766 rows. Engagement grounding is not aspirational; it is available today for essentially the whole customer base. This recommendation can be implemented as written rather than deferred behind a data-collection phase.

3.4 The prompt contradicts itself

Within a single synthesizeTopic call, one approved post reaches the model four times under conflicting instructions:

LineInstruction~chars
:2238repeat what works (via memory)995
:2248Recent post topics to AVOID repeating: — 20 titles, uncapped1,250
:2254Topics semantically close to this seed already exist — up to 301,900
:2295do NOT reuse these shapes or phrasings1,100

That is roughly 5.2k of a ~6.2k-char average topic_synthesis prompt (baseline.json) spent on four views of the same underlying rows, one of which points the opposite way. The model resolves the contradiction arbitrarily.

This is a design defect rather than a bug, and the §3.3 restructure removes it structurally rather than by re-wording any one block.

3.5 Signal amplification

api/src/routes/posts.ts:1264 fires one appendContentSignal per platform-post approval, carrying the same parent title/prompt. The comment above it concedes the behaviour: "one signal per platform-post approval, not deduplicated per parent post". A post approved on four platforms enqueues four identical [approved] bullets, so the nightly consolidation sees the theme four times and weights it accordingly. Multi-platform brands' memory is silently skewed toward whatever they publish most widely.

[measured — real, modest at current volume]. 40 signals across 28 distinct posts. 10 posts carry duplicate signals, averaging 2.2 each, worst case 3. So roughly a third of posts that produce a signal produce more than one, and the consolidation weights those themes 2–3× accordingly. Note this is measured on the rejected path, since no approved signal has ever been written — the same per-platform fan-out applies to both.

3.6 Missing rails, compared with its own twin

client_knowledge_base has these; client_content_memory has none of them:

RailKnowledge baseContent memory
>50% shrink warningknowledge-base.service.ts:293absent
Output-budget override for a full rewriteKB_GENERATION_MAX_TOKENS = 8000 (:27)absent — plain getLLM('reasoning') at content-memory.service.ts:80
API routesroutes/clients.tsnone
Front-end UIClientIntelTab.vuenone

Consolidation also discards its input signals on success (content-memory.service.ts:169), and saveContentMemory overwrites rather than retaining versions — so a bad rewrite is both invisible and unreconstructable.

On the output budget specifically: the 3,000-char document fits comfortably inside the 2,048-token default today, so this is not a live bug. It is a ceiling on the "give each section its own document" idea — the comment at knowledge-base.service.ts:20-26 documents exactly what happens when a maintained document outgrows its rewrite budget, which is another reason to shrink the memory rather than multiply it.

3.7 No decay

The consolidation prompt instructs the model to "Drop stale, low-value, or one-off themes to make room" while giving it no dates and no window; ## Recently Covered Themes never defines "recently". normalizeContentMemoryContent hard-truncates at 3,000 characters mid-string, which can sever a section header.

Contrast the learned brand-voice rules from #1144, which have an explicit cap and FIFO eviction. The §3.3 restructure dissolves this for two of the three sections: a derived avoid-list decays by construction, and brand-voice rules already evict.


Part 4 — the diagnosis underneath: there is no shared context layer

The memory duplication is one instance of a general pattern, and the pattern is the more useful finding.

Three tiers each independently survey the same customer, with their own queries, their own caps and their own framings:

  • Strategic planner — gatherPlanningContext, ~15 queries across 11 numbered steps
  • Topic synthesis — gatherContext, ~10 queries
  • Per-platform pipeline — 5 fetches, once per platform

Per generated post, the same fact is fetched and rendered into a prompt two to four times:

DataInjectionsNote
Brand description3×.slice(0,600) in tier 1, full in both tier-2 prompts
Content memory3×the .slice(0, 900) is duplicated verbatim across two files; the generator takes it uncapped
Site pages4×getRelevantPages is called 1 + N times per run — once per platform
Content type / pillar4×four different wordings
Knowledge base2×planner caps at 800 chars; the generator injects it uncapped
Campaign context2×
Recent post history4 separate querieswindows of 20 / 8 / 15 / 5, three different anti-repetition framings

The consequences are the things the rest of this review keeps running into:

Prompt size is unowned. The generate step averages 16,804 chars and peaks at 27,482 (baseline.json). Everything except the knowledge base sums to roughly 7,000–8,500 chars — so formatKnowledgeBaseSection (context-builder.ts:97-100), the one section with no cap at all, bounded only by the 20,000-char storage limit, is the dominant contributor. The planner truncates the very same document to 800 characters.

The writing rulebook exists in two divergent copies. formatGlobalWritingStyleRules feeds the generator; the social branch of formatPlatformWritingStyleRules feeds the critic. Eight of their bullets are near-identical, maintained separately. This is the #1141 landmine — "both functions must change together" — preserved as an invariant that nothing enforces.

Prompts contradict each other inside a single call. buildPromptContext(content, 'blog') (blog.ts:328) injects context-builder.ts:29 — "Do NOT use markdown formatting … Write plain text only" — into a prompt that states at blog.ts:321: "Use semantic elements: h1, h2, h3, p, ul, ol, li, blockquote, strong, em, table".

Expensive work is computed and discarded. createGenerationPlan is a reasoning-tier call (~8,986 chars in, ~5,629 out — the largest output of any step) run once per platform, producing an 11-field brief. Three fields survive, as metadata.plan_summary. The declared column customer_platform_post.generation_plan exists in 20260518120000_customer_site_page.sql:59 and in front-end/src/types/posts.ts:65, and is never written or read by any code — verified by grep across api/src and front-end/src.

The recommendation: extract a single CustomerContext, assembled once per run and passed down through all three tiers, with an explicit per-prompt budget; give each consumer a view of it with a declared cap; delete the duplicate queries. This is an extraction, not a rewrite, and it is the change that makes everything else cheaper — today per-run database and LLM work is O(platforms) on data that is per-customer.


Part 5 — roadmap

Re-ordered after the measurements. Two items dropped out of the urgent tier (note starvation and the echo loop are latent; the throughput ceiling is ~60 brands/hour, not 10–20), and one moved in that was not in the code reading at all (§3.2a). Two orderings are load-bearing, not cosmetic:

  • Raising worker concurrency before capping LLM retries turns the current accidental throughput cap into a rate-limit storm amplified 6× by the uncapped retry loop.
  • Fixing §3.2a before deciding §3.3 switches the echo loop on. The design decision must come first.

Stage 1 — genuinely live, fix now (days)

  1. §1.1 — make the entitlement query fail loudly instead of returning an empty set. A fleet-wide outage currently reports success. One line, highest severity-to-cost ratio in the review.
  2. §1.2 — fail the quality gate closed. The frequency is unmeasurable today (the marker is one day old, and two fabrication paths are unmarked by construction), and a fabricated 7 hides inside the modal score bucket — so fix the design rather than waiting for evidence that cannot arrive. Move the sentinel out of the pass band, mark the fabrications at critic.ts:158/:181, make the publish path honour critique_skipped, exclude skipped scores from the drift baseline.
  3. §1.6 — per-day ok/error in pipeline-health before any window aggregate; zero calls today is a red flag, not a pass. This is what let the #1175 outage read as healthy.
  4. §3.2a — decide the design (step 8) and then fix the bot approval path accordingly. Do not simply add the missing appendContentSignal call.

Stage 2 — make failure visible (days)

  1. §1.4 — record a missing brief in metadata and stop it inflating the score; give email the context it is scored against, or stop scoring it on context it never received. Email is the second-lowest-scoring platform and is generated blind.
  2. §1.5 — mark the degraded-fallback paths on the post, so a context-free post is not reported as a healthy one.
  3. §1.7 — add a cross-post variety check to the eval harness, reusing style-repetition.ts, and make the harness aware that its judge can fail open. Also re-run A7 once ~30+ social posts exist after 2026-08-31, to close out whether #1150/#1152 worked.
  4. §1.6 — tag the ~13 untagged LLM call sites so they rejoin the existing spend and budget alarms.

Stage 3 — the memory redesign (1–2 weeks)

  1. §3.3 — collapse the three memories: delete ## Recently Covered Themes, merge ## Rejected Patterns into brand voice, re-ground ## What Works Well on outcomes. Analytics coverage is confirmed at 8 of 9 active brands, so the outcome grounding is implementable now.
  2. §3.2 / §3.4 — drop "repeat what works" from all four read sites. They must change together, for the same reason #1141 required the generator and critic to change together.
  3. §3.5 / §3.6 — dedupe signals per parent post; add the shrink guard and a read route.

Doing 9–10 before anyone repairs §3.2a is what keeps the echo loop from ever running.

Stage 4 — latent landmines and scale (before growth)

  1. §1.3 — one shared notes accessor with a single defined ordering. Latent today (no brand has >1 pending note), fires as soon as one does.
  2. Add an LLM timeout, a maxRetries cap, a concurrency semaphore around the platform fan-out, retryBackoff, and a per-customer time budget — so a hung customer cannot both block the slot and spawn a concurrent duplicate.
  3. Only then raise batchSize / localConcurrency, and batch the dispatcher loop. Not urgent at 9 brands; needed in the low hundreds.
  4. Part 4 — extract the shared CustomerContext; cap the knowledge base at the generator; delete duplicate queries. Consolidate the two copies of the writing rulebook; fix the blog markdown/HTML contradiction; either use the generation brief or stop computing 8 of its 11 fields.

Stage 4's last item makes each post cheaper and faster, which raises throughput on top of the concurrency work.


Appendix A — production measurements

Run against jsdqlwsfmvlfbqfjzzzi on 2026-09-02, read-only, via the Supabase MCP. Summary of what each returned; the queries themselves are reproducible from the snippets in each section above.

#QuestionResult
A1How often does the critic skip?Inconclusive. 225 posts/90d, 0 skipped — but the marker only exists on 4 rows, all 2026-09-01, all false. Instrumentation is one day old.
A1bScore distribution111/225 (49%) score exactly 7 — the pass threshold and the fabricated value. 62/225 (27.6%) failed the gate.
A2Is note starvation live?No. Zero brands carry >1 unconverted note. Latent.
A3Real throughput~10 brands per 9–13 min sweep; generation averages 6–43s/customer. Poll-interval bound, ~60 brands/hour.
A4Is the memory populated?8 docs, avg 554/3000 chars, max version 3. All 8 have an empty ## What Works Well.
A4bWhy?40 signals, 100% rejected. Zero approved signals have ever been written. → §3.2a
A5Is outcome-grounding actionable?Yes. 8 of 9 active brands have post_analytics in 30d, 766 rows.
A6Signal amplification40 signals / 28 posts; 10 posts duplicated, avg 2.2, worst 3.
A7Did #1150/#1152 fix structure?Inconclusive — sample too small. Only 3 social posts since 2026-08-31.

A7 in detail — the baseline is solid, the after-sample is not

Social posts only (blog and email excluded), since 2026-07-01:

PeriodPostsQuestion rateTriplet rateDistinct-opener ratio
Before 2026-08-3116293.8%29.6%48.1%
After3100%0%100%

The "before" figures corroborate #1141's original audit almost exactly (it reported 95.0% and 24.8% on a 222-post sample). They also confirm that style-repetition.ts's thresholds are calibrated to reality: production's 48.1% distinct-opener ratio sits well below the 0.7 threshold, and the 93.8% question rate well above the 0.6 one — the detector would fire on this data.

Three posts is far too small to say whether #1150/#1152 worked. Re-run A7 once ~30+ social posts exist after 2026-08-31 — that is the number that tells you whether the structural fix landed, and whether the residual complaint is the topic-level convergence of §3.2 rather than sentence structure.

Appendix B — post-fix validation (2026-09-03)

All fifteen issues raised by this review (#1203–#1216, #1223) were fixed and merged. Re-validated against master at #1248: tsc clean, 4382 API tests pass, and each fix confirmed in code — the entitlement query now rethrows, the quality gate requires !critiqueSkipped, both previously-unmarked critic fabrications now carry the marker, the publish path honours critique_skipped in both services, notes go through one shared oldest-first accessor, "repeat what works" is gone from all four read sites, and no appendContentSignal caller remains.

One regression, introduced by the fix

#1230/#1232 changed the read framing to CONTENT LEARNINGS (what has worked well for this brand) and removed every signal writer — but did not migrate the stored documents, and nothing else will: consolidateContentMemory returns early when there are no unprocessed signals, and there are none.

So all 8 production documents remain frozen in the pre-#1208 three-section shape, and every generation for those brands now injects their ## Rejected Patterns bullets under a heading asserting the document is what has worked well. The sub-headers survive, so it reads as a contradiction rather than a clean inversion — but it is rejection copy presented as a positive exemplar, at four read sites, on every post.

Fixed by 20260902212544_backfill_stale_content_memory_sections.sql, which snapshots each document into client_content_memory_history (#1215's rollback path) before resetting it to the single-section shape. Nothing is lost: the documents are derived artefacts, and the rejection text they summarise still lives in post_feedback, where 47 of 69 rows remain unconsumed by brand-voice-learning.service.ts.

Still open

  • ## What Works Well now has no writer at all. The outcome-grounded source recommended in §3.3 — post_analytics, confirmed available for 8 of 9 active brands — was not implemented, so once the backfill lands the memory is permanently empty while still being read at four sites. The code comments record this as deliberate-for-now.
  • Deferred from Part 2 and Part 4, correctly: batchSize/localConcurrency still unset (fine at 9 brands); the knowledge base is still uncapped at formatKnowledgeBaseSection, still the dominant term in the 16.8k-char generate prompt; blog still receives the markdown ban alongside its HTML requirement; the two copies of the writing rulebook remain; the image services' LLM calls are still untagged; customer_platform_post.generation_plan is still a dead column.
  • A7 is still unanswerable — it needs ~30 or so social posts created after 2026-08-31.

Appendix C — incidental findings

Not worth issues of their own, but noted for whoever touches this next.

  • docs/LOGIC.md and CLAUDE.md are stale on generation. Both point at api/src/agents/createPosts.ts, which does not exist. Per-platform generation is api/src/modules/posts.ts plus api/src/agents/pipeline/run.ts, and contentMap lives at run.ts:67-86, not in src/routes/posts.ts. CLAUDE.md's "New platform" instructions will send someone to two files that are not the right ones.
  • generation_status has two dead enum values. fetching_data and reviewing are documented in the migration and never written. The column is only driven by the regeneration queue, so a first-pass generation is never observably in-flight.
  • character_count is computed three times in run.ts and never persisted or read.
  • The strategic planner is the only tier that does not read client_content_memory — which, given §3.2, is currently an accidental protection rather than a gap.