diff --git a/.claude/skills/blog-next/SKILL.md b/.claude/skills/blog-next/SKILL.md index 4e1ce07c7..418288184 100644 --- a/.claude/skills/blog-next/SKILL.md +++ b/.claude/skills/blog-next/SKILL.md @@ -26,9 +26,14 @@ reviewing and shipping are the other half and belong to the agents below. | A. Plan health + pick, or REBUILD the plan | **this skill** | | B. Research | **this skill** | | C. Outline + outline gate | **this skill** | -| Draft → 3 critics → cold-eyes → ship gates → commit | `blog-post-coordinator` | +| Draft → 3 critics → cold-eyes → ship gates → commit | **`blog-write` skill** | | N-post sprint, cluster sweep, one PR, CI watch | `blog-batch-orchestrator` | +**If the user wanted a POST and you return a verdict, say so in one line and +name `blog-write`.** This skill deciding not to write is a legitimate outcome, +but it is not what someone asking for a post expects, and leaving them to infer +the second half exists is how they end up thinking the pipeline is broken. + You produce one of three things, never a draft: a **topic row**, a **rebuilt plan section**, or a **HOLD** saying the slot should not be spent. @@ -192,8 +197,10 @@ it straight on and let the gates decide. Paul asked for delivery without a human in the loop (2026-08-22), and the stop list below is the whole of what he still owns. -**One post** → `blog-post-coordinator` with the topic row, branch, dev-server -port, research digest, **the approved outline**, and `premise audited: yes`. +**One post** → the **`blog-write` skill**, with the topic row, research digest, +**the approved outline**, and `premise audited: yes`. That skill owns STEP 4 +onward and is what the user can also invoke directly; it delegates to +`blog-post-coordinator` when agent spawning is available. **Several posts** → `blog-batch-orchestrator` with N; it runs Stages A-C per row. diff --git a/.claude/skills/blog-operator/SKILL.md b/.claude/skills/blog-operator/SKILL.md new file mode 100644 index 000000000..cdf64da43 --- /dev/null +++ b/.claude/skills/blog-operator/SKILL.md @@ -0,0 +1,106 @@ +--- +name: blog-operator +description: > + ONE door for blog work. Decides for itself whether to pick a topic, write a + post, upgrade an existing one, or repair a credibility defect - then does it, + and keeps going. Use whenever the user wants the blog moved forward without + saying how: "work on the blog", "improve the blog", "run the blog", "do blog + work", "make the blog better for clients", "keep going on content", a blog + sprint, or any standing instruction to build the brand through content. Also + use when the user is unsure whether they need blog-next or blog-write - that + choice is this skill's job, not theirs. + NOT for LinkedIn (linkedin-post-jt), course chapters, or landing pages. +--- + +# Blog operator + +**The blog exists to make a sceptical founder trust us enough to talk.** Traffic +is a proxy and sometimes a bad one. Judge every action by whether it moves that, +and you will pick differently than if you optimise clicks. + +The reader is `docs/90-99-content-strategy/strategy-analysis/90.10-icp-primary-website-target.md`: +a non-technical founder who has been burned by a devshop. They arrive sceptical +and they are reading for reasons to disqualify us. + +You own the decision of what to do next. `blog-next` and `blog-write` are your +two hands - do not ask the user which to run. + +## Pick the next action + +Check in this order and take the first that applies. The order is deliberate: +**credibility defects outrank new content**, because a founder who catches one +invented claim stops believing the rest of the page, and no new post recovers +that. + +1. **REPAIR - is something published that damages trust?** + Run the fabricated-claim ratchet: `bundle exec ruby -Itest test/unit/marketing_copy_test.rb`. + Above baseline, or a known unswept carrier? Fix it. Rank by live impressions, + not by how bad the claim reads - `.okf/content/claims-canon.md` carries the + rule and the reasoning. Use `blog-write` in UPGRADE mode. + +2. **UPGRADE - is an approved upgrade waiting?** + Check 20.09 §13 for UPGRADE verdicts with nothing shipped against them. An + upgrade to a page that already ranks beats a new post that has to earn its + position from zero. Hand it to `blog-write`. + +3. **WRITE - is there an approved WRITE row?** + A row that passed the Stage A gate and has not been drafted. `blog-write`. + +4. **RESTOCK - is the queue dry or stale?** + No actionable row, or the rows are older than the data they rest on. Run + `blog-next`, which will rebuild the plan section rather than dead-ending. + +If nothing applies, say so with the check that proves it. "Nothing to do" is a +legitimate answer exactly once - if you return it twice running without the +inputs changing, the ordering above is wrong and needs revisiting rather than +repeating. + +## Keep going + +**One unit at a time (WIP=1), then re-decide.** Finish the action, merge or open +its PR, then run the decision above again with the new state. Do not batch three +posts into one PR, and do not stop after one unit because a unit is "done" - the +user asked for the blog to move, not for one task. + +Re-deciding matters: a repair can reveal three more carriers, and an upgrade can +falsify the row that was queued behind it. State changes under you. + +## What "improves the brand" actually means here + +Concrete, in the order these tend to pay: + +- **Remove reasons to disbelieve us.** Invented case studies, unsourced numbers, + claims with no engagement behind them. This is why REPAIR is first. +- **Make the pages that already get read better**, rather than adding pages + nobody has found yet. +- **Say something only we can say.** First-hand operating evidence beats a + summary of public material - the latter is somebody else's post. +- **Write for the founder, not the developer.** A post that addresses developers + can be excellent and still be worth nothing here. + +Volume is not on that list. A tenth mediocre post costs more than it earns, +because it dilutes the nine and gives the sceptic more surface to find a flaw. + +## The gates are not yours to waive + +Both hands carry their own blocking gates and they stay blocking. You may +sequence work and decide what to do; you may not decide a gate does not apply +today. If a gate blocks, that is the system working. + +**Author ≠ verifier.** If agent spawning is unavailable, run the gates inline and +say plainly in the handback that no independent verifier ran. + +## Three exits + +- **SHIPPED** - one or more units delivered, each with its PR and gate numbers. + Say what you did and what you would do next. +- **HOLD, with evidence** - the checks ran and genuinely produced no action worth + taking. Quote the checks. Never invent work to look busy; a fabricated post is + the precise harm this skill exists to prevent. +- **BLOCKED on a named decision** - whether a claimed engagement or number is + real, publishing outward, overriding a documented gate, pricing/naming, or a + split-and-irreversible call. Name it, take the conservative option meanwhile, + and continue with everything not blocked by it. + +Report in the user's terms: what a founder reading the blog would now see that +they would not have seen before. diff --git a/.claude/skills/blog-write/SKILL.md b/.claude/skills/blog-write/SKILL.md new file mode 100644 index 000000000..a1064a9ae --- /dev/null +++ b/.claude/skills/blog-write/SKILL.md @@ -0,0 +1,123 @@ +--- +name: blog-write +description: > + Actually WRITE and ship a blog post - draft, critics, gates, commit, PR. This + is the delivery half of the pipeline; blog-next decides WHAT to write and this + writes it. Use whenever the user asks to write, draft, publish, ship or add a + blog post; when they hand over a topic and expect a post out the other end; + when they point at an approved row in the content plan; and when they ask to + UPGRADE or refresh an existing post rather than write a new one. Also use when + a /blog-next run ended in a WRITE or UPGRADE verdict and nothing has been + drafted yet. + NOT for choosing the topic (use blog-next), LinkedIn posts (linkedin-post-jt), + course chapters, or landing pages (page-cro, landing-page-optimization). +--- + +# Blog: write it and ship it + +**You produce a published post.** Not a plan, not a recommendation. If you finish +without a committed post or a named blocker, the run failed. + +`blog-next` owns "what and whether." You own everything after that. + +| Stage | Owner | +|---|---| +| Topic, research, gated outline | `blog-next` | +| **Draft → critics → gates → commit → PR** | **this skill** | + +**Read before starting; this skill deliberately does not copy them, so a +correction lands in one place:** + +- `docs/workflows/blog-pipeline.md` - **canonical from STEP 4 onward.** Follow it + step by step. Every blocking gate is defined there. +- `docs/90-99-content-strategy/strategy-analysis/90.11-voice-guide.md` - voice. +- `docs/90-99-content-strategy/strategy-analysis/90.10-icp-primary-website-target.md` - the reader. +- `.okf/content/claims-canon.md` - what you are allowed to say. A number with no + in-repo source is a defect, not a detail. + +## What you need before drafting + +A topic row with an angle, the research digest, and a gated outline. If you have +all three, start at STEP 4. + +**If you don't, get them - do not draft anyway.** Run `blog-next` first and come +back with its output. A post drafted without the premise audit is how a decayed +row or a dedup collision reaches a draft, which is the exact failure the split +exists to prevent. + +**If the user handed you a topic directly**, that is not a licence to skip the +audit - it is the case where the audit matters most, because nobody has checked +it against the corpus yet. Run `blog-next`'s Stage A on it, then continue here. + +## New post or upgrade + +Both are this skill's job, and the choice is `blog-next`'s verdict, not yours. + +- **WRITE** - a new post at `content/blog//index.md`. +- **UPGRADE** - edit the existing post in place. Do NOT create a second post on + the same topic; that is the cannibalisation the verdict exists to prevent. + Keep the URL, keep what still holds, and rewrite what the new material + changes. An upgrade that only appends a section has usually missed the point - + if the thesis moved, the shape moves with it. + +## Running the pipeline + +Follow `blog-pipeline.md` STEP 4 → STEP 7. It carries the cadence quotas, the +BAD/GOOD pairs, the two-pass writing rule, and the gate definitions. Do not +paraphrase it from memory - open it. + +The gates that most often get skipped, named here so they are not: + +- **STEP 4e self-critique** (`reflexion-reflect`) before the critic panel. +- **STEP 5a anti-AI pass** before the review loop, not after. +- **STEP 5b slop gate: `slop >= 8/10`.** This is the blog scale, 0-10, higher is + better. The course's `Slop <= 25` is a different scale in the other direction - + never mix them. +- **STEP 5c cold-eyes gate** runs LAST, by a reviewer that did not write the draft. +- **STEP 6b pre-publish checklist**, then **STEP 7 validate**. + +**Author ≠ verifier is the point of the panel.** Spawn a different agent type for +each critic role. If agent spawning is unavailable in this session, run the gates +inline against the written criteria and **say plainly in the handback that no +independent verifier ran** - a self-reviewed draft that claims a passed 4-eyes +gate is worse than one that admits it had none. + +## Gates before commit + +Content-only diff (markdown prose and frontmatter, no template/CSS/body HTML): +`bin/hugo-build` plus the rendered scroll gate. The visual suites do not apply. + +The moment the diff touches a template, stylesheet or body HTML, the full visual +gate applies - `bin/qtest --changed` before the commit. Check the actual diff, +not what you intended to change. + +New media gets the visual gate in `blog-pipeline.md`: 1280x800 and 390x844, four +criteria scored, and the scores written into the commit message. + +## Ship + +Feature branch, commit, `gh pr create` with the evidence. Never push to master. + +**End the handback with the local review link** - `http://localhost:/blog//`. +One dev server per session, never 1313: + +``` +PORT=$((20000 + RANDOM % 20000)) bin/dev +``` + +## Three exits, and only three + +- **SHIPPED** - committed, PR open, gate verdicts quoted with their numbers. +- **BLOCKED on a named decision** - one of: whether a claimed client engagement or + number is real, publishing outward, overriding a documented gate, + pricing/naming/internal numbers, a split-and-irreversible call. Name which one, + and take the conservative option meanwhile where one exists. +- **FAILED the gates twice** - stop, hand back the draft with both critic reports + and what you changed between rounds. Do not iterate a third time silently; two + failed rounds on the same draft usually means the outline was wrong, which is + `blog-next`'s problem and not something more prose will fix. + +**HOLD is not an exit here.** By the time work reaches this skill the decision to +write has already been made and audited. If you find a reason the post should not +exist, that is a falsified premise - say so explicitly and hand it back to +`blog-next` rather than quietly producing nothing. diff --git a/.gitignore b/.gitignore index b9655d4c6..9097a7265 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,8 @@ claude-flow.config.json .claude/skills/* !.claude/skills/README.md !.claude/skills/blog-next/ +!.claude/skills/blog-write/ +!.claude/skills/blog-operator/ # `:33` (.claude/**/*.json) still matches inside an un-ignored directory, so a # hand-authored skill's JSON needs its own file-level negation to ship. !.claude/skills/blog-next/**/*.json diff --git a/.okf/content/fabrication-ratchet.md b/.okf/content/fabrication-ratchet.md new file mode 100644 index 000000000..c977db001 --- /dev/null +++ b/.okf/content/fabrication-ratchet.md @@ -0,0 +1,78 @@ +--- +type: Validator +title: Fabricated-claim ratchet +description: A structural gate over blog source that counts invented-client-work shapes (case-study headings, "in our experience", tagged-but-unremoved figures) and fails when the count rises. +resource: test/unit/marketing_copy_test.rb +tags: [validation, blog, claims, regression] +generated: + by: claude/opus-5 + at: 2026-08-22T12:20:00Z +timestamp: 2026-08-22T12:20:00Z +--- + +The [banned-phrase guard](/content/banned-strings-ratchet.md) catches a defect +made of specific WORDS. It cannot catch the class +[claims-canon](/content/claims-canon.md) names "invented client work", because a +fabricated case study is written in ordinary words. What that class does have is +a SHAPE, and a shape is greppable. + +`test_blog_does_not_regress_on_fabricated_claim_markers` counts those shapes in +`content/blog/**/*.md` and fails when the count exceeds its baseline. + +# Why a gate rather than another sweep + +Three hand-sweeps on 2026-08-22 each missed carriers the previous one missed, +because each keyed on a different surface: + +| Sweep | Keyed on | Missed | +|---|---|---| +| 1 | `N clients/companies/times` | everything phrased as a case study | +| 2 | case-study headings | everything phrased as "in our experience" | +| 3 | body prose | frontmatter `description` / `twitter_description` | + +Sweep 2 found the two largest carriers by impressions, both absent from sweep +1's list. That is claims-canon's own finding reproducing itself inside a single +session: **manual sweeps under-count badly.** A ratchet does not need to +recognise a fabrication - it only has to notice the count going up. + +# Rules + +- **Markers are shape, not judgement.** A case-study heading, the + recurrence-generalisation hatch ("in our experience", "the pattern we see"), + and `(figures unverified)` - a number tagged instead of removed, where the tag + is the part a reader skips. +- **Prose markers match the whitespace-collapsed document, not line by line.** + `/\bin our experience\b/` returns false against `"in our\nexperience"`, so a + line-based scan has a hole that ordinary Markdown wrapping opens. Heading + markers stay line-based, because Markdown ends a heading at the newline, and + a line number is what you want when fixing one. +- **A case-study heading is a suspicion, not a verdict.** The shape is identical + whether the subject is real or invented. `VERIFIED_CASE_STUDIES` allows + specific headings by path; adding a line there asserts that someone checked. + The test is whether the write-up names a subject a reader could go verify - + this repo, a named client, a public postmortem. "A medium-sized content + platform" is not a subject, and that is the whole difference. +- **Set the baseline to the measured count, then prove it is exact** by dropping + it one lower and watching it fail. Slack in a ratchet swallows real defects - + the rendered baseline in the same file sat at 14 against an actual 11, and + those three spare hits absorbed a planted phrase whole. +- **dev.to imports are excluded**, derived from `source: dev_to` frontmatter, on + the same basis as the rendered pass: their stats belong to their original + authors. That is a TEST-scoping call and NOT editorial absolution - those + posts are still published on our domain. + +# Prioritise by impressions, never by indignation + +When clearing survivors, rank by live GSC impressions. The first sweep +prioritised a `featured: true` ICP-facing post carrying a "200+ clients" claim; +it had **4 impressions in 90 days**, while the top carrier had 40,025. `featured` +is a site-internal flag, not traffic. Claims-canon states the reasoning: a +fabricated story on a page nobody reads is a liability, on a ranking page it is +the first thing a prospect sees. + +# Citations + +- `test/unit/marketing_copy_test.rb` - `FABRICATION_HEADING_MARKERS`, + `FABRICATION_PHRASE_MARKERS`, `VERIFIED_CASE_STUDIES`, `FABRICATION_BASELINE`. +- [claims-canon](/content/claims-canon.md) - the standing purge policy and the + four fabrication classes. diff --git a/.okf/content/index.md b/.okf/content/index.md index 9c4bca077..9a39f8bbd 100644 --- a/.okf/content/index.md +++ b/.okf/content/index.md @@ -5,3 +5,4 @@ * [Company claims canon](claims-canon.md) - founding date, tenure, rating; what JetThoughts may assert about itself, where it is ratcheted, and why ranking legacy blog posts still hold fabricated client stories * [Voice rules](voice-rules.md) - Sam voice, banned patterns, and the em-dash rule * [Banned-strings ratchet](banned-strings-ratchet.md) - how fixed prose defects stay fixed +* [Fabricated-claim ratchet](fabrication-ratchet.md) - the structural gate over blog source, why three hand-sweeps each missed what the last one missed, and why you rank by impressions rather than indignation diff --git a/.okf/log.md b/.okf/log.md index 4f547c4cb..226e74f2c 100644 --- a/.okf/log.md +++ b/.okf/log.md @@ -73,6 +73,47 @@ answers kept deliberately, because the SHAPE recurs; and the open decision (run CI in the same container vs accept the split). STATUS.md and the 2608 README carried the wrong arch explanation for an hour and are corrected. +## 2026-08-22 - three hand-sweeps, three different blind spots, one gate + +The blog archive carried invented case studies on its highest-traffic pages - +the class claims-canon flagged on 2026-08-20 with a standing policy nobody had +run. Executing it exposed something more useful than the defects. + +Three sweeps, each keyed on a different surface, each missing what the last one +missed. A `N clients/companies/times` regex missed everything phrased as a case +study. A case-study-heading sweep missed everything phrased as "in our +experience". Both missed frontmatter, where a `description` ships the claim to +every SERP and social card. Sweep 2 found the two biggest carriers by +impressions and neither appeared in sweep 1's list at all. + +That is claims-canon's own line - "manual sweeps under-count badly" - happening +inside one session to someone who had read it that morning. The answer was a +gate: [fabrication-ratchet](/content/fabrication-ratchet.md). + +**Rank by impressions, not by indignation.** The first thing flagged was a +`featured: true` ICP-facing post claiming "200+ times with clients". Live GSC: +4 impressions in 90 days. The top carrier had 40,025. `featured` is a +site-internal flag and says nothing about who reads the page. + +Peer review found four defects in the gate itself, and three are worth keeping: + +- A case-study heading is a SUSPICION, not a verdict. `async-remote-xp-practices` + writes up this repo's own CSS migration - as verifiable as a case study gets - + and the marker classed it as fabrication. Verified subjects are now allowlisted + by path, and the test for entry is whether a reader could go check the subject. +- Line-by-line matching has a hole that ordinary Markdown wrapping opens: + `/\bin our experience\b/` returns false against `"in our\nexperience"`. Prose + markers now match the whitespace-collapsed document, the same reason the + rendered pass collapses. Headings stay line-based; Markdown ends them at the + newline. +- Removing a fabricated number and replacing it with a confident mechanism claim + is not a fix. The Propshaft rewrite said precompilation "stops being a build + step that scales with your asset count" - Propshaft still walks, fingerprints + and copies every asset, so it does scale. What drops is the per-asset cost. + +The third one is the general lesson: de-fabrication has its own failure mode. +The removed claim leaves a hole, and the thing written into the hole gets less +scrutiny than the claim that was there before it. ## 2026-08-22 - the Linux visual gate was green because it was not testing `bin/dtest` run from a git worktree compared NOTHING. A worktree's `.git` is a diff --git a/CLAUDE.md b/CLAUDE.md index fe63353fa..7c53bf87f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,7 +107,9 @@ Operational knowledge lives in `.okf/` (`build/`, `content/`, `design/`, `workfl ### ✍️ Blog Post Pipeline (MANDATORY) -**Entry point: `/blog-next`** — it owns STEP 1-3 (pick the topic from live GSC+GA4 against the 20.09 §13 queue, research primary sources, gate the outline) and hands off to `blog-post-coordinator` / `blog-batch-orchestrator`. Any request to write/draft/schedule/publish a post executes `docs/workflows/blog-pipeline.md` end-to-end — do not stop after the draft step; a failing step is fixed and retried before the next (drafts too — publish-ready when flipped). Repo voice guides and workflow docs override generic writing/SEO/humanizer skill advice. Pre-writing reads: voice-guide 90.11, thoughtbot analysis, ICP 90.10. +**Default door: `/blog-operator`** — one command, decides for itself whether to repair a credibility defect, upgrade a ranking page, write a new post, or restock the queue, then does it and re-decides (WIP=1). Use it whenever the ask is "move the blog forward" rather than a specific step. It ranks REPAIR above new content because a founder who catches one invented claim stops believing the page. + +**The two hands underneath, NOT interchangeable** — `/blog-next` decides what to write and **never writes**; `/blog-write` writes and ships. "Give me a post" is `/blog-write` (it runs the premise audit itself when the topic is unaudited); "what should we write next" is `/blog-next`. `/blog-next` owns STEP 1-3 (pick the topic from live GSC+GA4 against the 20.09 §13 queue, research primary sources, gate the outline), then hands its gated outline to `/blog-write`, which owns STEP 4 onward and delegates to `blog-post-coordinator` / `blog-batch-orchestrator` when agent spawning is available. Any request to write/draft/schedule/publish a post executes `docs/workflows/blog-pipeline.md` end-to-end — do not stop after the draft step; a failing step is fixed and retried before the next (drafts too — publish-ready when flipped). Repo voice guides and workflow docs override generic writing/SEO/humanizer skill advice. Pre-writing reads: voice-guide 90.11, thoughtbot analysis, ICP 90.10. **BLOCKING gates** — enforced by blog-pipeline.md; named here so none is skipped, detail in the canonical home: - **Voice / zero-tolerance AI patterns** — banned structural patterns + fixes: voice-guide; use `-` not `—`. diff --git a/content/blog/2025/complete-guide-ruby-rails-ai-integration-2025.md b/content/blog/2025/complete-guide-ruby-rails-ai-integration-2025.md index 3b193229d..ab4ff60f4 100644 --- a/content/blog/2025/complete-guide-ruby-rails-ai-integration-2025.md +++ b/content/blog/2025/complete-guide-ruby-rails-ai-integration-2025.md @@ -388,7 +388,7 @@ end 5. Deploy again 6. Revoke old key #1 safely -**Why this matters**: We've seen 3 clients avoid production outages using this pattern. +**Why this matters**: rotating without this overlap window takes every in-flight request down with the old key. ### 4. Prompt Injection Prevention (Security Critical) @@ -1172,7 +1172,7 @@ Ruby on Rails AI integration in 2025 is production-ready. With ruby-openai, anth The Ruby AI ecosystem has reached critical mass. The question isn't "Can I build AI features in Rails?" but "Which AI features should I prioritize?" -At JetThoughts, we've helped 200+ clients integrate AI into production Rails applications. If you need hands-on guidance for your specific use case, [schedule a consultation](https://jetthoughts.com/contact-us/). +If you want hands-on guidance for your specific use case, [schedule a consultation](https://jetthoughts.com/contact-us/). ## Resources diff --git a/content/blog/2025/pgvector-rails-tutorial-production-semantic-search.md b/content/blog/2025/pgvector-rails-tutorial-production-semantic-search.md index f786d5783..d845bf489 100644 --- a/content/blog/2025/pgvector-rails-tutorial-production-semantic-search.md +++ b/content/blog/2025/pgvector-rails-tutorial-production-semantic-search.md @@ -1,6 +1,6 @@ --- title: "pgvector Rails Production Guide: Semantic Search 2025" -description: "Build production semantic search in Rails with pgvector and PostgreSQL. Save $6,000/year vs Pinecone. Complete tutorial with 45+ code examples and benchmarks." +description: "Build production semantic search in Rails with pgvector and PostgreSQL - no external vector database. Complete tutorial with working code examples and a benchmark you run on your own data." created_at: "2025-01-18T10:00:00Z" edited_at: "2025-01-18T10:00:00Z" draft: false @@ -9,24 +9,17 @@ canonical_url: "https://jetthoughts.com/blog/pgvector-rails-tutorial-production- slug: "pgvector-rails-tutorial-production-semantic-search" --- -**TL;DR**: pgvector delivers 40% faster similarity search than Pinecone for typical Rails apps while saving $500+/month in infrastructure costs. Here's how to migrate your Rails app from external vector databases to PostgreSQL in under 1 day. +**TL;DR**: pgvector runs vector similarity search inside the PostgreSQL you already operate, which removes a service, a bill, and a synchronisation path. Here's how to migrate a Rails app off an external vector database. -## The $6,000 Question: Why Am I Paying for Pinecone? +## Why Am I Paying for a Separate Vector Database? -Last month, a Rails startup CTO reached out: "We're spending $600/month on Pinecone for 50,000 product embeddings. Is there a cheaper option?" +A separate vector database means a second store holding a copy of your data, a sync path between the two, a second uptime dependency, and a bill. For a catalogue that already lives in Postgres, every one of those is a cost you took on to get one feature. -I asked him to run a simple test: dump his vectors into PostgreSQL with pgvector and compare query performance. The results shocked both of us. +pgvector removes them by putting the vectors in the database that already holds the rows they describe. Embeddings update in the same transaction as the product. There is no eventual consistency window, because there is no second system. -**Pinecone query time**: 89ms average (P95: 142ms) -**pgvector query time**: 52ms average (P95: 78ms) +Whether it is also *faster* for your workload is a question only your own data answers - it depends on vector count, dimensionality, index type, and how much of your working set fits in memory. This tutorial shows how to set up the benchmark so you measure that yourself rather than taking anyone's number for it. -Not only was pgvector **40% faster**, but it ran on infrastructure he already paid for. His monthly savings: $600. His annual savings: **$7,200**. - -But here's what really convinced him to migrate: When Pinecone experienced a 3-hour outage, his semantic search went down. With pgvector, his search shared the same uptime as his primary database—99.95% over the past year. - -What if you could eliminate your vector database bill, simplify your infrastructure, improve performance, AND increase reliability? pgvector makes this possible by bringing semantic search directly into PostgreSQL—no new services, no data synchronization, no vendor lock-in. - -In this tutorial, you'll learn how to implement production-ready vector search in Rails using pgvector and the neighbor gem. You'll see working code, real performance benchmarks, and migration strategies that saved real startups thousands of dollars annually. +In this tutorial, you'll learn how to implement production-ready vector search in Rails using pgvector and the neighbor gem, with working code and a migration path off an external vector database. ## What is pgvector? PostgreSQL Vector Search Explained @@ -50,7 +43,7 @@ pgvector is a PostgreSQL extension that enables vector similarity search directl **Infrastructure Simplification**: Use your existing PostgreSQL server instead of managing another service. No Pinecone pods, no Qdrant clusters, no Weaviate instances. -**Cost Savings**: Typical Rails apps save $300-1,000+/month by eliminating external vector database costs. For a 100K product catalog with 50K searches/month, pgvector costs $0 (using existing infrastructure) vs Pinecone's $70/month. +**Cost Savings**: you stop paying for a separate service. Price your own case against the provider's current pricing page - the saving is whatever that line item costs you, and pgvector adds only storage on a server you already run. **Team Knowledge**: Your team already knows PostgreSQL. No learning curve for specialized vector databases, no new deployment pipelines, no additional monitoring tools. @@ -62,7 +55,7 @@ pgvector is a PostgreSQL extension that enables vector similarity search directl **Choose pgvector when**: - Vector count < 10 million (pgvector's sweet spot) -- Cost optimization is important ($500-2,000/year savings typical) +- Cost optimization is important (you drop a per-service bill entirely) - Data sovereignty matters (healthcare, finance, government) - Team size is small (1-5 developers without specialized DevOps) - You value simplicity over absolute maximum performance @@ -1379,7 +1372,7 @@ Implementing production-ready vector search requires careful architecture decisi - 🗓️ Book consultation: [Schedule with JetThoughts](https://calendly.com/jetthoughts) - 💬 Twitter: [@jetthoughts](https://twitter.com/jetthoughts) -We've helped 15+ Rails teams save $500-2,000/month by migrating to pgvector. Let's see if it's right for you. +If you're weighing pgvector against a hosted vector database, the deciding factors are usually collection size, write volume, and whether you already run Postgres. --- diff --git a/content/blog/cost-optimization-llm-applications-token-management/index.md b/content/blog/cost-optimization-llm-applications-token-management/index.md index aa64a2306..1baafc7b9 100644 --- a/content/blog/cost-optimization-llm-applications-token-management/index.md +++ b/content/blog/cost-optimization-llm-applications-token-management/index.md @@ -1,6 +1,6 @@ --- title: 'Cost Optimization for LLM Applications: Managing Token Budgets and Scaling Efficiently' -description: Learn proven strategies to reduce LLM costs by 30-60% in our experience through token management, caching optimization, prompt engineering, and smart model selection. Practical examples with cost tracking included. +description: Learn proven strategies to reduce LLM costs substantially through token management, caching optimization, prompt engineering, and smart model selection. Practical examples with cost tracking included. date: 2025-10-15 created_at: '2025-10-15T19:00:00Z' draft: false @@ -14,7 +14,7 @@ slug: cost-optimization-llm-applications-token-management The explosive growth of Large Language Model (LLM) applications has brought unprecedented capabilities-and equally unprecedented costs. Organizations deploying LLM-powered features often face a harsh reality: what starts as a $500/month experiment quickly escalates to $15,000+/month as usage grows. Without proper cost optimization strategies, LLM expenses can consume entire product budgets and make features economically unviable. -The good news? Through systematic token management, intelligent caching, prompt optimization, and strategic model selection, most organizations can reduce their LLM costs by 30-60% in our experience while maintaining or even improving application performance. This guide provides practical, battle-tested strategies with working code examples that you can implement immediately. +The good news? Through systematic token management, intelligent caching, prompt optimization, and strategic model selection, most organizations can reduce their LLM costs substantially while maintaining or even improving application performance. This guide provides practical, battle-tested strategies with working code examples that you can implement immediately. ### Key Takeaways @@ -1344,29 +1344,7 @@ To help you estimate potential savings from implementing these strategies, we've ## Scaling Efficiently: Putting It All Together -Let's see how combining all these strategies enables cost-effective scaling. Consider a real-world case study: - -### Case Study: Customer Support Chatbot - -**Initial State** (Month 1): -- Volume: 5,000 conversations/month -- Model: claude-3-5-sonnet for all requests -- Average conversation: 6 turns, 400 tokens per turn -- **Cost: ~$1,200/month** - -**After Optimization** (Month 3): -- Volume: 20,000 conversations/month (4x growth) -- Changes implemented: - 1. **Caching layer**: 75% cache hit rate for common questions - 2. **Model routing**: gpt-4o-mini for 70% of requests, claude-3-5-sonnet for 30% - 3. **Context optimization**: Sliding window reduced context tokens by 60% - 4. **Prompt compression**: 40% fewer instruction tokens - -**Results**: -- Gross cost without optimization: $1,200 × 4 = $4,800/month -- Actual cost with optimization: **~$1,400/month** -- **Savings: ~$3,400/month (~70% reduction)** -- **ROI: Implementation took 40 hours ($8,000 developer time), pays for itself in 2-3 months** +These strategies compound: caching cuts the request count, routing cuts the per-request price, and budgets cap the tail. Sequence them in that order, because each one shrinks the surface the next has to cover. ### Implementation Roadmap @@ -1389,7 +1367,7 @@ Let's see how combining all these strategies enables cost-effective scaling. Con - Implement context window management - Add retry logic with exponential backoff - Fine-tune caching TTLs based on usage patterns -- **Expected savings: 30-60% in our experience** +- **Expected savings: the compounding of the four levers above, sized to your own token bill** ### Continuous Improvement @@ -1414,7 +1392,7 @@ Cost optimization is an ongoing process: ## Where to go from here -LLM cost optimization isn't about compromising on quality-it's about being smart with resources. Through systematic application of token management, intelligent caching, prompt optimization, and strategic model selection, organizations routinely achieve 30-60% cost reductions in our experience while maintaining or improving application performance. +LLM cost optimization isn't about compromising on quality-it's about being smart with resources. Token management, caching, prompt optimization, and model selection each cut a different part of the bill, and they compound: caching reduces the call count, routing reduces the per-call price, and budgets cap the tail. The key principles: diff --git a/content/blog/crewai-multi-agent-systems-orchestration/index.md b/content/blog/crewai-multi-agent-systems-orchestration/index.md index 2840079e4..f479b52d6 100644 --- a/content/blog/crewai-multi-agent-systems-orchestration/index.md +++ b/content/blog/crewai-multi-agent-systems-orchestration/index.md @@ -379,7 +379,7 @@ Five patterns that consistently produce better results: 2. Complementary skills: design teams where skills cover each other's gaps. A content crew needs researchers, writers, and editors. 3. Tool alignment: only give tools to agents that need them. Research agents get search tools; analysts get calculation tools. 4. Backstory matters: "You're a cautious compliance officer" produces very different output than "You're an innovative growth hacker." Encode domain expertise and risk tolerance in the backstory. -5. Model selection by role: not every agent needs the largest model. Use big models for hard reasoning (financial, legal); use smaller models for routine work (formatting, simple search). In our experience, swapping editor and SEO from gpt-4o to gpt-4o-mini cut per-task cost by roughly half on benchmark crews. +5. Model selection by role: not every agent needs the largest model. Use big models for hard reasoning (financial, legal); use smaller models for routine work (formatting, simple search). ## Sequential and hierarchical @@ -443,7 +443,7 @@ This pattern gives you async processing (long-running crews don't block API requ Multi-agent systems can become expensive if not optimized. Here are production techniques for managing costs: -1. **Model selection by role.** Use a large model for complex reasoning and a smaller model for routine ops. A content crew might use gpt-4o for the researcher and writer, and gpt-4o-mini for the editor and SEO specialist. In our experience, swapping editor and SEO from gpt-4o to gpt-4o-mini cut per-task cost by roughly half on benchmark crews. +1. **Model selection by role.** Use a large model for complex reasoning and a smaller model for routine ops. A content crew might use gpt-4o for the researcher and writer, and gpt-4o-mini for the editor and SEO specialist. 2. Context window management. Don't pass entire previous outputs to every agent. The SEO specialist doesn't need the full research report - just the final article. CrewAI's `context` parameter lets you scope this precisely. diff --git a/content/blog/hiring-dev-shop-questions/index.md b/content/blog/hiring-dev-shop-questions/index.md index 38c027545..89434bef1 100644 --- a/content/blog/hiring-dev-shop-questions/index.md +++ b/content/blog/hiring-dev-shop-questions/index.md @@ -116,7 +116,7 @@ But if you're committing $50K+ for core functionality the business depends on, e ## The Pattern That Kills Projects -The pattern across the rescues we've taken is consistent. Most arrive with test coverage in single digits, no clause in the contract that names the founder as code owner, and a spend that's already crossed six figures. The five questions don't catch every bad shop. They do catch most of the ones that can't answer them - those shops self-select out before the contract is drawn up. +The failure mode is recognisable: test coverage in single digits, no clause in the contract naming the founder as code owner, and a spend that is already substantial before anyone asks to see the repository. The five questions don't catch every bad shop. They do catch most of the ones that can't answer them - those shops self-select out before the contract is drawn up. The founders who asked "what's your rate?" first and skipped the rest? They hired a shop that looked good on the website and fell apart three months in, usually around the first real deadline. If this sounds like your situation, we have a [comprehensive guide for founders who've been burned](/blog/founders-guide-hiring-dev-shop/) by dev shops. diff --git a/content/blog/how-to-manage-developers-when-you-cant-code.md b/content/blog/how-to-manage-developers-when-you-cant-code.md index 77084d78b..ab0f432ce 100644 --- a/content/blog/how-to-manage-developers-when-you-cant-code.md +++ b/content/blog/how-to-manage-developers-when-you-cant-code.md @@ -17,8 +17,6 @@ Your dev team says they need two months. Is that reasonable? You have no idea. This scenario plays out in thousands of startups every day. You're brilliant at your business domain – maybe you're a killer salesperson, a design genius, or an industry expert. But when your technical co-founder left or you're hiring your first dev team, you're suddenly responsible for managing people who speak in acronyms and seem to live in a world of mysterious complexity. -We've seen this exact situation 200+ times with clients at JetThoughts. - Here's the truth: you don't need to code to manage developers effectively. You need the right framework, clear communication patterns, and metrics that translate technical work into business outcomes. --- diff --git a/content/blog/langgraph-workflows-state-machines-ai-agents/index.md b/content/blog/langgraph-workflows-state-machines-ai-agents/index.md index e99f2677d..bd420ea1c 100644 --- a/content/blog/langgraph-workflows-state-machines-ai-agents/index.md +++ b/content/blog/langgraph-workflows-state-machines-ai-agents/index.md @@ -1010,16 +1010,9 @@ This guide introduced LangGraph's state machine fundamentals and production patt - **API Reference**: [Python API](https://langchain-ai.github.io/langgraph/reference/graphs/) - **Community**: [LangChain Forum](https://forum.langchain.com/) -### Production Case Studies +## Workflow Patterns Library -- **Uber**: Code migration automation with multi-agent systems (figures unverified) -- **LinkedIn**: SQL bot serving millions of employees (figures unverified) -- **Klarna**: 80% reduction in customer resolution time with AI assistant (figures unverified) -- **AppFolio**: 2x accuracy improvement in property management copilot (figures unverified) - -## Bonus: Workflow Patterns Library - -**Download our free Workflow Patterns Library** with 10+ production-ready templates: +Production-ready templates you can adapt: ### Template 1: Research and Summarization **Use Case**: Automated research reports with source validation diff --git a/content/blog/laravel-performance-monitoring-complete-apm-comparison-guide/index.md b/content/blog/laravel-performance-monitoring-complete-apm-comparison-guide/index.md index 6cfc628fd..f2b86c506 100644 --- a/content/blog/laravel-performance-monitoring-complete-apm-comparison-guide/index.md +++ b/content/blog/laravel-performance-monitoring-complete-apm-comparison-guide/index.md @@ -1935,56 +1935,35 @@ $queue_optimization = [ ]; ``` -## Real-World Performance Optimization Case Studies +## Two Fixes APM Points You Straight At -Understanding how other teams used APM tools to identify and fix performance bottlenecks provides actionable insights for your own optimization efforts. +APM earns its cost on problems that are invisible in application logs. These are the two you will hit first. -### Case Study 1: E-Commerce Platform Database Optimization +### N+1 queries behind a slow dashboard -### Background: -- **Application**: High-traffic Laravel e-commerce platform -- **Issue**: Dashboard loading 8+ seconds, user complaints -- **APM Tool**: Scout APM -- **Team Size**: 4 developers - -#### Problem Discovery: - -```php -// Scout APM revealed the issue -$apm_insights = [ - 'endpoint' => 'GET /dashboard', - 'avg_response_time' => 8734, // ms - 'database_percentage' => 94, // 94% of time in database - 'n_plus_one_queries' => 7, // 7 different N+1 patterns - 'total_queries' => 12456, - 'memory_usage' => 234 // MB -]; - -// Slowest queries identified by Scout: -// 1. SELECT * FROM products WHERE category_id = ? (executed 2,345 times) -// 2. SELECT * FROM reviews WHERE product_id = ? (executed 5,678 times) -// 3. SELECT * FROM images WHERE product_id = ? (executed 4,123 times) -``` - -#### Solution Implementation: +A trace showing most of the request time inside the database, spread across thousands of small identical queries, is the N+1 signature. The nested loop is the cause: ```php -// Before: Multiple N+1 queries +// Before: a query per category, then per product, then per relation public function dashboard() { - $categories = Category::all(); // 1 query + $categories = Category::all(); foreach ($categories as $category) { - $products = $category->products; // +50 queries + $products = $category->products; foreach ($products as $product) { - $reviews = $product->reviews; // +2,345 queries - $images = $product->images; // +2,345 queries + $reviews = $product->reviews; + $images = $product->images; } } } +``` -// After: Optimized eager loading + caching +Eager-load the relations in one pass, constrain what each one returns, and cache the assembled result: + +```php +// After: eager loading with constrained relations, plus a cache window public function dashboard() { $categories = Cache::remember('dashboard_categories', 600, function () { @@ -2005,8 +1984,11 @@ public function dashboard() return view('dashboard', compact('categories')); } +``` + +Eager loading alone will still scan without the right composite indexes, so add them to match the constraints above: -// Added strategic indexes +```php Schema::table('products', function (Blueprint $table) { $table->index(['category_id', 'featured', 'active']); }); @@ -2016,102 +1998,26 @@ Schema::table('reviews', function (Blueprint $table) { }); ``` -#### Results: +### Memory exhaustion in report generation -```php -$optimization_results = [ - 'performance' => [ - 'response_time_before' => 8734, // ms - 'response_time_after' => 187, // ms - 'improvement' => '97.9%', - - 'queries_before' => 12456, - 'queries_after' => 5, - 'query_reduction' => '99.96%', - - 'database_time_before' => 8212, // ms - 'database_time_after' => 67, // ms - 'database_improvement' => '99.2%' - ], - - 'business_impact' => [ - 'user_satisfaction' => '+42 NPS points', - 'bounce_rate_reduction' => '67%', - 'conversion_rate_increase' => '23%', - 'support_tickets_reduction' => '84%' - ], - - 'infrastructure' => [ - 'database_cpu_reduction' => '73%', - 'monthly_rds_cost_savings' => '$1,840', - 'able_to_downgrade_rds_instance' => true - ], - - 'timeline' => [ - 'issue_identification' => '2 hours (with Scout APM)', - 'optimization_implementation' => '8 hours', - 'testing_validation' => '4 hours', - 'total_time_to_fix' => '14 hours' - ] -]; -``` - -#### Key Learnings: -1. **Scout's N+1 detection was critical**: Identified 7 separate N+1 patterns with specific fix recommendations -2. **Caching multiplied benefits**: Combined eager loading with caching for 600-second TTL -3. **Indexes dramatically improved**: Adding composite indexes reduced query time 98% -4. **Monitoring prevented regression**: Continued Scout monitoring ensured optimizations remained effective - -### Case Study 2: SaaS Application Memory Leak Resolution - -### Background: -- **Application**: Multi-tenant SaaS platform -- **Issue**: Memory exhaustion errors, 500 internal server errors -- **APM Tool**: New Relic + Blackfire -- **Team Size**: 8 developers - -#### Problem Discovery: +The tell in APM is memory climbing with result-set size until the process dies. `get()` materialises every row and every eager-loaded relation at once: ```php -// New Relic alert: Memory threshold exceeded -$memory_alert = [ - 'transaction' => 'POST /api/reports/generate', - 'peak_memory' => 3200, // MB (exceeds 2GB limit) - 'frequency' => 47, // times per day - 'error_type' => 'Fatal error: Allowed memory size exhausted', - 'affected_tenants' => 23 -]; - -// Blackfire deep profiling revealed: -$blackfire_profile = [ - 'memory_allocation_hotspot' => [ - 'function' => 'Illuminate\Database\Eloquent\Collection::load', - 'memory_allocated' => 2847, // MB - 'calls' => 1, - 'line' => 'app/Services/ReportService.php:45' - ], - - 'root_cause' => 'Loading 500,000+ Eloquent models into memory at once' -]; -``` - -#### Solution Implementation: - -```php -// Before: Loading everything into memory +// Before: the whole result set in memory at once public function generateReport($tenant_id) { $orders = Order::where('tenant_id', $tenant_id) ->with('items', 'customer', 'payments') - ->get(); // Loads 500,000+ orders into memory - - // Memory peak: 3.2 GB - // Result: Fatal error + ->get(); return $this->processOrders($orders); } +``` -// After: Chunk-based processing +`chunk()` holds one batch at a time, so peak memory stays flat regardless of how many rows match: + +```php +// After: constant memory across the run public function generateReport($tenant_id) { $results = []; @@ -2122,83 +2028,15 @@ public function generateReport($tenant_id) $processed = $this->processOrders($orders); $results = array_merge($results, $processed); - // Clear Eloquent model cache after each chunk $orders = null; gc_collect_cycles(); }); - // Memory peak: 87 MB (constant across chunks) - return $results; } - -// Added memory monitoring -public function generateReport($tenant_id) -{ - $initial_memory = memory_get_usage(true); - - // ... processing logic ... - - $peak_memory = memory_get_peak_usage(true); - $memory_used = $peak_memory - $initial_memory; - - NewRelic::recordMetric('Custom/Report/MemoryUsage', $memory_used / 1024 / 1024); // MB - - if ($memory_used > 100 * 1024 * 1024) { // >100 MB - logger()->warning('High memory usage detected', [ - 'tenant_id' => $tenant_id, - 'memory_mb' => $memory_used / 1024 / 1024 - ]); - } -} -``` - -#### Results: - -```php -$memory_optimization_results = [ - 'performance' => [ - 'peak_memory_before' => 3200, // MB - 'peak_memory_after' => 87, // MB - 'memory_reduction' => '97.3%', - - 'processing_time_before' => 45, // seconds (when it worked) - 'processing_time_after' => 67, // seconds (acceptable trade-off) - 'time_increase' => '48.9%', - - 'error_rate_before' => 0.47, // 47% of requests failed - 'error_rate_after' => 0.0, // 0% failures - 'reliability_improvement' => '100%' - ], - - 'business_impact' => [ - 'reports_generated_successfully' => '100%', - 'customer_complaints_eliminated' => true, - 'refunds_due_to_errors' => '$0 (previously $12,400/month)', - 'customer_churn_reduction' => '8 customers retained' - ], - - 'cost_savings' => [ - 'reduced_server_instances' => 3, - 'monthly_ec2_savings' => '$840', - 'prevented_refunds' => '$12,400' - ] -]; ``` -#### Key Learnings: -1. **New Relic alerts identified pattern**: Memory threshold alerts showed consistent failure pattern -2. **Blackfire profiling pinpointed root cause**: Function-level profiling revealed exact memory allocation point -3. **Chunking solved memory issue**: Processing in batches kept memory constant -4. **Trade-off was acceptable**: 48% longer processing time was acceptable vs 47% error rate - ---- - -Pick one APM tool. Install it. Measure your baseline. Then fix the worst bottleneck first. - -Scout APM if you want Laravel-specific simplicity. New Relic or Datadog if you need enterprise-grade observability across multiple services. Blackfire if your problem is deep code-level profiling. Most teams don't need more than one - start with Scout or Blackfire's free tier, and add complexity only when the data tells you to. - -The pattern we see repeatedly: teams add APM, find 3-5 N+1 queries they didn't know existed, fix them in a day, and cut response times by half. The tool pays for itself in the first week. +Measure both before and after on your own data with `memory_get_peak_usage(true)` - the chunk size that works depends on how wide your rows are. ## FAQ: Laravel Performance Monitoring diff --git a/content/blog/production-scaling-langchain-crewai-enterprise/index.md b/content/blog/production-scaling-langchain-crewai-enterprise/index.md index 30c4d9b0f..58b212b5c 100644 --- a/content/blog/production-scaling-langchain-crewai-enterprise/index.md +++ b/content/blog/production-scaling-langchain-crewai-enterprise/index.md @@ -31,7 +31,7 @@ Here's the architecture we land on, the security patterns that survive audits, a Your prototype works because the development environment forgives everything: a single user, unlimited retries, manual error handling, no compliance constraints, no rate limits, and a tolerance for 30-second latencies because you're the only one running it. Production has none of those affordances. The user count goes from one to ten thousand. The error tolerance goes from "I'll fix it" to a 99.9% uptime SLA - 43 minutes of downtime per month, total. The data goes from sample PDFs to actual PII, financial records, and protected health information. The cost ceiling goes from "whatever it takes" to a budget your CFO has signed off on. And the deployment goes from `git push` to a multi-region Kubernetes cluster with zero-downtime rolling updates. -The pattern we see most often: a prototype processes a few sample documents flawlessly, the team moves it toward production, and the same code immediately hits four problems in sequence. OpenAI rate-limits the deployment within hours of going live. Compliance blocks the next push because PII is going out to the LLM provider unredacted. Security audits flag API keys living in environment variables. And the first real load test shows 30-second p95 latency that nobody noticed when one developer was the only user. +A prototype processes a few sample documents flawlessly. The same code moved toward production hits four problems in sequence. OpenAI rate-limits the deployment within hours of going live. Compliance blocks the next push because PII is going out to the LLM provider unredacted. Security audits flag API keys living in environment variables. And the first real load test shows 30-second p95 latency that nobody noticed when one developer was the only user. Most teams rebuild a substantial fraction of the infrastructure before the first real production deployment. The rest of this post is what to put in that rebuild. diff --git a/content/blog/propshaft-vs-sprockets-rails-8-asset-pipeline-migration/index.md b/content/blog/propshaft-vs-sprockets-rails-8-asset-pipeline-migration/index.md index d7e325846..676e65ef6 100644 --- a/content/blog/propshaft-vs-sprockets-rails-8-asset-pipeline-migration/index.md +++ b/content/blog/propshaft-vs-sprockets-rails-8-asset-pipeline-migration/index.md @@ -22,7 +22,7 @@ cover_image_alt: "Propshaft vs Sprockets comparison for Rails 8 asset pipeline m Your Sprockets precompile takes 60 seconds. You change one CSS variable. Sixty seconds again. Every deploy, every CI run, every developer on the team—waiting. -Propshaft replaces Sprockets as the default asset pipeline in Rails 8, and the difference is dramatic: in our experience, build times drop from 45-60 seconds to under 5 seconds for medium-sized apps. But Propshaft isn't a drop-in replacement. It removes features you might depend on—Sass compilation, CoffeeScript transpilation, asset concatenation. If you migrate without understanding these tradeoffs, you'll break your app. +Propshaft replaces Sprockets as the default asset pipeline in Rails 8. It drops the transpilation and concatenation stages, so precompilation digests and copies assets instead of compiling and bundling them. Every asset is still walked and fingerprinted, so the work still scales with how many you have - what drops is the cost per asset. But Propshaft isn't a drop-in replacement. It removes features you might depend on - Sass compilation, CoffeeScript transpilation, asset concatenation. If you migrate without understanding these tradeoffs, you'll break your app. This guide walks through migrating from Sprockets to Propshaft: what changes, what breaks, how to fix it, and when to stay on Sprockets. @@ -50,7 +50,7 @@ Consider a typical Rails application with Sprockets: This manifest triggers a multi-stage compilation process. Sprockets scans your entire directory tree, then walks every `require` directive across hundreds of files to resolve dependencies. It concatenates everything into massive bundles, runs compression over the whole result, and finally generates fingerprinted filenames. -In our experience, this process takes **45-60 seconds** on moderate-sized applications with 200+ assets. For larger applications, precompilation can exceed **2 minutes**, dragging down every deploy and CI run. +Every one of those stages runs on every deploy and every CI build, and the cost scales with how many assets you have. Time it on your own app with `time RAILS_ENV=production bin/rails assets:precompile` - that number is the one that matters for your migration decision. ### The Maintenance Burden @@ -665,10 +665,7 @@ $ bin/rails assets:clobber $ time RAILS_ENV=production bin/rails assets:precompile ``` -Our typical results: -- **Small apps** (50 assets): 1-2 seconds (vs 10-15s with Sprockets) -- **Medium apps** (200 assets): 3-5 seconds (vs 45-60s with Sprockets) -- **Large apps** (500+ assets): 8-12 seconds (vs 2-3min with Sprockets) +Run the same command on your Sprockets branch before you migrate, so the comparison is your app rather than someone else's. The gap widens with asset count, because Sprockets transpiles and concatenates where Propshaft only fingerprints and copies. ### Phase 5: Production Deployment @@ -771,253 +768,11 @@ Rails.application.configure do end ``` -## Production Case Studies and Real-World Results - -Here's what we've seen in actual migrations. If you're also containerizing your Rails app, check our [Rails 8 Docker deployment guide](/blog/rails-8-docker-deployment-production-guide/) for how Propshaft interacts with Docker-based builds. - -### Case Study 1: E-Commerce Platform Migration - -#### Background: -- **Application**: Large e-commerce Rails application -- **Assets**: 450+ JavaScript files, 200+ stylesheets -- **Previous setup**: Sprockets with heavy CoffeeScript usage -- **Team size**: 8 developers - -#### Migration Timeline: - -#### Week 1-2: Assessment and Planning -- Audited 450+ asset files -- Identified 87 CoffeeScript files requiring conversion -- Documented 23 Sass files with complex mixins -- Created migration checklist and rollback plan - -#### Week 3-4: Preparation -```bash -# Converted CoffeeScript to JavaScript -$ find app/assets/javascripts -name "*.coffee" | wc -l -87 -$ decaffeinate app/assets/javascripts/**/*.coffee -# Manual review and cleanup of converted files - -# Set up Dart Sass for preprocessing -$ bundle add dartsass-rails -``` - -#### Week 5-6: Migration Execution -```ruby -# Gemfile -gem "propshaft" -# Removed: gem "sprockets-rails" - -# Restructured assets -$ mv app/assets/javascripts app/javascript -``` - -#### Week 7: Testing and Deployment -- Comprehensive testing across 50+ pages -- Staged rollout: 10% → 50% → 100% of traffic -- Zero downtime deployment using blue-green strategy - -#### Results: - -| Metric | Before (Sprockets) | After (Propshaft) | Change | -|--------|--------------------|--------------------|--------| -| Asset precompile | 127.3s | 12.8s | 90% faster | -| Full deployment | 892s | 445s | 50% faster | -| CI pipeline | 1240s | 687s | 45% faster | - -The team also measured runtime improvements: first paint dropped by 0.4s, time to interactive improved by 0.7s, and their Lighthouse performance score jumped from 83 to 95. Cache hit ratio improved by 23% because individual file digests meant most assets survived deploys untouched. - -On the developer experience side, hot reload got 3.2 seconds faster, the team deployed 2.3x more often, and production incidents related to the asset pipeline dropped by 67%. - -#### What We Learned: - -The CoffeeScript conversion ate most of the migration time. Automated tooling handled the syntax, but the team spent days reviewing edge cases by hand. Import maps turned out to be a net simplifier because they eliminated the npm package conflicts the team had been fighting for years. HTTP/2 multiplexing handled 40+ concurrent asset requests without degradation, which surprised even the optimists on the team. And the monitoring setup they built during migration caught 12 missing-asset issues before any user saw them. - -```ruby -# Monitoring setup that caught 12 issues before production -# config/initializers/asset_audit.rb -# Walk Propshaft's load_path at boot in production-like environments -# and log assets the layouts reference but the pipeline cannot resolve. -if Rails.env.production? || Rails.env.staging? - Rails.application.config.after_initialize do - referenced = %w[application.js application.css logo.png] - referenced.each do |logical_path| - asset = Rails.application.assets&.load_path&.find(logical_path) - Sentry.capture_message("Missing asset: #{logical_path}") if asset.nil? - end - end -end -``` - -Propshaft does not currently publish a documented `load.propshaft` -ActiveSupport notification, so we audit the load path on boot instead. - -### Case Study 2: SaaS Application with Microservices - -#### Background: -- **Application**: Multi-tenant SaaS platform -- **Architecture**: 5 Rails services sharing asset pipeline -- **Assets**: 280+ files across services -- **Complexity**: Shared component library - -#### Migration Challenge: - -Coordinating asset pipeline changes across 5 microservices while maintaining shared component compatibility. - -#### Solution Architecture: - -```ruby -# Shared asset gem approach -# shared_assets/shared_assets.gemspec -Gem::Specification.new do |spec| - spec.name = "shared_assets" - spec.version = "1.0.0" - spec.files = Dir["app/assets/**/*"] - spec.add_dependency "propshaft" -end - -# Each microservice's Gemfile -gem 'shared_assets', path: '../shared_assets' - -# config/application.rb (in each service) -config.assets.paths << SharedAssets.asset_path -``` - -#### Phased Rollout Strategy: - -The team migrated services in dependency order, starting with the simplest: - -| Service | Dependencies | Assets | Migration Week | -|---------|-------------|--------|----------------| -| analytics_service | 0 | 45 | 1-2 | -| auth_service | 1 | 32 | 2-3 | -| admin_service | 1 | 9 | 3 | -| reporting_service | 2 | 38 | 4 | -| core_service | 3 | 156 | 5-6 | - -They started with analytics (zero dependencies, low risk) and saved core_service for last because it had the most shared assets and the highest dependency count. - -#### Results: - -The team completed the migration across all 5 services in 6 weeks with zero downtime and zero rollbacks. Asset compile times dropped by 88%, and the shared asset cache hit rate reached 94%. - -On the cost side, the faster builds saved roughly $4,800/year in CI pipeline costs, better caching cut CDN bandwidth by $2,100/year, and the team estimated $14,200/year in developer time savings from faster deploys. Those numbers add up when you multiply across 5 services. - -#### Implementation Highlights: - -```javascript -// Shared component with import map -// shared_assets/app/assets/javascripts/components/modal.js -export class Modal { - constructor(element) { - this.element = element; - this.setupEventListeners(); - } - - setupEventListeners() { - this.element.querySelector('.close').addEventListener('click', () => { - this.close(); - }); - } - - open() { - this.element.classList.add('active'); - } - - close() { - this.element.classList.remove('active'); - } -} - -// Each service's import map pins the shared component -// config/importmap.rb -pin "components/modal", to: "shared_assets/components/modal.js" -``` - -### Case Study 3: Legacy Application Gradual Migration - -#### Background: -- **Application**: 10-year-old Rails monolith -- **Assets**: 600+ files with heavy jQuery dependencies -- **Challenge**: Cannot afford complete rewrite -- **Goal**: Incremental modernization - -#### Hybrid Approach Strategy: - -```ruby -# Running Propshaft and Sprockets simultaneously during transition -# Gemfile -gem 'propshaft' -# Note: bundling both propshaft and sprockets-rails simultaneously -# is not officially supported. Most teams migrate by serving legacy -# pre-compiled assets from a separate URL prefix until the cutover. - -# config/environments/production.rb -# Serve legacy assets from separate path -config.assets.prefix = '/assets' - -# Mount legacy Sprockets assets via Rack::Static -config.middleware.insert_before ActionDispatch::Static, Rack::Static, - urls: ['/legacy-assets'], root: Rails.root.join('public') -``` - -#### Incremental Migration Plan: - -| Phase | Duration | Scope | Assets Migrated | Approach | -|-------|----------|-------|----------------|----------| -| 1 | 2 months | New features only | 45 | Build new features with Propshaft/import maps | -| 2 | 3 months | High-traffic pages | 120 | Migrate pages covering 80% of traffic | -| 3 | 4 months | Admin/internal tools | 200 | Modernize internal tooling with lower risk | -| 4 | 3 months | Remaining pages | 235 | Complete migration, remove Sprockets | - -The key insight was starting with new features. Every new page the team built used Propshaft from day one, so the legacy surface area stopped growing while the team chipped away at existing pages. - -#### Feature Flag Implementation: - -```ruby -# lib/asset_pipeline_feature_flag.rb -class AssetPipelineFeatureFlag - def self.use_propshaft_for?(controller_name, action_name) - # Gradual rollout based on traffic patterns - migrated_routes = [ - {controller: "home", action: "index"}, - {controller: "products", action: "show"}, - {controller: "cart", action: "index"} - ] - - migrated_routes.any? do |route| - route[:controller] == controller_name && - route[:action] == action_name - end - end -end - -# app/views/layouts/application.html.erb -<% if AssetPipelineFeatureFlag.use_propshaft_for?(controller_name, action_name) %> - <%= javascript_importmap_tags %> -<% else %> - <%= javascript_include_tag "application", "data-turbo-track": "reload" %> -<% end %> -``` - -#### Results After 12-Month Migration: - -The team migrated all 600 assets. Build time dropped from 187.5 seconds to 14.2 seconds, a 92% improvement. - -Page loads improved across the board: the homepage loaded 1.2 seconds faster, product pages gained 0.8 seconds, and checkout improved by 0.6 seconds. Cache hit rates jumped from 67% to 91% because individual file digests meant most assets survived code changes. Average cache size per user dropped from 8.7MB to 2.3MB, cutting bandwidth by 73%. - -#### What Made This Work: - -The founders gave the team a 12-month runway for incremental migration instead of demanding a big-bang cutover. Two developers worked on it full-time, which sounds expensive until you compare it to the cost of a botched migration on a 10-year-old monolith. The team built monitoring before they migrated a single asset, so they could track performance at every phase. And they ran A/B tests comparing Propshaft and Sprockets in production, which gave them hard data to justify continuing the migration when stakeholders got nervous. - -After 12 months, build times dropped from over 3 minutes to under 15 seconds, and the asset pipeline stopped being a topic at standup. - -If you're planning a large-scale migration and want a second pair of eyes, our [Rails development team](/services/app-web-development/) has done this migration dozens of times. +If you're also containerizing your Rails app, check our [Rails 8 Docker deployment guide](/blog/rails-8-docker-deployment-production-guide/) for how Propshaft interacts with Docker-based builds. ## Troubleshooting Common Migration Issues -Even with careful planning, Propshaft migrations can encounter challenges. This section covers the most common issues and their solutions based on real-world migration experiences. +Even with careful planning, Propshaft migrations can encounter challenges. This section covers the most common issues and their solutions. ### Issue 1: Missing Asset Errors in Production diff --git a/content/blog/rails-8-docker-deployment-production-guide/index.md b/content/blog/rails-8-docker-deployment-production-guide/index.md index e86a9fb6d..37902f785 100644 --- a/content/blog/rails-8-docker-deployment-production-guide/index.md +++ b/content/blog/rails-8-docker-deployment-production-guide/index.md @@ -854,21 +854,11 @@ services: retries: 5 ``` -## Real-World Case Studies - -### Case Study: SaaS Platform Migration to Docker - -**Company:** B2B SaaS platform with 50,000 active users -**Before:** Traditional server deployments with Capistrano -**After:** Docker-based deployment with container orchestration - -On a recent client project, the migration changed how the whole team worked. Their deploys went from slow Capistrano runs where everyone held their breath to fast image pulls that nobody even noticed. The "works on my machine" conversations disappeared entirely once every environment ran the same container. When something did go wrong, the on-call engineer swapped a container tag instead of manually reverting files -- rollbacks dropped from a tense 30-minute procedure to under a minute. New developers went from two days of setup to running the full stack before lunch on day one. The tradeoff was about three weeks of upfront migration work and a steeper learning curve for the team members who had never touched Docker. - -We wrote about a common gotcha during this migration in [Solving Kamal's "target failed to become healthy"](/blog/solving-kamals-target-failed-become-healthy/) -- health check timing is the number one deployment blocker we see. +Health check timing is a common blocker on this path - see [Solving Kamal's "target failed to become healthy"](/blog/solving-kamals-target-failed-become-healthy/). ## When NOT to Use Docker for Rails Deploys -Docker isn't always the right call, and we've talked founders out of it more than once. +Docker isn't always the right call. If you're a solo founder shipping on Heroku or Render, those platforms already containerize your app behind the scenes. Adding your own Docker layer means you're maintaining infrastructure instead of building features. diff --git a/content/blog/rails-8-solid-cache-performance-redis-migration/index.md b/content/blog/rails-8-solid-cache-performance-redis-migration/index.md index 51cad6bfe..011e0a755 100644 --- a/content/blog/rails-8-solid-cache-performance-redis-migration/index.md +++ b/content/blog/rails-8-solid-cache-performance-redis-migration/index.md @@ -864,18 +864,8 @@ class RedisOptimalUseCases end ``` -## Real-World Case Studies - -### Case Study 1: Content Management Platform - -A medium-sized content platform was running a 5GB Redis cache that cost $450/month. Their team migrated the bulk of their caching to Solid Cache and kept Redis only for real-time features -- about 10% of the original usage. After the switch, their infrastructure bill dropped to $125/month (a 72% reduction). They maintained an 85% cache hit rate, and average response times went up by 12ms -- a tradeoff they accepted because their users never noticed the difference and their ops team stopped getting paged about Redis memory pressure. - We've covered [Rails performance optimization strategies](/blog/ruby-on-rails-performance-optimization-patterns-2026/) in depth if you're looking at the broader picture beyond caching. -### Case Study 2: E-commerce Application (Memcached, not Redis) - -This one wasn't a Redis migration -- it was Memcached -- but the pattern applies to any external cache service. An online retail platform had been fighting cache invalidation race conditions in their Memcached cluster for months. A customer would place an order, the inventory cache wouldn't invalidate in time, and another customer would buy the same item. Their team switched to Solid Cache specifically for the transactional consistency: cache writes and deletes now live inside the same database transaction as the business logic, so race conditions disappeared entirely. They saved $320/month on Memcached hosting, simplified their deploys (no more Memcached cluster to coordinate), and their developers spent less time debugging stale cache issues because they could trace cache state in the same database queries they already knew. - ## When NOT to Use Solid Cache Solid Cache isn't the right call for every app. Be specific about when to keep Redis: diff --git a/content/blog/rails-event-structured-logging-8-1/index.md b/content/blog/rails-event-structured-logging-8-1/index.md index 5493adc92..543daed06 100644 --- a/content/blog/rails-event-structured-logging-8-1/index.md +++ b/content/blog/rails-event-structured-logging-8-1/index.md @@ -18,7 +18,7 @@ canonical_url: "https://jetthoughts.com/blog/rails-event-structured-logging-8-1/ related_posts: false --- -*TL;DR: Rails 8.1 replaces log scraping with structured events your monitoring can trust. We migrated four production apps and the false-positive alert rate dropped by **60%**. Migration runs one to two days per app.* +*TL;DR: Rails 8.1 replaces log scraping with structured events your monitoring can trust. Alerts fire on the event your app actually emitted instead of on a regex that matched the wrong line.* ```ruby # Replace regex-against-log-output with this: diff --git a/content/blog/solid-queue-vs-sidekiq-complete-comparison/index.md b/content/blog/solid-queue-vs-sidekiq-complete-comparison/index.md index 8a08105e0..f3c7850e0 100644 --- a/content/blog/solid-queue-vs-sidekiq-complete-comparison/index.md +++ b/content/blog/solid-queue-vs-sidekiq-complete-comparison/index.md @@ -369,34 +369,6 @@ class FeedUpdatePipeline end ``` -## Real-World Case Studies - -### Case Study 1: E-commerce Platform Migration - -**Company:** Medium-sized e-commerce platform -**Before:** Sidekiq processing 500 jobs/hour -**After:** Solid Queue handling same workload - -**Results:** -- **Operational complexity:** Reduced by 40% (eliminated Redis management) -- **Monthly costs:** Saved $200/month on Redis hosting -- **Performance:** Negligible impact on job processing times -- **Reliability:** Improved due to transactional job storage - -Our [Ruby on Rails development team](/services/app-web-development/) has guided similar migrations, helping teams evaluate their background job requirements and choose the optimal solution. We've successfully migrated applications processing 50,000+ jobs daily from Sidekiq to Solid Queue, reducing infrastructure costs by an average of 35% while maintaining reliability standards. - -### Case Study 2: SaaS Application Scaling - -**Company:** B2B SaaS with growing job volumes -**Challenge:** Scaling from 1,000 to 10,000 jobs/hour -**Decision:** Stayed with Sidekiq - -**Reasoning:** -- **Performance requirements:** Needed sub-second job latency -- **Complex workflows:** Required advanced job routing and priorities -- **Existing expertise:** Team already skilled in Redis operations -- **Monitoring needs:** Relied heavily on Sidekiq Pro features - ## Performance Optimization Strategies ### Optimizing Solid Queue Performance diff --git a/content/blog/solid-trifecta-hybrid-redis-rails-8/index.md b/content/blog/solid-trifecta-hybrid-redis-rails-8/index.md index 63fd116b0..03e788712 100644 --- a/content/blog/solid-trifecta-hybrid-redis-rails-8/index.md +++ b/content/blog/solid-trifecta-hybrid-redis-rails-8/index.md @@ -15,11 +15,11 @@ cover_image_alt: "JetThoughts blog cover for Solid Trifecta When to Keep Redis - canonical_url: "https://jetthoughts.com/blog/solid-trifecta-hybrid-redis-rails-8/" --- -Last quarter we migrated a B2B SaaS client off Redis for $360/month in savings. The Solid stack made it possible. Whether your app should follow them off Redis is a different question, and the answer is "it depends on three things." +Rails 8 ships a database-backed replacement for Redis. Whether your app should actually drop Redis is a different question, and the answer is "it depends on three things." Solid Cache, Solid Queue, and Solid Cable (the "Solid Trifecta") replace Redis for caching, background jobs, and WebSockets. All three are database-backed and ship as defaults in new Rails 8 apps. 37signals runs Solid Cache in production across Basecamp and HEY, handling what used to require [1.1 terabytes of Redis RAM](https://dev.37signals.com/solid-cache/) with 80 gigabytes of database storage, an 80% infrastructure cost reduction at their scale. -Across the migrations we've shipped this year, the rough split is half fully Redis-free, half hybrid. The decision came down to throughput, data structure usage, and burstiness. Here's the framework. +Going fully Redis-free and staying hybrid are both defensible outcomes. The decision comes down to throughput, data structure usage, and burstiness. Here's the framework. ## What each component replaces diff --git a/content/blog/test-driven-development-tdd-in-ruby-step-by-guide-tutorial-bestpractices/index.md b/content/blog/test-driven-development-tdd-in-ruby-step-by-guide-tutorial-bestpractices/index.md index 6464ea155..0edb58c1b 100644 --- a/content/blog/test-driven-development-tdd-in-ruby-step-by-guide-tutorial-bestpractices/index.md +++ b/content/blog/test-driven-development-tdd-in-ruby-step-by-guide-tutorial-bestpractices/index.md @@ -201,7 +201,7 @@ The third mistake is bundling tidy and behavior commits. We covered the mechanic When we inherit a codebase, the three failure modes above are usually all present at once - tests coupled to internals, refactor steps skipped for months, commits that mix tidy with behavior. We rescue Ruby on Rails projects from devshops that shipped this exact configuration with a CI suite that takes 22 minutes to run. The 90-second loop is the rhythm we put back first. -The pattern's consistent across the rescues we've taken: either no tests at all (the last three we picked up), or a brittle test suite written months after the code, mocked-to-the-teeth and useless under change pressure. We rebuild the rhythm first. Then we fix the bugs. [Refactoring callbacks back into services](/blog/how-avoid-callbacks-using-services-rails-refactoring/) and tightening the test suite go hand in hand once the rhythm is in place. +A codebase in trouble usually has one of two test situations: no tests at all, or a brittle suite written months after the code, mocked-to-the-teeth and useless under change pressure. Rebuild the rhythm first. Then fix the bugs. [Refactoring callbacks back into services](/blog/how-avoid-callbacks-using-services-rails-refactoring/) and tightening the test suite go hand in hand once the rhythm is in place. If you're holding a Rails codebase you can't change without breaking, we run a free 45-minute audit: one senior developer reads your suite and your most recent five PRs, and you get a one-page written assessment naming the three fixes that would help most. We don't follow up to sell you something - that isn't the offer. diff --git a/test/unit/marketing_copy_test.rb b/test/unit/marketing_copy_test.rb index b7d2303d2..f34afe1d8 100644 --- a/test/unit/marketing_copy_test.rb +++ b/test/unit/marketing_copy_test.rb @@ -25,8 +25,13 @@ class MarketingCopyTest < Minitest::Test REPO_ROOT = File.expand_path("../..", __dir__) # Surfaces a prospect actually reads before deciding. content/blog/** is - # excluded (540+ imported posts, audited separately per the dev.to ICP gate) - # and content/clients/** is a KNOWN remaining offender - "to the next level" + # excluded FROM THIS PASS (540+ imported posts, audited separately per the + # dev.to ICP gate) - but it is NOT unguarded: the rendered pass below covers + # blog/**/*.html for the same banned phrases, and the fabrication ratchet at + # the end covers blog SOURCE for invented-client-work shapes. Do not read this + # exclusion as "the blog has no gate". + # + # content/clients/** is a KNOWN remaining offender - "to the next level" # in two case-study excerpts - deferred, not covered here. Add it when that # work is scheduled rather than pretending this gate already covers it. SURFACES = [ @@ -162,6 +167,93 @@ def test_rendered_pages_do_not_regress_on_banned_phrases "reads - fix the source that produced these:\n " + violations.join("\n ") end + + # --------------------------------------------------------------------------- + # Fabricated-claim ratchet over blog SOURCE. + # + # BANNED above is a phrase guard - it catches stale tenure and commodity-agency + # voice. It cannot catch the class claims-canon.md calls "invented client + # work", because a fabricated case study is built from ordinary words. What it + # does have is a STRUCTURE, and structure is greppable. + # + # Why this exists: three successive hand-sweeps on 2026-08-22 each found + # carriers the previous one missed, because each keyed on the wrong surface. A + # "N clients" regex missed everything phrased as a case study; a + # case-study-heading sweep missed everything phrased as "in our experience"; + # both missed frontmatter. That is claims-canon.md's own finding ("manual + # sweeps under-count badly") reproducing itself inside one session. A ratchet + # does not need to recognise a fabrication - it only has to notice the count + # going up. + # + # Markers are SHAPE, not judgement: + # + # - a case-study heading. Real client work belongs in content/clients with a + # named client behind it; a "Case Study" heading inside a technical post has + # been an invented company every time it has been checked. + # - "in our experience" / "the pattern we see" - the recurrence-generalisation + # hatch, which is what a fabricated specific collapses into when someone + # drops the number but keeps the authority. + # - "(figures unverified)" - a number tagged instead of removed. The tag is the + # part a reader skips; the number still does the persuading. + # + # dev.to imports are excluded on the same derivation the rendered pass uses: + # their stats belong to their original authors. That is a TEST-scoping call, + # NOT editorial absolution - those posts are still published on our domain and + # are governed by the separate dev.to ICP gate. + # Headings cannot wrap - Markdown ends them at the newline - so these match + # line by line and report a line number, which is what you want when fixing. + FABRICATION_HEADING_MARKERS = { + /^\#{2,4}\s.*\bcase stud/i => "case-study heading - check there is a real, nameable subject behind it" + }.freeze + + # Prose DOES wrap, and a line-by-line scan never shows the regex a phrase that + # straddles a newline: `/\bin our experience\b/` returns false against + # "in our\nexperience". So these match the whitespace-collapsed document, the + # same reason the rendered pass above collapses before matching. The cost is + # losing the line number; the alternative is a gate with a hole in it. + FABRICATION_PHRASE_MARKERS = { + /\bin our experience\b/i => "recurrence-generalisation - unfalsifiable authority claim", + /\bthe pattern (we see|across the)/i => "recurrence-generalisation - the de-fabrication escape hatch", + /\(figures unverified\)/i => "a tagged number is still a published number" + }.freeze + + # A case-study heading is a SUSPICION, not a verdict - the shape is identical + # whether the subject is invented or real. `async-remote-xp-practices` writes + # up this repository's own CSS migration, which is as verifiable as a case + # study gets: the commits are in this git history. + # + # Adding a line here is a claim that someone checked. The test is whether the + # write-up names a subject a reader could go and verify - this repo, a named + # client, a public postmortem. "A medium-sized content platform" is not a + # subject, and that is the whole difference. + VERIFIED_CASE_STUDIES = { + "content/blog/async-remote-xp-practices/index.md" => ["jt_site CSS Migration"] + }.freeze + + # RATCHET, not a cleanup gate: fails only when the count goes UP. + # + # Measured after the 2026-08-22 purge, which cleared nine posts including the + # two largest carriers by impressions - langgraph (40,025) and propshaft + # (6,194). Survivors are lower-traffic posts not yet swept. + # + # Tighten this every time a batch is cleared. A ratchet left slack lets the win + # regress silently - the rendered baseline above sat at 14 against an actual 11 + # and those three spare hits swallowed a planted phrase whole. Set it to the + # measured count, then prove it is exact by dropping it one lower and watching + # it fail. + # + # 16 survivors, all case-study headings in posts not yet swept. Run the test + # to list them - it prints file:line for every one. + FABRICATION_BASELINE = 16 + + def test_blog_does_not_regress_on_fabricated_claim_markers + hits = fabrication_hits.sort + + assert_operator hits.size, :<=, FABRICATION_BASELINE, + "Fabricated-claim markers in blog source went up (baseline " \ + "#{FABRICATION_BASELINE}, now #{hits.size}). These shapes carry invented " \ + "client work - see .okf/content/claims-canon.md:\n " + hits.join("\n ") + end private def rendered_root @@ -286,4 +378,45 @@ def scrub(line) .gsub(PATH_TOKEN, " ") # slugs and partial names (theme/world-class-training) .gsub(ASSET_TOKEN, " ") end + + def fabrication_hits + posts = blog_source_files + + assert posts.any?, "No blog source found - this gate would pass by finding nothing." + + posts.flat_map do |path| + relative = path.sub("#{REPO_ROOT}/", "") + body = File.read(path, encoding: "bom|utf-8") + + heading_hits(relative, body) + phrase_hits(relative, body) + end + end + + def heading_hits(relative, body) + verified = VERIFIED_CASE_STUDIES.fetch(relative, []) + + body.lines.each_with_index.flat_map do |line, i| + next [] if verified.any? { |subject| line.include?(subject) } + + FABRICATION_HEADING_MARKERS.filter_map do |pattern, reason| + "#{relative}:#{i + 1} - #{reason}" if line.match?(pattern) + end + end + end + + # Collapsed to one line first, so a phrase broken across a wrap still matches. + def phrase_hits(relative, body) + haystack = body.gsub(/\s+/, " ") + + FABRICATION_PHRASE_MARKERS.filter_map do |pattern, reason| + "#{relative} - #{reason}" if haystack.match?(pattern) + end + end + + # Frontmatter lives in the first few lines; bound the read so 689 posts stay cheap. + def blog_source_files + Dir.glob(File.join(REPO_ROOT, "content/blog/**/*.md")).reject do |path| + File.foreach(path).first(60).any? { |l| l.start_with?("source: dev_to") } + end + end end