From 859a14d075aa1c2c77200f3ff6dcf33cbc098349 Mon Sep 17 00:00:00 2001 From: Paul Keen <125715+pftg@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:23:24 +0200 Subject: [PATCH 1/5] Purge fabricated case studies from the three highest-traffic carriers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Executes the standing canon policy (claims-canon.md, 2026-08-20): "extend the purge to ranking legacy posts, highest-impression first." Priority came from a live GSC pull (90d, 2026-05-24 → 2026-08-20, page dimension filtered to /blog/), NOT from how bad each claim looked. That ordering overturned my own starting assumption - see below. Removed, all "invented client work" in the canon's sense (an anonymous company, precise metrics, no client behind it and no possible source): - `rails-8-solid-cache-performance-redis-migration` (**4,891 impr, pos 9.6** - the highest-traffic carrier): two case studies inventing a content platform ($450→$125/month, 72%, 85% hit rate, +12ms) and a retailer ($320/month). Internal link preserved. - `laravel-performance-monitoring-complete-apm-comparison-guide` (**1,729 impr**): a 265-line "Real-World Performance Optimization Case Studies" section with two invented case studies carrying fabricated APM readings (8734ms, 94% DB time, 12456 queries). Its closing line - "the pattern we see repeatedly" - is the recurrence-generalisation shape that is itself a banned de-fabrication hatch. - `rails-event-structured-logging-8-1` (**594 impr, pos 10.5**): the TL;DR claimed "we migrated four production apps and the false-positive alert rate dropped by 60%". The string occurs exactly once in the repo - no source. Replaced with the mechanism claim, which is a property of Rails 8.1 and needs no engagement behind it. Two method notes: 1. **Impressions, not indignation.** I had flagged `how-to-manage-developers-when-you-cant-code` ("200+ times with clients") as the priority because it is `featured: true` and ICP-facing. It has **4 impressions in 90 days**. `featured` is a site-internal flag, not traffic. The canon's own rule caught my error: a fabricated story on a page nobody reads is a liability, on a ranking page it is what a prospect sees first. 2. **The 500-row pull hit its cap (`has_more: true`)** - Trap A. Harmless here only because rank 500 has 1 impression, so anything absent is zero-traffic. Stated rather than assumed. Tradeoff worth naming: the laravel deletion removed working Laravel optimisation code along with the invented framing. The code was persuasive BECAUSE of the fabricated results around it; salvaging it is a separate call. Gate: `bin/hugo-build` green. Content-only diff (markdown prose), so the visual suites correctly do not apply. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016PUkwFTsiv7EB2DYKogbpg --- .../index.md | 265 ------------------ .../index.md | 10 - .../index.md | 2 +- 3 files changed, 1 insertion(+), 276 deletions(-) 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..5fe2b0f81 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,271 +1935,6 @@ $queue_optimization = [ ]; ``` -## Real-World Performance Optimization Case Studies - -Understanding how other teams used APM tools to identify and fix performance bottlenecks provides actionable insights for your own optimization efforts. - -### Case Study 1: E-Commerce Platform Database Optimization - -### 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: - -```php -// Before: Multiple N+1 queries -public function dashboard() -{ - $categories = Category::all(); // 1 query - - foreach ($categories as $category) { - $products = $category->products; // +50 queries - - foreach ($products as $product) { - $reviews = $product->reviews; // +2,345 queries - $images = $product->images; // +2,345 queries - } - } -} - -// After: Optimized eager loading + caching -public function dashboard() -{ - $categories = Cache::remember('dashboard_categories', 600, function () { - return Category::with([ - 'products' => function ($query) { - $query->active() - ->orderBy('featured', 'desc') - ->limit(10); - }, - 'products.reviews' => function ($query) { - $query->latest()->limit(3); - }, - 'products.images' => function ($query) { - $query->orderBy('order')->limit(5); - } - ])->get(); - }); - - return view('dashboard', compact('categories')); -} - -// Added strategic indexes -Schema::table('products', function (Blueprint $table) { - $table->index(['category_id', 'featured', 'active']); -}); - -Schema::table('reviews', function (Blueprint $table) { - $table->index(['product_id', 'created_at']); -}); -``` - -#### Results: - -```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: - -```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 -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 - - return $this->processOrders($orders); -} - -// After: Chunk-based processing -public function generateReport($tenant_id) -{ - $results = []; - - Order::where('tenant_id', $tenant_id) - ->with('items', 'customer', 'payments') - ->chunk(1000, function ($orders) use (&$results) { - $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. - ## FAQ: Laravel Performance Monitoring #### Q: Which APM tool is best for small Laravel teams with limited budget? 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: From 7de032e24d6cd78f13afa02552b6c4501f9ada25 Mon Sep 17 00:00:00 2001 From: Paul Keen <125715+pftg@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:38:40 +0200 Subject: [PATCH 2/5] Sweep the rest: Paul confirmed the first-person claims are invented too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the previous commit, after Paul confirmed all three first-person claims I had flagged were invented rather than real engagements. That verdict made the whole class suspect, so I re-swept - and the re-sweep found the first pass had been looking for the wrong thing. **The candidate regex was the defect.** It keyed on "N clients/companies/times", which misses every fabrication phrased as a case study or as "in our experience". Searching the STRUCTURE instead (`^#{2,4} .*Case Stud`, `Real-World Results`) returned 33 files, and the two biggest carriers were absent from the original list entirely: - `langgraph-workflows-state-machines-ai-agents` - **40,025 impressions**, 8x anything in the first pass. Carried four third-party claims (Uber, LinkedIn, Klarna "80% reduction", AppFolio "2x accuracy") each tagged "(figures unverified)". That tag is a half-measure: the number still does the persuading and the disclaimer is what a reader skips. Also removed a "Download our free Workflow Patterns Library" CTA - there is no download, the templates are listed inline. - `propshaft-vs-sprockets` - **6,194 impressions, pos 10.4**. A 244-line invented case study, plus "in our experience" timing figures (45-60s → under 5s) and an "Our typical results" benchmark table with no measurement behind it. Replaced with the command to measure their own app, which is the number that actually decides their migration. Also cleared: `solid-queue-vs-sidekiq` (50,000+ jobs daily / 35% cost reduction), `rails-8-docker-deployment` (a "B2B SaaS platform with 50,000 active users"), `cost-optimization-llm-applications` (a chatbot with a $3,400/month saving), the AI-integration guide (200+ clients, 3 clients), pgvector (15+ teams), crewai (a duplicated unsourced cost claim), and the three first-person claims Paul confirmed invented. Three recurrence-generalisation openers went too ("the pattern we see most often", "the pattern across the rescues we've taken"). That shape is the default escape hatch when a fabricated specific is removed, and it is banned for exactly that reason - it keeps the authority of experience while shedding the falsifiable part. **Salvage (Paul approved):** the Laravel N+1 and memory-exhaustion fixes are restored as "Two Fixes APM Points You Straight At" - the before/after code kept, the invented companies and measurements dropped, and both now tell the reader to measure their own numbers. Gate: `bin/hugo-build` green. Content-only diff, so the visual suites do not apply. NOT covered, named so it is not mistaken for done: 13 dev.to-sourced posts carry claim-shaped strings (two at 3,285 and 2,934 impressions); the ratchet skips them as the original authors' stats, which is a TEST-scoping decision and not editorial absolution. And `marketing_copy_test.rb` still excludes `content/blog/**`, so nothing prevents regression here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016PUkwFTsiv7EB2DYKogbpg --- ...te-guide-ruby-rails-ai-integration-2025.md | 4 +- ...ils-tutorial-production-semantic-search.md | 2 +- .../index.md | 24 +- .../index.md | 4 +- .../blog/hiring-dev-shop-questions/index.md | 2 +- ...to-manage-developers-when-you-cant-code.md | 2 - .../index.md | 11 +- .../index.md | 103 +++++++ .../index.md | 2 +- .../index.md | 255 +----------------- .../index.md | 14 +- .../index.md | 28 -- .../index.md | 4 +- .../index.md | 2 +- 14 files changed, 123 insertions(+), 334 deletions(-) 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..4601a9168 100644 --- a/content/blog/2025/pgvector-rails-tutorial-production-semantic-search.md +++ b/content/blog/2025/pgvector-rails-tutorial-production-semantic-search.md @@ -1379,7 +1379,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..640fbd845 100644 --- a/content/blog/cost-optimization-llm-applications-token-management/index.md +++ b/content/blog/cost-optimization-llm-applications-token-management/index.md @@ -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 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 5fe2b0f81..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,6 +1935,109 @@ $queue_optimization = [ ]; ``` +## Two Fixes APM Points You Straight At + +APM earns its cost on problems that are invisible in application logs. These are the two you will hit first. + +### N+1 queries behind a slow dashboard + +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: a query per category, then per product, then per relation +public function dashboard() +{ + $categories = Category::all(); + + foreach ($categories as $category) { + $products = $category->products; + + foreach ($products as $product) { + $reviews = $product->reviews; + $images = $product->images; + } + } +} +``` + +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 () { + return Category::with([ + 'products' => function ($query) { + $query->active() + ->orderBy('featured', 'desc') + ->limit(10); + }, + 'products.reviews' => function ($query) { + $query->latest()->limit(3); + }, + 'products.images' => function ($query) { + $query->orderBy('order')->limit(5); + } + ])->get(); + }); + + return view('dashboard', compact('categories')); +} +``` + +Eager loading alone will still scan without the right composite indexes, so add them to match the constraints above: + +```php +Schema::table('products', function (Blueprint $table) { + $table->index(['category_id', 'featured', 'active']); +}); + +Schema::table('reviews', function (Blueprint $table) { + $table->index(['product_id', 'created_at']); +}); +``` + +### Memory exhaustion in report generation + +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 +// 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(); + + return $this->processOrders($orders); +} +``` + +`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 = []; + + Order::where('tenant_id', $tenant_id) + ->with('items', 'customer', 'payments') + ->chunk(1000, function ($orders) use (&$results) { + $processed = $this->processOrders($orders); + $results = array_merge($results, $processed); + + $orders = null; + gc_collect_cycles(); + }); + + return $results; +} +``` + +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 #### Q: Which APM tool is best for small Laravel teams with limited budget? 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..fa5c549f3 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 entirely, so asset precompilation stops being a build step that scales with your asset count. 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/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. From d83b0dd2d63bb6a24be093603bfaecaeeea66c09 Mon Sep 17 00:00:00 2001 From: Paul Keen <125715+pftg@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:52:51 +0200 Subject: [PATCH 3/5] Add a fabricated-claim ratchet so the next sweep is mechanical, not manual Three hand-sweeps in one session 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", and both missed frontmatter. That is claims-canon.md's own finding reproducing itself - "these are ratcheted mechanically, not by review; manual sweeps under-count badly". BANNED cannot catch this class, because a fabricated case study is built from ordinary words. What it has instead is a SHAPE, and shape is greppable: - a case-study heading in a technical post (real client work lives in content/clients with a named client) - "in our experience" / "the pattern we see" - the recurrence-generalisation hatch, what a fabricated specific collapses into when the number is dropped but the authority is kept - "(figures unverified)" - a number tagged rather than removed **Baseline 17, and proven exact.** Set to the measured count, then dropped to 16 to confirm the gate actually fires rather than sitting on slack - it failed with "Expected 17 to be <= 16", then restored. That step is not ceremony: the rendered baseline in this same file sat at 14 against an actual 11, and the three spare hits swallowed a planted phrase whole. 8 of the 17 survivors are ONE deferred decision, not 8 defects: the fractional-CTO posts already fall under Paul's 2026-08-21 ban on fractional-CTO title claims and need a wholesale call (rewrite, redirect or retire). Editing their case-study headings first would bury that decision under a cosmetic fix. Also corrected a comment that had become false and would mislead the next reader: SURFACES says `content/blog/**` is excluded, which is true of THAT pass only - the rendered pass already covers `blog/**/*.html`, and now the fabrication ratchet covers blog source. I repeated that stale reading myself earlier today before checking. It now says so explicitly. Content fixes in the same commit, both found by the frontmatter sweep the markers now make unnecessary: - `pgvector-rails-tutorial` carried an invented anecdote as its OPENING - a named-in-all-but-name CTO, fabricated benchmarks (89ms vs 52ms), "his annual savings: $7,200" - plus "Save $6,000/year vs Pinecone" in the frontmatter description. Frontmatter is published copy; this is the exact defect .okf/log.md records for twitter_description. - `cost-optimization-llm-applications` claimed "30-60% in our experience" in the description, the intro, a summary bullet and the conclusion. Gates: `bundle exec ruby -Itest test/unit/marketing_copy_test.rb` - 4 runs, 10 assertions, 0 failures. `bin/hugo-build` green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016PUkwFTsiv7EB2DYKogbpg --- ...ils-tutorial-production-semantic-search.md | 25 ++--- .../index.md | 8 +- test/unit/marketing_copy_test.rb | 100 +++++++++++++++++- 3 files changed, 111 insertions(+), 22 deletions(-) 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 4601a9168..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 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 640fbd845..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 @@ -1367,7 +1367,7 @@ These strategies compound: caching cuts the request count, routing cuts the per- - 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 @@ -1392,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/test/unit/marketing_copy_test.rb b/test/unit/marketing_copy_test.rb index b7d2303d2..7bfd432fe 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,75 @@ 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. + FABRICATION_MARKERS = { + /^\#{2,4}\s.*\bcase stud/i => "case-study heading - invented client work every time it has been checked", + /\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 + + # 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. + # + # 17 survivors, and 8 of them are one deferred decision rather than 8 defects: + # the fractional-CTO posts (fractional-cto-vs-full-time-cto-complete-comparison, + # fractional-vs-full-time-cto-cost-benefit-analysis, fractional-cto-roi-calculator) + # are already subject to Paul's 2026-08-21 positioning ban on fractional-CTO + # title claims. Those need a wholesale call - rewrite, redirect or retire - and + # editing their case-study headings first would bury that decision under a + # cosmetic fix. Recorded here so the count is legible rather than mysterious. + FABRICATION_BASELINE = 17 + + 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 +360,26 @@ 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}/", "") + File.readlines(path, encoding: "bom|utf-8").each_with_index.flat_map do |line, i| + FABRICATION_MARKERS.filter_map do |pattern, reason| + "#{relative}:#{i + 1} - #{reason}" if line.match?(pattern) + end + end + 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 From b78a3191a83846c4b57ecaf9ec98e96adf040843 Mon Sep 17 00:00:00 2001 From: Paul Keen <125715+pftg@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:57:48 +0200 Subject: [PATCH 4/5] The pipeline had no door that produces a post Paul ran /blog-next repeatedly and never got a post, and concluded the pipeline was broken. It was not broken - it was half-built, and the missing half had no way in. /blog-next never writes, by design: Paul asked for the split. But the delivery half existed only as AGENTS (blog-post-coordinator, blog-batch-orchestrator), which a user cannot invoke by typing. So there was literally no command that produced a post. The split was right; shipping only one side of it was not. Two skills close it: **`blog-write`** - the delivery half, invokable. Owns blog-pipeline.md STEP 4 onward and deliberately does not restate it, so a gate correction lands in one place. Handles UPGRADE as a first-class mode, not an afterthought - the one approved item in the queue right now IS an upgrade, so a writer that only created new posts would still have been unable to do the only queued job. Carries three exits, and explicitly does NOT have HOLD: by the time work reaches it the decision to write has been audited, so "produce nothing" is a falsified premise to hand back, not a quiet non-result. **`blog-operator`** - one door that decides which hand to use, then keeps going (WIP=1, re-decide after each unit). Its ordering is the opinionated part: REPAIR > UPGRADE > WRITE > RESTOCK. 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. An upgrade to a page that already ranks beats a new post starting from zero. Both state plainly that volume is not the goal - a tenth mediocre post dilutes the nine and gives a sceptical reader more surface to find a flaw. The .gitignore trap fired again, exactly as it did for blog-next: both skills were invisible to `git status` until named. `.claude/skills/*` is ignored as claude-flow scaffolding, so every hand-authored skill needs its own negation. Checked with `git status --untracked-files=all` rather than `git check-ignore`, which returns exit 0 either way and reads as confirmation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016PUkwFTsiv7EB2DYKogbpg --- .claude/skills/blog-next/SKILL.md | 13 ++- .claude/skills/blog-operator/SKILL.md | 106 ++++++++++++++++++++++ .claude/skills/blog-write/SKILL.md | 123 ++++++++++++++++++++++++++ .gitignore | 2 + CLAUDE.md | 4 +- 5 files changed, 244 insertions(+), 4 deletions(-) create mode 100644 .claude/skills/blog-operator/SKILL.md create mode 100644 .claude/skills/blog-write/SKILL.md 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/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 `—`. From f46999fc4bfb62bc6a06dbf5f2ec39aa61d8108f Mon Sep 17 00:00:00 2001 From: Paul Keen <125715+pftg@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:11:42 +0200 Subject: [PATCH 5/5] Fix four defects peer review found in the ratchet and the purge /codex:review returned four findings against this branch. All four reproduced against the tree, so all four are fixed here. **False positive: a real case study counted as fabrication.** `async-remote-xp-practices` writes up THIS repository's CSS migration - the commits are in this git history, which is as verifiable as a case study gets - and the heading marker classed it with the invented ones. A case-study heading is a SUSPICION, not a verdict: the shape is identical either way. Verified subjects are now allowlisted by path, and the entry test is whether the write-up names a subject a reader could go and check. "A medium-sized content platform" is not a subject; that is the whole difference. Baseline 17 -> 16, because one of the 17 was never a defect. **False negative: ordinary Markdown wrapping walked straight through the gate.** `/\bin our experience\b/` returns false against `"in our\nexperience"` - verified directly, not assumed. A line-by-line scan therefore has a hole that any re-wrap opens. Prose markers now match the whitespace-collapsed document, which is exactly why the rendered pass in this same file collapses before matching. Heading markers stay line-based: Markdown ends a heading at the newline, and a line number is what you want when fixing one. **A confident replacement is not a fix.** My propshaft rewrite removed an unsourced timing figure and asserted that 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. Trading a fabricated number for an inaccurate mechanism is the worse outcome, because the mechanism reads as reasoning rather than as a claim to check. Also converted two em dashes in the same sentence to hyphens per the voice rule. **The OKF entry the ENFORCED rule requires.** A durable gate landed with its rationale only in test comments. Added `.okf/content/fabrication-ratchet.md` plus its index line and a dated log entry, in this commit rather than a later one. Also removed the baseline comment's reasoning about the fractional-CTO posts - Paul took those off the table entirely, and a comment that argues about them is the opposite of ignoring them. Gates: marketing_copy_test 4 runs / 10 assertions / 0 failures. Ratchet proven exact again at the new count - dropped to 15, failed with "Expected 16 to be <= 15", restored. `bin/hugo-build` green. `okf_validate --strict`: conformant, zero errors. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016PUkwFTsiv7EB2DYKogbpg --- .okf/content/fabrication-ratchet.md | 78 +++++++++++++++++++ .okf/content/index.md | 1 + .okf/log.md | 41 ++++++++++ .../index.md | 2 +- test/unit/marketing_copy_test.rb | 65 ++++++++++++---- 5 files changed, 172 insertions(+), 15 deletions(-) create mode 100644 .okf/content/fabrication-ratchet.md 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/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 fa5c549f3..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. It drops the transpilation and concatenation stages entirely, so asset precompilation stops being a build step that scales with your asset count. 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. diff --git a/test/unit/marketing_copy_test.rb b/test/unit/marketing_copy_test.rb index 7bfd432fe..f34afe1d8 100644 --- a/test/unit/marketing_copy_test.rb +++ b/test/unit/marketing_copy_test.rb @@ -200,13 +200,36 @@ def test_rendered_pages_do_not_regress_on_banned_phrases # 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. - FABRICATION_MARKERS = { - /^\#{2,4}\s.*\bcase stud/i => "case-study heading - invented client work every time it has been checked", + # 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 @@ -219,14 +242,9 @@ def test_rendered_pages_do_not_regress_on_banned_phrases # measured count, then prove it is exact by dropping it one lower and watching # it fail. # - # 17 survivors, and 8 of them are one deferred decision rather than 8 defects: - # the fractional-CTO posts (fractional-cto-vs-full-time-cto-complete-comparison, - # fractional-vs-full-time-cto-cost-benefit-analysis, fractional-cto-roi-calculator) - # are already subject to Paul's 2026-08-21 positioning ban on fractional-CTO - # title claims. Those need a wholesale call - rewrite, redirect or retire - and - # editing their case-study headings first would bury that decision under a - # cosmetic fix. Recorded here so the count is legible rather than mysterious. - FABRICATION_BASELINE = 17 + # 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 @@ -368,14 +386,33 @@ def fabrication_hits posts.flat_map do |path| relative = path.sub("#{REPO_ROOT}/", "") - File.readlines(path, encoding: "bom|utf-8").each_with_index.flat_map do |line, i| - FABRICATION_MARKERS.filter_map do |pattern, reason| - "#{relative}:#{i + 1} - #{reason}" if line.match?(pattern) - end + 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|